3v324v23 commited on
Commit
b0144a1
·
1 Parent(s): 3a5834e

FINAL: 11-bug fix — scoped CSS, session_state persistence, error handling, diff renderer, binary safety

Browse files
Files changed (1) hide show
  1. app.py +448 -123
app.py CHANGED
@@ -1,6 +1,18 @@
1
  """
2
- PR Review Command Center — Final Studio Grade Revision
3
- Theme: Ultra-Clean, High-Contrast & Secure
 
 
 
 
 
 
 
 
 
 
 
 
4
  """
5
 
6
  import streamlit as st
@@ -9,7 +21,9 @@ import os
9
  import httpx
10
  from openai import OpenAI
11
 
12
- # ─── Page Config ──────────────────────────────────────────────────────────────
 
 
13
  st.set_page_config(
14
  page_title="PR Command Center",
15
  page_icon="🛡️",
@@ -17,28 +31,38 @@ st.set_page_config(
17
  initial_sidebar_state="expanded",
18
  )
19
 
20
- # ─── Professional CSS Core ────────────────────────────────────────────────────
 
 
21
  st.markdown("""
22
  <style>
23
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Fira+Code:wght@400;500&display=swap');
24
 
25
- .stApp { background-color: #f8fafc; font-family: 'Inter', sans-serif; }
 
 
 
 
26
 
27
- /* Sidebar: Professional Deep Blue */
28
  [data-testid="stSidebar"] {
29
  background-color: #ffffff !important;
30
  border-right: 1px solid #e2e8f0;
31
  }
32
 
33
- /* Deep High Contrast Text */
34
- h1, h2, h3, p, span, label { color: #0f172a !important; }
35
  .section-label {
36
- font-size: 0.75rem; font-weight: 800; color: #1e293b !important;
37
- text-transform: uppercase; letter-spacing: 0.05rem;
38
- margin-top: 2rem; border-bottom: 2px solid #f1f5f9; padding-bottom: 4px;
 
 
 
 
 
39
  }
40
 
41
- /* Glass Metric Cards */
42
  .metric-card {
43
  background: #ffffff;
44
  border: 1px solid #e2e8f0;
@@ -47,30 +71,40 @@ h1, h2, h3, p, span, label { color: #0f172a !important; }
47
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
48
  }
49
 
50
- /* Badge System */
51
- .badge { padding: 6px 14px; border-radius: 20px; font-size: 0.85rem; font-weight: 800; border: 1px solid transparent; }
52
- .badge-approve { background: #dcfce7; color: #166534 !important; border-color: #bbf7d0; }
53
- .badge-request { background: #fee2e2; color: #991b1b !important; border-color: #fecaca; }
54
- .badge-escalate { background: #fef9c3; color: #854d0e !important; border-color: #fef08a; }
55
- .badge-running { background: #dbeafe; color: #1e40af !important; border-color: #bfdbfe; }
 
 
 
 
 
 
 
56
 
57
- /* Timeline Bubbles */
58
- .bubble { padding: 1.5rem; border-radius: 12px; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; border: 1px solid #e2e8f0; }
 
 
 
 
 
 
 
 
59
  .bubble-reviewer { background: #ffffff; border-left: 6px solid #3b82f6; }
60
- .bubble-author { background: #f1f5f9; border-right: 6px solid #94a3b8; }
61
- .bubble-header { font-size: 0.75rem; font-weight: 800; margin-bottom: 0.75rem; color: #334155; display: flex; align-items: center; gap: 8px; }
62
-
63
- /* Clearer Buttons */
64
- button[kind="primary"] { background-color: #0f172a !important; color: white !important; font-weight: 700 !important; }
65
- button[kind="secondary"] { background-color: #ffffff !important; border: 1px solid #d0d7de !important; }
66
-
67
- /* Metric Values — FORCED VISIBILITY */
68
- .metric-card div:last-child {
69
- color: #0f172a !important; /* Force Black/Blue */
70
- opacity: 1 !important;
71
  }
72
 
73
- /* Diff Container — LIGHT THEME HIGH CONTRAST */
74
  .diff-container {
75
  background: #ffffff;
76
  border-radius: 12px;
@@ -86,158 +120,449 @@ button[kind="secondary"] { background-color: #ffffff !important; border: 1px sol
86
  padding: 12px 20px;
87
  color: #475569;
88
  font-weight: 700;
 
89
  border-bottom: 1px solid #e2e8f0;
90
  }
91
- .diff-line { padding: 2px 20px; white-space: pre-wrap; border-left: 4px solid transparent; }
92
- .diff-line-add { background: #ecfdf5; color: #065f46; border-left-color: #10b981; }
93
- .diff-line-del { background: #fef2f2; color: #991b1b; border-left-color: #ef4444; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
- /* Visibility Fix for Inputs */
96
- .stTextInput input, .stTextArea textarea, .stSelectbox select {
97
  background-color: #ffffff !important;
98
  color: #0f172a !important;
99
  border: 1px solid #cbd5e1 !important;
100
  }
 
 
 
 
101
  </style>
102
  """, unsafe_allow_html=True)
103
 
104
- # ─── Constants ───────────────────────────────────────────────────────────────
 
 
105
  ENV_BASE_URL = "http://localhost:8000"
106
  TASKS = ["single-pass-review", "iterative-negotiation", "escalation-judgment", "custom-review"]
107
 
108
- # ─── Helper Functions ─────────────────────────────────────────────────────────
109
- def get_agent_action(obs, model_id, url, token):
 
 
 
 
 
 
 
 
110
  try:
111
- client = OpenAI(base_url=url, api_key=token)
112
- sys_p = "Senior Engineer. Respond ONLY JSON: {\"decision\":\"approve|request_changes|escalate\", \"comment\":\"...\"}"
113
- hist = "\n".join([f"{h['role'].upper()}: {h['content']}" for h in obs.get("review_history", [])])
114
- prompt = f"PR: {obs.get('pr_title')}\nDiff:\n{obs.get('diff')}\nHistory:\n{hist}\nDecision JSON:"
115
-
116
- resp = client.chat.completions.create(model=model_id, messages=[{"role":"system","content":sys_p},{"role":"user","content":prompt}], temperature=0.1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  raw = resp.choices[0].message.content.strip()
118
- if "```json" in raw: raw = raw.split("```json")[1].split("```")[0]
119
- elif "```" in raw: raw = raw.split("```")[1].split("```")[0]
120
- return json.loads(raw)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  except Exception as e:
122
- return {"decision": "error", "comment": f"⚠️ CONNECTION ERROR: {str(e)}"}
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
- def format_diff_html(diff_text):
125
  lines = diff_text.split("\n")
 
 
126
  filename = "unknown_file"
127
  for line in lines:
128
- if line.startswith("+++ b/"): filename = line.replace("+++ b/", "")
129
- html = [f'<div class="diff-container"><div class="diff-header">{filename}</div>']
 
 
 
 
130
  for line in lines:
131
- cls = "background:rgba(16,185,129,0.1); border-left:4px solid #10b981;" if line.startswith("+") and not line.startswith("+++") else ("background:rgba(239,68,68,0.1); border-left:4px solid #ef4444;" if line.startswith("-") and not line.startswith("---") else "border-left:4px solid transparent;")
132
- html.append(f'<div style="{cls} padding:2px 20px; white-space:pre-wrap;">{line}</div>')
133
- html.append("</div>")
134
- return "\n".join(html)
 
 
 
 
 
 
 
 
 
 
135
 
136
- # ─── Session State ────────────────────────────────────────────────────────────
 
 
 
137
  if "initialized" not in st.session_state:
138
  st.session_state.update({
139
- "initialized": False, "turn": 0, "score": 0.0, "decision": "IDLE",
140
- "observation": {}, "done": False, "reward_history": [],
141
- "api_url": os.getenv("API_BASE_URL", "https://router.huggingface.co/v1"),
142
- "api_token": os.getenv("HF_TOKEN", "")
 
 
 
 
 
 
143
  })
144
 
145
- # ─── Sidebar ───────────────────────────────────────────────────────────────────
 
 
 
146
  with st.sidebar:
147
  st.markdown("### 🔍 PR Command Center")
148
-
 
149
  st.markdown('<div class="section-label">1. Engine Selection</div>', unsafe_allow_html=True)
 
150
  PRESETS = {}
151
- if os.getenv("gemma4"): PRESETS["Gemma 4 IT (Secure Internal)"] = {"id":"google/gemma-4-31b-it", "url":"https://integrate.api.nvidia.com/v1", "token":os.getenv("gemma4")}
152
- if os.getenv("nemotron3"): PRESETS["Nemotron 3 (Secure Internal)"] = {"id":"nvidia/nemotron-3-super-120b-a12b", "url":"https://integrate.api.nvidia.com/v1", "token":os.getenv("nemotron3")}
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  PRESETS.update({
154
- "Qwen 2.5 72B (Hugging Face)": {"id":"Qwen/Qwen2.5-72B-Instruct", "url":"https://router.huggingface.co/v1", "token":None},
155
- "Llama 3 70B (Groq)": {"id":"llama3-70b-8192", "url":"https://api.groq.com/openai/v1", "token":None},
156
- "Custom Endpoint": {"id":"custom", "url":"", "token":None}
 
 
 
 
 
 
 
 
 
 
 
 
157
  })
158
-
159
- selected_p = st.selectbox("Intelligence Engine", list(PRESETS.keys()), label_visibility="collapsed")
160
- conf = PRESETS[selected_p]
161
-
162
- m_id = st.text_input("Model ID", value=conf["id"]) if conf["id"] == "custom" else conf["id"]
163
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  with st.expander("🔑 Credentials", expanded=(conf["token"] is None)):
165
- if conf["token"]:
166
- st.info("🔒 Secure Internal Key Active")
167
- t_url, t_key = conf["url"], conf["token"]
168
  else:
169
- t_url = st.text_input("API URL", value=st.session_state.api_url)
170
- t_key = st.text_input("API Key", type="password", value=st.session_state.api_token)
171
- st.session_state.update({"api_url":t_url, "api_token":t_key})
 
 
 
 
 
 
 
 
172
 
173
- st.markdown('<div class="section-label">2. Task Configuration</div>', unsafe_allow_html=True)
174
  c_title = st.text_input("Scenario Title", value="Feature Implementation")
175
  c_desc = st.text_area("Context", value="Refactoring the user service.")
176
-
177
- # Simple File Picker
178
- all_files = [os.path.join(r, f).replace("./", "") for r, _, fs in os.walk(".") for f in fs if ".git" not in r and "__pycache__" not in r]
179
- sel_f = st.selectbox("Load Code From File", ["-- Select File --"] + sorted(all_files))
180
- loaded_c = ""
181
- if sel_f != "-- Select File --":
182
- with open(sel_f, "r") as f: loaded_c = f.read()
183
- c_diff = st.text_area("Diff Content", value=loaded_c, height=120)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
  if st.button("Apply Custom Context", use_container_width=True):
186
  try:
187
- httpx.post(f"{ENV_BASE_URL}/config/custom", json={"diff":c_diff, "pr_title":c_title, "pr_description":c_desc}, timeout=30)
188
- st.success("Custom Context Applied.")
189
- except: st.error("Sync Error.")
 
 
 
 
 
 
 
 
190
 
 
191
  st.divider()
192
- scen = st.selectbox("Select Scenario", TASKS)
 
193
  if st.button("🚀 INITIALIZE ENVIRONMENT", use_container_width=True, type="primary"):
194
  try:
195
- r = httpx.post(f"{ENV_BASE_URL}/reset", json={"task_name": scen}, timeout=30).json()
196
- st.session_state.update({"initialized":True, "turn":1, "score":0.0, "decision":"IDLE", "observation":r, "reward_history":[], "done":False})
197
- st.rerun()
198
- except: st.error("Engine Connection Failed.")
 
 
 
 
 
 
 
 
 
 
 
 
 
199
 
200
- # ─── Main View ────────────────────────────────────────────────────────────────
 
 
201
  if not st.session_state.initialized:
202
- st.markdown('<div style="text-align:center; padding-top:10rem;"><h1>PR Review Negotiation Arena</h1><p>Configure Engine & Task in the sidebar to start.</p></div>', unsafe_allow_html=True)
 
 
 
 
 
 
203
  st.stop()
204
 
205
- # Header
206
  obs = st.session_state.observation
207
- h1, h2 = st.columns([4, 1])
208
- with h1: st.markdown(f"### {obs.get('pr_title')}")
209
- with h2:
 
 
 
 
210
  d = st.session_state.decision
211
- badge = {"APPROVE":"badge-approve", "REQUEST_CHANGES":"badge-request", "ESCALATE":"badge-escalate"}.get(d, "badge-running")
212
- st.markdown(f'<div style="text-align:right;"><span class="badge {badge}">{d}</span></div>', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
213
 
214
  st.write("")
 
 
215
  m1, m2, m3 = st.columns(3)
216
- with m1: st.markdown(f'<div class="metric-card"><div style="font-weight:800; font-size:0.7rem; color:#64748b;">TOTAL REWARD</div><div style="font-size:1.8rem; font-weight:800;">{st.session_state.score:.2f}</div></div>', unsafe_allow_html=True)
217
- with m2: st.markdown(f'<div class="metric-card"><div style="font-weight:800; font-size:0.7rem; color:#64748b;">TURN</div><div style="font-size:1.8rem; font-weight:800;">{st.session_state.turn} / 3</div></div>', unsafe_allow_html=True)
218
- with m3: st.markdown(f'<div class="metric-card"><div style="font-weight:800; font-size:0.7rem; color:#64748b;">ENGINE</div><div style="font-size:1.2rem; font-weight:700; color:#3b82f6;">{m_id[:20]}...</div></div>', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
  st.write("")
221
- t1, t2 = st.tabs(["📄 Code", "💬 Negotiation"])
222
 
223
- with t1: st.markdown(format_diff_html(obs.get("diff", "")), unsafe_allow_html=True)
 
 
 
 
 
 
 
224
 
225
- with t2:
226
- for item in obs.get("review_history", []):
227
- r = item["role"] == "reviewer"
228
- cl, h = ("bubble-reviewer", "🤖 AI REVIEWER") if r else ("bubble-author", "👨‍💻 AUTHOR")
229
- st.markdown(f'<div class="bubble {cl}"><div class="bubble-header">{h}</div>{item["content"]}</div>', unsafe_allow_html=True)
230
-
 
 
 
 
 
 
 
 
 
 
231
  if not st.session_state.done:
232
  if st.button("▶ EXECUTE NEXT ROUND", use_container_width=True, type="primary"):
233
  with st.spinner("AI analyzing code..."):
234
- action = get_agent_action(obs, m_id, t_url, t_key)
 
 
 
 
 
 
 
235
  if action["decision"] == "error":
236
  st.error(action["comment"])
237
  else:
238
  try:
239
- r = httpx.post(f"{ENV_BASE_URL}/step", json={"action": action}, timeout=30).json()
240
- st.session_state.update({"observation":r["observation"], "score":st.session_state.score+r["reward"], "reward_history":st.session_state.reward_history+[r["reward"]], "done":r["done"], "decision":action["decision"].upper()})
241
- if not st.session_state.done: st.session_state.turn += 1
242
- st.rerun()
243
- except: st.error("Backend Step Failed.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ PR Review Command Center — FINAL PRODUCTION BUILD
3
+ ──────────────────────────────────────────────────
4
+ Every line in this file has been audited. Changes from the audit:
5
+ Fix #1: Removed blanket h1/h2/h3/p/span/label color override (was making diff text invisible)
6
+ Fix #2: Metric card values use explicit inline color:#0f172a (CSS :last-child unreliable)
7
+ Fix #3: format_diff_html() now emits CSS classes, not inline styles
8
+ Fix #4: CSS classes .diff-line-add/.diff-line-del now actually applied in HTML
9
+ Fix #5: m_id stored in session_state for cross-scope reliability
10
+ Fix #6: t_url/t_key initialized BEFORE expander to prevent NameError
11
+ Fix #7: File loader wrapped in try/except for binary files
12
+ Fix #8: All file reads wrapped in try/except
13
+ Fix #9: Bare except: replaced with except Exception as e everywhere
14
+ Fix #10: Reset bare except: replaced with except Exception as e
15
+ Fix #11: Model name truncation is conditional (no false "..." on short names)
16
  """
17
 
18
  import streamlit as st
 
21
  import httpx
22
  from openai import OpenAI
23
 
24
+ # ═══════════════════════════════════════════════════════════════════════════════
25
+ # PAGE CONFIG — Must be the first Streamlit command
26
+ # ═══════════════════════════════════════════════════════════════════════════════
27
  st.set_page_config(
28
  page_title="PR Command Center",
29
  page_icon="🛡️",
 
31
  initial_sidebar_state="expanded",
32
  )
33
 
34
+ # ═══════════════════════════════════════════════════════════════════════════════
35
+ # CSS — Every selector is scoped. No blanket overrides. (Fix #1)
36
+ # ═══════════════════════════════════════════════════════════════════════════════
37
  st.markdown("""
38
  <style>
39
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Fira+Code:wght@400;500&display=swap');
40
 
41
+ /* ── App Background ── */
42
+ .stApp {
43
+ background-color: #f8fafc;
44
+ font-family: 'Inter', sans-serif;
45
+ }
46
 
47
+ /* ── Sidebar ── */
48
  [data-testid="stSidebar"] {
49
  background-color: #ffffff !important;
50
  border-right: 1px solid #e2e8f0;
51
  }
52
 
53
+ /* ── Section Labels (sidebar only) ── */
 
54
  .section-label {
55
+ font-size: 0.75rem;
56
+ font-weight: 800;
57
+ color: #1e293b;
58
+ text-transform: uppercase;
59
+ letter-spacing: 0.05rem;
60
+ margin-top: 2rem;
61
+ border-bottom: 2px solid #f1f5f9;
62
+ padding-bottom: 4px;
63
  }
64
 
65
+ /* ── Metric Cards ── */
66
  .metric-card {
67
  background: #ffffff;
68
  border: 1px solid #e2e8f0;
 
71
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
72
  }
73
 
74
+ /* ── Status Badges ── */
75
+ .badge {
76
+ display: inline-block;
77
+ padding: 6px 14px;
78
+ border-radius: 20px;
79
+ font-size: 0.85rem;
80
+ font-weight: 800;
81
+ border: 1px solid transparent;
82
+ }
83
+ .badge-approve { background: #dcfce7; color: #166534; border-color: #bbf7d0; }
84
+ .badge-request { background: #fee2e2; color: #991b1b; border-color: #fecaca; }
85
+ .badge-escalate { background: #fef9c3; color: #854d0e; border-color: #fef08a; }
86
+ .badge-running { background: #dbeafe; color: #1e40af; border-color: #bfdbfe; }
87
 
88
+ /* ── Timeline Chat Bubbles ── */
89
+ .bubble {
90
+ padding: 1.5rem;
91
+ border-radius: 12px;
92
+ font-size: 0.95rem;
93
+ line-height: 1.6;
94
+ margin-bottom: 1.5rem;
95
+ border: 1px solid #e2e8f0;
96
+ color: #1e293b;
97
+ }
98
  .bubble-reviewer { background: #ffffff; border-left: 6px solid #3b82f6; }
99
+ .bubble-author { background: #f1f5f9; border-right: 6px solid #94a3b8; }
100
+ .bubble-header {
101
+ font-size: 0.75rem;
102
+ font-weight: 800;
103
+ margin-bottom: 0.75rem;
104
+ color: #334155;
 
 
 
 
 
105
  }
106
 
107
+ /* ── Diff Container — LIGHT THEME (Fix #3, #4) ── */
108
  .diff-container {
109
  background: #ffffff;
110
  border-radius: 12px;
 
120
  padding: 12px 20px;
121
  color: #475569;
122
  font-weight: 700;
123
+ font-size: 0.85rem;
124
  border-bottom: 1px solid #e2e8f0;
125
  }
126
+ .diff-line {
127
+ padding: 3px 20px;
128
+ white-space: pre-wrap;
129
+ word-break: break-all;
130
+ border-left: 4px solid transparent;
131
+ color: #334155;
132
+ font-size: 0.85rem;
133
+ line-height: 1.5;
134
+ }
135
+ .diff-line-add {
136
+ background: #ecfdf5;
137
+ color: #065f46;
138
+ border-left-color: #10b981;
139
+ }
140
+ .diff-line-del {
141
+ background: #fef2f2;
142
+ color: #991b1b;
143
+ border-left-color: #ef4444;
144
+ }
145
 
146
+ /* ── Input Visibility ── */
147
+ .stTextInput input, .stTextArea textarea {
148
  background-color: #ffffff !important;
149
  color: #0f172a !important;
150
  border: 1px solid #cbd5e1 !important;
151
  }
152
+
153
+ /* ── Hide Streamlit chrome ── */
154
+ #MainMenu { visibility: hidden; }
155
+ footer { visibility: hidden; }
156
  </style>
157
  """, unsafe_allow_html=True)
158
 
159
+ # ═══════════════════════════════════════════════════════════════════════════════
160
+ # CONSTANTS
161
+ # ═══════════════════════════════════════════════════════════════════════════════
162
  ENV_BASE_URL = "http://localhost:8000"
163
  TASKS = ["single-pass-review", "iterative-negotiation", "escalation-judgment", "custom-review"]
164
 
165
+ # ═══════════════════════════════════════════════════════════════════════════════
166
+ # HELPER: AI Agent Action
167
+ # ═══════════════════════════════════════════════════════════════════════════════
168
+ def get_agent_action(obs: dict, model_id: str, api_url: str, api_key: str) -> dict:
169
+ """
170
+ Calls the LLM to produce a review decision.
171
+ Returns {"decision": "...", "comment": "..."} on success.
172
+ Returns {"decision": "error", "comment": "..."} on any failure.
173
+ The caller MUST check for decision=="error" before stepping the env.
174
+ """
175
  try:
176
+ client = OpenAI(base_url=api_url, api_key=api_key)
177
+
178
+ system_prompt = (
179
+ 'You are a senior software engineer performing a pull request code review.\n'
180
+ 'Respond with ONLY this JSON (no markdown, no extra text):\n'
181
+ '{"decision": "approve|request_changes|escalate", "comment": "your review"}'
182
+ )
183
+
184
+ # Build history string
185
+ history_lines = []
186
+ for h in obs.get("review_history", []):
187
+ history_lines.append(f"{h['role'].upper()}: {h['content']}")
188
+ history_str = "\n".join(history_lines) if history_lines else "None yet."
189
+
190
+ user_prompt = (
191
+ f"PR Title: {obs.get('pr_title', 'N/A')}\n"
192
+ f"PR Description: {obs.get('pr_description', 'N/A')}\n\n"
193
+ f"Diff:\n{obs.get('diff', 'No diff')}\n\n"
194
+ f"Review History:\n{history_str}\n\n"
195
+ f"Author's latest response: {obs.get('author_response') or 'N/A'}\n\n"
196
+ f"Submit your review decision as JSON:"
197
+ )
198
+
199
+ resp = client.chat.completions.create(
200
+ model=model_id,
201
+ messages=[
202
+ {"role": "system", "content": system_prompt},
203
+ {"role": "user", "content": user_prompt},
204
+ ],
205
+ max_tokens=500,
206
+ temperature=0.1,
207
+ )
208
+
209
  raw = resp.choices[0].message.content.strip()
210
+
211
+ # Strip markdown fences if the model wrapped its response
212
+ if "```json" in raw:
213
+ raw = raw.split("```json")[1].split("```")[0]
214
+ elif "```" in raw:
215
+ raw = raw.split("```")[1].split("```")[0]
216
+
217
+ raw = raw.strip()
218
+ parsed = json.loads(raw)
219
+
220
+ # Validate required keys exist
221
+ if "decision" not in parsed or "comment" not in parsed:
222
+ return {"decision": "error", "comment": "Model returned JSON without 'decision' or 'comment' keys."}
223
+
224
+ return parsed
225
+
226
+ except json.JSONDecodeError as e:
227
+ return {"decision": "error", "comment": f"Model returned invalid JSON: {e}"}
228
  except Exception as e:
229
+ return {"decision": "error", "comment": f"API Error: {e}"}
230
+
231
+
232
+ # ═══════════════════════════════════════════════════════════════════════════════
233
+ # HELPER: Diff Renderer — uses CSS classes, NOT inline styles (Fix #3, #4)
234
+ # ═══════════════════════════════════════════════════════════════════════════════
235
+ def format_diff_html(diff_text: str) -> str:
236
+ """
237
+ Converts a unified diff string into styled HTML.
238
+ Uses CSS classes .diff-line, .diff-line-add, .diff-line-del defined above.
239
+ """
240
+ if not diff_text or not diff_text.strip():
241
+ return '<div class="diff-container"><div class="diff-header">No diff available</div></div>'
242
 
 
243
  lines = diff_text.split("\n")
244
+
245
+ # Extract filename from +++ header
246
  filename = "unknown_file"
247
  for line in lines:
248
+ if line.startswith("+++ b/"):
249
+ filename = line[6:] # strip "+++ b/" prefix
250
+ break
251
+
252
+ html_parts = [f'<div class="diff-container"><div class="diff-header">{filename}</div>']
253
+
254
  for line in lines:
255
+ # Determine CSS class based on line prefix
256
+ if line.startswith("+") and not line.startswith("+++"):
257
+ css_class = "diff-line diff-line-add"
258
+ elif line.startswith("-") and not line.startswith("---"):
259
+ css_class = "diff-line diff-line-del"
260
+ else:
261
+ css_class = "diff-line"
262
+
263
+ # Escape HTML entities to prevent XSS and rendering issues
264
+ safe_line = line.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
265
+ html_parts.append(f'<div class="{css_class}">{safe_line}</div>')
266
+
267
+ html_parts.append("</div>")
268
+ return "\n".join(html_parts)
269
 
270
+
271
+ # ═══════════════════════════════════════════════════════════════════════════════
272
+ # SESSION STATE — Single initialization block
273
+ # ═══════════════════════════════════════════════════════════════════════════════
274
  if "initialized" not in st.session_state:
275
  st.session_state.update({
276
+ "initialized": False,
277
+ "turn": 0,
278
+ "score": 0.0,
279
+ "decision": "IDLE",
280
+ "observation": {},
281
+ "done": False,
282
+ "reward_history": [],
283
+ "active_model_id": "", # Fix #5: persisted model ID
284
+ "active_api_url": "", # Fix #6: persisted API URL
285
+ "active_api_key": "", # Fix #6: persisted API key
286
  })
287
 
288
+
289
+ # ═══════════════════════════════════════════════════════════════════════════════
290
+ # SIDEBAR
291
+ # ═══════════════════════════════════════════════════════════════════════════════
292
  with st.sidebar:
293
  st.markdown("### 🔍 PR Command Center")
294
+
295
+ # ── Section 1: Engine Selection ──
296
  st.markdown('<div class="section-label">1. Engine Selection</div>', unsafe_allow_html=True)
297
+
298
  PRESETS = {}
299
+ # Internal secrets: only show if env var exists
300
+ if os.getenv("gemma4"):
301
+ PRESETS["Gemma 4 IT (Secure)"] = {
302
+ "id": "google/gemma-4-31b-it",
303
+ "url": "https://integrate.api.nvidia.com/v1",
304
+ "token": os.getenv("gemma4"),
305
+ }
306
+ if os.getenv("nemotron3"):
307
+ PRESETS["Nemotron 3 (Secure)"] = {
308
+ "id": "nvidia/nemotron-3-super-120b-a12b",
309
+ "url": "https://integrate.api.nvidia.com/v1",
310
+ "token": os.getenv("nemotron3"),
311
+ }
312
+
313
+ # Public presets
314
  PRESETS.update({
315
+ "Qwen 2.5 72B (Hugging Face)": {
316
+ "id": "Qwen/Qwen2.5-72B-Instruct",
317
+ "url": "https://router.huggingface.co/v1",
318
+ "token": None,
319
+ },
320
+ "Llama 3 70B (Groq)": {
321
+ "id": "llama3-70b-8192",
322
+ "url": "https://api.groq.com/openai/v1",
323
+ "token": None,
324
+ },
325
+ "Custom Endpoint": {
326
+ "id": "custom",
327
+ "url": "",
328
+ "token": None,
329
+ },
330
  })
331
+
332
+ selected_preset_name = st.selectbox(
333
+ "Select Model", list(PRESETS.keys()), label_visibility="collapsed"
334
+ )
335
+ conf = PRESETS[selected_preset_name]
336
+
337
+ # Model ID: editable only for "Custom Endpoint"
338
+ if conf["id"] == "custom":
339
+ current_model_id = st.text_input("Model ID", value="Qwen/Qwen2.5-72B-Instruct")
340
+ else:
341
+ current_model_id = conf["id"]
342
+
343
+ # ── Fix #6: Set t_url / t_key BEFORE the expander ──
344
+ # Default values come from the preset. These get overridden inside
345
+ # the expander if it renders (for non-internal presets).
346
+ if conf["token"] is not None:
347
+ # Internal preset: use its hardcoded URL and secret token
348
+ current_api_url = conf["url"]
349
+ current_api_key = conf["token"]
350
+ else:
351
+ # External preset: use session_state defaults (may be overridden below)
352
+ current_api_url = os.getenv("API_BASE_URL", conf["url"] or "https://router.huggingface.co/v1")
353
+ current_api_key = os.getenv("HF_TOKEN", "")
354
+
355
+ # Credentials expander
356
  with st.expander("🔑 Credentials", expanded=(conf["token"] is None)):
357
+ if conf["token"] is not None:
358
+ st.info("🔒 Secure internal key active. No manual config needed.")
 
359
  else:
360
+ # Let user edit URL and key
361
+ current_api_url = st.text_input("API URL", value=current_api_url)
362
+ current_api_key = st.text_input("API Key", type="password", value=current_api_key)
363
+
364
+ # Fix #5: Persist to session_state so they survive reruns
365
+ st.session_state.active_model_id = current_model_id
366
+ st.session_state.active_api_url = current_api_url
367
+ st.session_state.active_api_key = current_api_key
368
+
369
+ # ── Section 2: Task Configuration ──
370
+ st.markdown('<div class="section-label">2. Custom Review Config</div>', unsafe_allow_html=True)
371
 
 
372
  c_title = st.text_input("Scenario Title", value="Feature Implementation")
373
  c_desc = st.text_area("Context", value="Refactoring the user service.")
374
+
375
+ # File picker — filters out common binary extensions (Fix #7)
376
+ BINARY_EXTS = {".pyc", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".zip", ".tar", ".gz", ".webp", ".mp4", ".pdf"}
377
+ all_files = []
378
+ for root, _dirs, files in os.walk("."):
379
+ if ".git" in root or "__pycache__" in root:
380
+ continue
381
+ for fname in files:
382
+ if os.path.splitext(fname)[1].lower() in BINARY_EXTS:
383
+ continue
384
+ all_files.append(os.path.join(root, fname).replace("./", ""))
385
+ all_files.sort()
386
+
387
+ sel_file = st.selectbox("Load Code From File", ["-- Select File --"] + all_files)
388
+
389
+ loaded_content = ""
390
+ if sel_file != "-- Select File --":
391
+ try: # Fix #8: Protect against encoding errors on unexpected files
392
+ with open(sel_file, "r", encoding="utf-8", errors="replace") as f:
393
+ loaded_content = f.read()
394
+ except Exception as e:
395
+ st.warning(f"Could not read file: {e}")
396
+
397
+ c_diff = st.text_area("Diff Content", value=loaded_content, height=120)
398
 
399
  if st.button("Apply Custom Context", use_container_width=True):
400
  try:
401
+ resp = httpx.post(
402
+ f"{ENV_BASE_URL}/config/custom",
403
+ json={"diff": c_diff, "pr_title": c_title, "pr_description": c_desc},
404
+ timeout=30,
405
+ )
406
+ if resp.status_code == 200:
407
+ st.success("Custom context applied. Select 'custom-review' below.")
408
+ else:
409
+ st.error(f"Backend returned status {resp.status_code}")
410
+ except Exception as e: # Fix #9: Show actual error
411
+ st.error(f"Sync Error: {e}")
412
 
413
+ # ── Section 3: Scenario & Launch ──
414
  st.divider()
415
+ scenario = st.selectbox("Select Scenario", TASKS)
416
+
417
  if st.button("🚀 INITIALIZE ENVIRONMENT", use_container_width=True, type="primary"):
418
  try:
419
+ r = httpx.post(f"{ENV_BASE_URL}/reset", json={"task_name": scenario}, timeout=30)
420
+ if r.status_code != 200:
421
+ st.error(f"Backend error: {r.status_code} — {r.text}")
422
+ else:
423
+ st.session_state.update({
424
+ "initialized": True,
425
+ "turn": 1,
426
+ "score": 0.0,
427
+ "decision": "IDLE",
428
+ "observation": r.json(),
429
+ "reward_history": [],
430
+ "done": False,
431
+ })
432
+ st.rerun()
433
+ except Exception as e: # Fix #10: Show actual error
434
+ st.error(f"Engine connection failed: {e}")
435
+
436
 
437
+ # ═══════════════════════════════════════════════════════════════════════════════
438
+ # MAIN VIEW — Only renders after initialization
439
+ # ═══════════════════════════════════════════════════════════════════════════════
440
  if not st.session_state.initialized:
441
+ st.markdown(
442
+ '<div style="text-align:center; padding-top:10rem;">'
443
+ '<h1 style="color:#0f172a; font-size:2.5rem; font-weight:800;">PR Review Negotiation Arena</h1>'
444
+ '<p style="color:#475569; font-size:1.1rem;">Configure Engine & Task in the sidebar, then click Initialize.</p>'
445
+ '</div>',
446
+ unsafe_allow_html=True,
447
+ )
448
  st.stop()
449
 
450
+ # ── Read state ──
451
  obs = st.session_state.observation
452
+ active_model = st.session_state.active_model_id # Fix #5: read from session_state
453
+
454
+ # ── Header row ──
455
+ col_title, col_badge = st.columns([4, 1])
456
+ with col_title:
457
+ st.markdown(f"### {obs.get('pr_title', 'Untitled PR')}")
458
+ with col_badge:
459
  d = st.session_state.decision
460
+ badge_map = {
461
+ "APPROVE": "badge-approve",
462
+ "REQUEST_CHANGES": "badge-request",
463
+ "ESCALATE": "badge-escalate",
464
+ }
465
+ badge_cls = badge_map.get(d, "badge-running")
466
+ st.markdown(
467
+ f'<div style="text-align:right; padding-top:0.5rem;">'
468
+ f'<span class="badge {badge_cls}">{d}</span></div>',
469
+ unsafe_allow_html=True,
470
+ )
471
 
472
  st.write("")
473
+
474
+ # ── Metric Cards — explicit inline colors (Fix #2) ──
475
  m1, m2, m3 = st.columns(3)
476
+ with m1:
477
+ st.markdown(
478
+ f'<div class="metric-card">'
479
+ f'<div style="font-weight:800; font-size:0.7rem; color:#64748b;">TOTAL REWARD</div>'
480
+ f'<div style="font-size:1.8rem; font-weight:800; color:#0f172a;">{st.session_state.score:.2f}</div>'
481
+ f'</div>',
482
+ unsafe_allow_html=True,
483
+ )
484
+ with m2:
485
+ st.markdown(
486
+ f'<div class="metric-card">'
487
+ f'<div style="font-weight:800; font-size:0.7rem; color:#64748b;">TURN</div>'
488
+ f'<div style="font-size:1.8rem; font-weight:800; color:#0f172a;">{st.session_state.turn} / 3</div>'
489
+ f'</div>',
490
+ unsafe_allow_html=True,
491
+ )
492
+ with m3:
493
+ # Fix #11: Conditional truncation — only add "..." if name is actually long
494
+ display_name = active_model if len(active_model) <= 25 else active_model[:22] + "..."
495
+ st.markdown(
496
+ f'<div class="metric-card">'
497
+ f'<div style="font-weight:800; font-size:0.7rem; color:#64748b;">ENGINE</div>'
498
+ f'<div style="font-size:1.1rem; font-weight:700; color:#3b82f6;">{display_name}</div>'
499
+ f'</div>',
500
+ unsafe_allow_html=True,
501
+ )
502
 
503
  st.write("")
 
504
 
505
+ # ── Tabs: Code View + Negotiation Timeline ──
506
+ tab_code, tab_nego = st.tabs(["📄 Code View", "💬 Negotiation"])
507
+
508
+ with tab_code:
509
+ st.markdown(format_diff_html(obs.get("diff", "")), unsafe_allow_html=True)
510
+
511
+ with tab_nego:
512
+ history = obs.get("review_history", [])
513
 
514
+ if not history:
515
+ st.info("No review activity yet. Click the button below to start the first round.")
516
+
517
+ for item in history:
518
+ is_reviewer = item["role"] == "reviewer"
519
+ bubble_cls = "bubble-reviewer" if is_reviewer else "bubble-author"
520
+ header_text = "🤖 AI REVIEWER" if is_reviewer else "👨‍💻 AUTHOR"
521
+ st.markdown(
522
+ f'<div class="bubble {bubble_cls}">'
523
+ f'<div class="bubble-header">{header_text}</div>'
524
+ f'{item["content"]}'
525
+ f'</div>',
526
+ unsafe_allow_html=True,
527
+ )
528
+
529
+ # ── Action Button ──
530
  if not st.session_state.done:
531
  if st.button("▶ EXECUTE NEXT ROUND", use_container_width=True, type="primary"):
532
  with st.spinner("AI analyzing code..."):
533
+ action = get_agent_action(
534
+ obs,
535
+ st.session_state.active_model_id, # Fix #5
536
+ st.session_state.active_api_url, # Fix #6
537
+ st.session_state.active_api_key, # Fix #6
538
+ )
539
+
540
+ # Guard: If the LLM call failed, do NOT step the environment
541
  if action["decision"] == "error":
542
  st.error(action["comment"])
543
  else:
544
  try:
545
+ step_resp = httpx.post(
546
+ f"{ENV_BASE_URL}/step",
547
+ json={"action": action},
548
+ timeout=30,
549
+ )
550
+ if step_resp.status_code != 200:
551
+ st.error(f"Backend step error: {step_resp.status_code} — {step_resp.text}")
552
+ else:
553
+ result = step_resp.json()
554
+ new_reward = result["reward"]
555
+ st.session_state.update({
556
+ "observation": result["observation"],
557
+ "score": st.session_state.score + new_reward,
558
+ "reward_history": st.session_state.reward_history + [new_reward],
559
+ "done": result["done"],
560
+ "decision": action["decision"].upper(),
561
+ })
562
+ if not st.session_state.done:
563
+ st.session_state.turn += 1
564
+ st.rerun()
565
+ except Exception as e: # Fix #9: Show actual error
566
+ st.error(f"Backend step failed: {e}")
567
+ else:
568
+ st.success(f"Episode complete. Final reward: {st.session_state.score:.2f}")