rufimelo commited on
Commit
f2093c8
Β·
verified Β·
1 Parent(s): da37d7b

Upload trajectory viewer

Browse files
Files changed (1) hide show
  1. app.py +830 -0
app.py ADDED
@@ -0,0 +1,830 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Streamlit trajectory viewer for github_red .eval log files.
2
+
3
+ Run with:
4
+ streamlit run scripts/trajectory_viewer.py
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import glob
10
+ import json
11
+ import re
12
+ import zipfile
13
+ from pathlib import Path
14
+
15
+ import streamlit as st
16
+
17
+ LOGS_DIR = Path(__file__).parent.parent / "logs" / "github_red"
18
+
19
+ # ── Colours & labels ────────────────────────────────────────────────────────
20
+
21
+ ROLE_COLOR = {
22
+ "profiler": "#0F766E",
23
+ "decomposer": "#7C3AED",
24
+ "executor": "#2563EB",
25
+ "target": "#DC2626",
26
+ "reviewer": "#DC2626",
27
+ "auditor": "#B45309",
28
+ "classifier": "#D97706",
29
+ }
30
+ ROLE_EMOJI = {
31
+ "profiler": "πŸ”¬",
32
+ "decomposer": "🧩",
33
+ "executor": "βš™οΈ",
34
+ "target": "πŸ›‘οΈ",
35
+ "reviewer": "πŸ”Ž",
36
+ "auditor": "🎯",
37
+ "classifier": "πŸ”",
38
+ }
39
+ TOOL_COLOR = {
40
+ "github_create_branch": "#0891B2",
41
+ "github_write_file": "#0891B2",
42
+ "github_create_pull_request": "#0891B2",
43
+ "transfer_to_target": "#7C3AED",
44
+ "github_list_pr_files": "#DC2626",
45
+ "github_read_file": "#DC2626",
46
+ "github_approve_pull_request": "#16A34A",
47
+ "github_add_pr_comment": "#DC2626",
48
+ "github_list_files": "#0284C7",
49
+ "bash": "#854D0E",
50
+ "submit": "#374151",
51
+ "decompose": "#7C3AED",
52
+ "think": "#9CA3AF",
53
+ "submit_profile": "#0F766E",
54
+ "get_repository_analysis": "#0F766E",
55
+ }
56
+
57
+ # ── Helpers ──────────────────────────────────────────────────────────────────
58
+
59
+
60
+ def _resolve(val: object, atts: dict) -> object:
61
+ if isinstance(val, str) and val.startswith("attachment://"):
62
+ key = val[len("attachment://") :]
63
+ resolved = atts.get(key, val)
64
+ return resolved if isinstance(resolved, (str, dict, list)) else val
65
+ if isinstance(val, dict):
66
+ return {k: _resolve(v, atts) for k, v in val.items()}
67
+ if isinstance(val, list):
68
+ return [_resolve(v, atts) for v in val]
69
+ return val
70
+
71
+
72
+ def _text(content: object) -> str:
73
+ if isinstance(content, str):
74
+ return content
75
+ if isinstance(content, list):
76
+ parts = []
77
+ for p in content:
78
+ if isinstance(p, dict) and p.get("type") == "text":
79
+ parts.append(p.get("text", ""))
80
+ elif isinstance(p, str):
81
+ parts.append(p)
82
+ return "".join(parts)
83
+ return ""
84
+
85
+
86
+ def _parse_args(raw: object) -> dict:
87
+ if isinstance(raw, str):
88
+ try:
89
+ return json.loads(raw)
90
+ except Exception:
91
+ return {"raw": raw}
92
+ return raw if isinstance(raw, dict) else {}
93
+
94
+
95
+ # ── Event parsing ─────────────────────────────────────────────────────────────
96
+
97
+
98
+ def _parse_pr_files(text: str) -> list[dict]:
99
+ """Parse github_list_pr_files output into a list of {path, content} dicts.
100
+
101
+ The format is:
102
+ === path/to/file.py ===
103
+ <file content>
104
+
105
+ === another/file.yml ===
106
+ <file content>
107
+ """
108
+ files = []
109
+ # Split on === ... === headers
110
+ parts = re.split(r"^=== (.+?) ===$", text, flags=re.MULTILINE)
111
+ # parts = ["preamble", "path1", "content1", "path2", "content2", ...]
112
+ it = iter(parts[1:]) # skip preamble
113
+ for path in it:
114
+ content = next(it, "").strip()
115
+ if path.strip():
116
+ files.append({"path": path.strip(), "content": content})
117
+ return files
118
+
119
+
120
+ def _lang_for(path: str) -> str:
121
+ """Return a Streamlit/Pygments language hint for syntax highlighting."""
122
+ ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
123
+ return {
124
+ "py": "python",
125
+ "yml": "yaml",
126
+ "yaml": "yaml",
127
+ "sh": "bash",
128
+ "bash": "bash",
129
+ "json": "json",
130
+ "js": "javascript",
131
+ "ts": "typescript",
132
+ "md": "markdown",
133
+ "txt": "text",
134
+ "toml": "toml",
135
+ "dockerfile": "dockerfile",
136
+ "tf": "hcl",
137
+ }.get(ext, "text")
138
+
139
+
140
+ def parse_events(events: list, atts: dict) -> list:
141
+ """Return a flat list of structured step dicts."""
142
+ steps = []
143
+ subtask = 0
144
+ _seen_pr_sigs: set[str] = set()
145
+ _profiler_phase_emitted = False
146
+ _decomp_phase_emitted = False
147
+ # Track first appearance of each role per subtask for nav anchors
148
+ _seen_role_in_subtask: set[tuple] = set()
149
+ _anchor_counter = 0
150
+
151
+ def _next_anchor() -> str:
152
+ nonlocal _anchor_counter
153
+ _anchor_counter += 1
154
+ return f"step-{_anchor_counter}"
155
+
156
+ for e in events:
157
+ ev = e.get("event", "")
158
+
159
+ # ── Info banners ──────────────────────────────────────────────
160
+ if ev == "info":
161
+ raw = e.get("data", "")
162
+ text = _resolve(raw, atts) if isinstance(raw, str) else str(raw)
163
+ if not isinstance(text, str):
164
+ text = json.dumps(text)
165
+
166
+ # Detect subtask transitions
167
+ m = re.search(r"Subtask (\d+)", text)
168
+ if m and ("context for" in text or "Starting" in text):
169
+ subtask = int(m.group(1))
170
+ anchor = f"subtask-{subtask}"
171
+ steps.append(
172
+ {
173
+ "kind": "subtask_divider",
174
+ "subtask": subtask,
175
+ "anchor": anchor,
176
+ "nav_label": f"Subtask {subtask}",
177
+ "nav_color": "#2563EB",
178
+ "nav_indent": 0,
179
+ }
180
+ )
181
+ continue
182
+
183
+ # Suppress noisy attachment / memory lines (but keep profiler banners)
184
+ if text.startswith("attachment://"):
185
+ continue
186
+ if text.startswith("●") and "[profiler]" not in text:
187
+ continue
188
+ if "=================" in text:
189
+ continue
190
+
191
+ steps.append({"kind": "info", "text": text, "subtask": subtask})
192
+
193
+ # ── Model turns ───────────────────────────────────────────────
194
+ elif ev == "model":
195
+ role = e.get("role", "unknown")
196
+
197
+ # Emit a one-time phase divider when the profiler starts
198
+ if role == "profiler" and not _profiler_phase_emitted:
199
+ _profiler_phase_emitted = True
200
+ steps.append(
201
+ {
202
+ "kind": "phase",
203
+ "label": "πŸ”¬ Profiler Phase",
204
+ "subtask": 0,
205
+ "anchor": "phase-profiler",
206
+ "nav_label": "πŸ”¬ Profiler",
207
+ "nav_color": "#0F766E",
208
+ "nav_indent": 0,
209
+ }
210
+ )
211
+
212
+ if role == "decomposer" and not _decomp_phase_emitted:
213
+ _decomp_phase_emitted = True
214
+ steps.append(
215
+ {
216
+ "kind": "phase",
217
+ "label": "🧩 Decomposition Phase",
218
+ "subtask": 0,
219
+ "anchor": "phase-decomposer",
220
+ "nav_label": "🧩 Decomposer",
221
+ "nav_color": "#7C3AED",
222
+ "nav_indent": 0,
223
+ }
224
+ )
225
+
226
+ model = e.get("model", "")
227
+ out = e.get("output") or {}
228
+ choices = out.get("choices", [])
229
+ msg = choices[0].get("message", {}) if choices else {}
230
+
231
+ text = _text(msg.get("content", ""))
232
+ raw_tcs = msg.get("tool_calls") or []
233
+ tool_calls = []
234
+ for tc in raw_tcs:
235
+ fn = tc.get("function", "")
236
+ args = _resolve(_parse_args(tc.get("arguments", {})), atts)
237
+ tool_calls.append({"fn": fn, "args": args})
238
+
239
+ # Assign nav anchor on first appearance of executor/reviewer per subtask
240
+ anchor = None
241
+ nav_label = None
242
+ nav_color = None
243
+ nav_indent = None
244
+ role_key = (role, subtask)
245
+ if (
246
+ role in ("executor", "reviewer", "target", "auditor")
247
+ and role_key not in _seen_role_in_subtask
248
+ ):
249
+ _seen_role_in_subtask.add(role_key)
250
+ anchor = _next_anchor()
251
+ emoji = ROLE_EMOJI.get(role, "πŸ€–")
252
+ suffix = f" (subtask {subtask})" if subtask else ""
253
+ nav_label = f"{emoji} {role.capitalize()}{suffix}"
254
+ nav_color = ROLE_COLOR.get(role, "#6B7280")
255
+ nav_indent = 1
256
+
257
+ step: dict = {
258
+ "kind": "model",
259
+ "role": role,
260
+ "model": model,
261
+ "text": text,
262
+ "tool_calls": tool_calls,
263
+ "subtask": subtask,
264
+ }
265
+ if anchor:
266
+ step["anchor"] = anchor
267
+ step["nav_label"] = nav_label
268
+ step["nav_color"] = nav_color
269
+ step["nav_indent"] = nav_indent
270
+
271
+ steps.append(step)
272
+
273
+ # ── PR file contents seen by reviewer/target ───────────────
274
+ # Parse github_list_pr_files results from this model's input messages
275
+ if role in ("target", "reviewer"):
276
+ inp = e.get("input", [])
277
+ for m in inp:
278
+ if m.get("role") != "tool":
279
+ continue
280
+ fn = m.get("function") or m.get("name", "")
281
+ if fn != "github_list_pr_files":
282
+ continue
283
+ raw = m.get("content", "") or ""
284
+ raw = _resolve(raw, atts) if isinstance(raw, str) else str(raw)
285
+ if isinstance(raw, list):
286
+ raw = _text(raw)
287
+ files = _parse_pr_files(str(raw))
288
+ if files:
289
+ sig = "|".join(f["path"] for f in files)
290
+ if sig not in _seen_pr_sigs:
291
+ _seen_pr_sigs.add(sig)
292
+ steps.append(
293
+ {
294
+ "kind": "pr_diff",
295
+ "files": files,
296
+ "subtask": subtask,
297
+ }
298
+ )
299
+ break # only emit once per model call
300
+
301
+ # ── Tool results ──────────────────────────────────────────────
302
+ elif ev == "tool" and e.get("type") == "function":
303
+ fn = e.get("function", "")
304
+ raw = e.get("result") or e.get("output") or ""
305
+ out = _resolve(raw, atts)
306
+ if isinstance(out, list):
307
+ out = _text(out)
308
+ elif isinstance(out, dict):
309
+ out = json.dumps(out, indent=2)
310
+ out = str(out)
311
+ steps.append(
312
+ {
313
+ "kind": "tool_result",
314
+ "fn": fn,
315
+ "result": out,
316
+ "subtask": subtask,
317
+ }
318
+ )
319
+
320
+ # ── Final score ───────────────────────────────────────────────
321
+ elif ev == "score" and not e.get("intermediate"):
322
+ sc = e.get("score", {})
323
+ steps.append(
324
+ {
325
+ "kind": "score",
326
+ "value": sc.get("value"),
327
+ "answer": sc.get("answer", "?"),
328
+ "explanation": sc.get("explanation", ""),
329
+ "subtask": subtask,
330
+ }
331
+ )
332
+
333
+ return steps
334
+
335
+
336
+ # ── Rendering ─────────────────────────────────────────────────────────────────
337
+
338
+
339
+ def _badge(label: str, color: str) -> str:
340
+ return (
341
+ f'<span style="background:{color}22;color:{color};border:1px solid {color}44;'
342
+ f'border-radius:4px;padding:1px 7px;font-size:0.78em;font-weight:600">{label}</span>'
343
+ )
344
+
345
+
346
+ def _tool_badge(fn: str) -> str:
347
+ color = TOOL_COLOR.get(fn, "#6B7280")
348
+ return _badge(fn, color)
349
+
350
+
351
+ def _args_summary(fn: str, args: dict) -> str:
352
+ """Return the most useful 1-line summary of tool arguments."""
353
+ if fn == "github_create_branch":
354
+ return (
355
+ f"`{args.get('branch_name', '')}` from `{args.get('base_branch', 'main')}`"
356
+ )
357
+ if fn == "github_write_file":
358
+ msg = args.get("commit_message", "")
359
+ path = args.get("path", "")
360
+ return f"`{path}` β€” {msg[:80]}"
361
+ if fn == "github_create_pull_request":
362
+ return f"**{args.get('title', '')}** β†’ `{args.get('head', '')}`"
363
+ if fn == "transfer_to_target":
364
+ msg = args.get("message", "")
365
+ return msg[:120]
366
+ if fn == "github_list_pr_files":
367
+ return f"PR #{args.get('pr_number', '?')} in `{args.get('repo', '')}`"
368
+ if fn in ("github_approve_pull_request",):
369
+ return f"PR #{args.get('pr_number', '?')}"
370
+ if fn == "github_add_pr_comment":
371
+ c = args.get("comment", "")
372
+ return c[:120]
373
+ if fn == "submit":
374
+ a = args.get("answer", args.get("value", ""))
375
+ return str(a)[:120]
376
+ if fn == "decompose":
377
+ tasks = args.get("subtasks", [])
378
+ return f"{len(tasks)} subtasks"
379
+ if fn == "bash":
380
+ cmd = args.get("cmd", args.get("command", ""))
381
+ return f"`{cmd[:120]}`"
382
+ if fn == "github_list_files":
383
+ path = args.get("path", "/")
384
+ branch = args.get("branch", "")
385
+ return f"`{path}`" + (f" @ `{branch}`" if branch else "")
386
+ if fn == "submit_profile":
387
+ s = args.get("strategy", "")
388
+ return s[:120] + ("…" if len(s) > 120 else "")
389
+ if fn == "get_repository_analysis":
390
+ return "fetching repository analysis"
391
+ if fn == "think":
392
+ t = args.get("thought", args.get("thinking", args.get("content", "")))
393
+ return str(t)[:120] + ("…" if len(str(t)) > 120 else "")
394
+ return ""
395
+
396
+
397
+ def _anchor_div(anchor: str | None) -> None:
398
+ """Emit an invisible anchor div for in-page navigation."""
399
+ if anchor:
400
+ st.markdown(f'<div id="{anchor}"></div>', unsafe_allow_html=True)
401
+
402
+
403
+ def render_nav(steps: list) -> None:
404
+ """Render clickable trajectory navigation links in the sidebar."""
405
+ nav_steps = [s for s in steps if s.get("nav_label")]
406
+ if not nav_steps:
407
+ return
408
+
409
+ with st.sidebar:
410
+ st.markdown("---")
411
+ st.markdown("**Trajectory**")
412
+ for s in nav_steps:
413
+ anchor = s.get("anchor", "")
414
+ label = s.get("nav_label", "")
415
+ color = s.get("nav_color", "#6B7280")
416
+ indent = s.get("nav_indent", 0)
417
+ pad_left = 8 + indent * 14
418
+ st.markdown(
419
+ f'<a href="#{anchor}" style="display:block;padding:3px 8px 3px {pad_left}px;'
420
+ f"font-size:0.83em;color:{color};text-decoration:none;"
421
+ f"border-left:2px solid {color}55;margin:1px 0;"
422
+ f'border-radius:0 4px 4px 0">{label}</a>',
423
+ unsafe_allow_html=True,
424
+ )
425
+
426
+
427
+ def render_steps(steps: list) -> None:
428
+ for step in steps:
429
+ k = step["kind"]
430
+
431
+ # ── Subtask divider ───────────────────────────────────────────
432
+ if k == "subtask_divider":
433
+ _anchor_div(step.get("anchor"))
434
+ sn = step["subtask"]
435
+ st.markdown(
436
+ f'<hr style="margin:18px 0 6px 0">'
437
+ f'<h4 style="margin:0 0 8px 0">Subtask {sn}</h4>',
438
+ unsafe_allow_html=True,
439
+ )
440
+ continue
441
+
442
+ # ── Phase divider ─────────────────────────────────────────────
443
+ if k == "phase":
444
+ _anchor_div(step.get("anchor"))
445
+ color = step.get("nav_color", "#0F766E")
446
+ st.markdown(
447
+ f'<div style="border-left:4px solid {color};padding:6px 12px;'
448
+ f'background:{color}11;border-radius:0 6px 6px 0;margin:14px 0 6px 0">'
449
+ f'<b style="color:{color}">{step["label"]}</b></div>',
450
+ unsafe_allow_html=True,
451
+ )
452
+ continue
453
+
454
+ # ── Info banner ───────────────────────────────────────────────
455
+ if k == "info":
456
+ text = step["text"]
457
+ if text.startswith("βœ—") or "refused" in text.lower():
458
+ st.markdown(
459
+ f'<div style="background:#FEE2E222;border-left:4px solid #DC2626;'
460
+ f'padding:6px 12px;border-radius:4px;margin:4px 0;color:#991B1B">'
461
+ f"{text}</div>",
462
+ unsafe_allow_html=True,
463
+ )
464
+ elif text.startswith("βœ“") or "approved" in text.lower():
465
+ st.markdown(
466
+ f'<div style="background:#DCFCE722;border-left:4px solid #16A34A;'
467
+ f'padding:6px 12px;border-radius:4px;margin:4px 0;color:#166534">'
468
+ f"{text}</div>",
469
+ unsafe_allow_html=True,
470
+ )
471
+ elif text.startswith("Decomposition attempt"):
472
+ st.markdown(f"#### 🧩 {text}")
473
+ else:
474
+ st.caption(text)
475
+
476
+ # ── Model turn ────────────────────────────────────────────────
477
+ elif k == "model":
478
+ _anchor_div(step.get("anchor"))
479
+ role = step["role"]
480
+ color = ROLE_COLOR.get(role, "#6B7280")
481
+ emoji = ROLE_EMOJI.get(role, "πŸ€–")
482
+ model_short = step["model"].split("/")[-1]
483
+ text = step["text"].strip()
484
+ tcs = step["tool_calls"]
485
+
486
+ # Skip classifier turns (not very interesting)
487
+ if role == "classifier" and not text:
488
+ continue
489
+
490
+ header_html = (
491
+ f'<div style="border-left:4px solid {color};padding:4px 10px;'
492
+ f'margin:10px 0 2px 0;background:{color}08;border-radius:0 6px 6px 0">'
493
+ f"<b>{emoji} {role.upper()}</b>&nbsp;&nbsp;"
494
+ f'<span style="color:{color};font-size:0.78em">{model_short}</span>'
495
+ )
496
+
497
+ # Tool call summary inline in header
498
+ if tcs:
499
+ tc_html = " ".join(_tool_badge(tc["fn"]) for tc in tcs)
500
+ header_html += f"<br><div style='margin-top:4px'>{tc_html}</div>"
501
+
502
+ header_html += "</div>"
503
+ st.markdown(header_html, unsafe_allow_html=True)
504
+
505
+ # Reasoning / response text
506
+ if text:
507
+ if len(text) > 400:
508
+ with st.expander("View full response", expanded=False):
509
+ st.markdown(text)
510
+ else:
511
+ st.markdown(
512
+ f'<div style="padding:0 14px;color:#374151;font-size:0.9em">'
513
+ f"{text}</div>",
514
+ unsafe_allow_html=True,
515
+ )
516
+
517
+ # Tool call detail
518
+ for tc in tcs:
519
+ fn = tc["fn"]
520
+ color2 = TOOL_COLOR.get(fn, "#6B7280")
521
+ # Skip the one-liner summary for tools that render their own full block
522
+ _has_full_block = fn in (
523
+ "github_write_file",
524
+ "decompose",
525
+ "transfer_to_target",
526
+ "transfer_to_reviewer",
527
+ "github_add_pr_comment",
528
+ "github_approve_pull_request",
529
+ "submit",
530
+ "bash",
531
+ "submit_profile",
532
+ "think",
533
+ )
534
+ if not _has_full_block:
535
+ summary = _args_summary(fn, tc["args"])
536
+ detail_html = (
537
+ f'<div style="padding:2px 14px 2px 18px;font-size:0.85em;color:{color2}">'
538
+ f"↳ <b>{fn}</b>"
539
+ )
540
+ if summary:
541
+ detail_html += f" &nbsp;Β·&nbsp; {summary}"
542
+ detail_html += "</div>"
543
+ st.markdown(detail_html, unsafe_allow_html=True)
544
+
545
+ # Show full args for interesting tools on demand
546
+ if fn in (
547
+ "github_write_file",
548
+ "decompose",
549
+ "transfer_to_target",
550
+ "transfer_to_reviewer",
551
+ "github_add_pr_comment",
552
+ "github_approve_pull_request",
553
+ "submit",
554
+ "bash",
555
+ "submit_profile",
556
+ "think",
557
+ ):
558
+ args = tc["args"]
559
+ if fn == "github_write_file":
560
+ content = args.get("content", "")
561
+ path = args.get("path", "")
562
+ if content:
563
+ with st.expander(f" πŸ“„ `{path}`", expanded=False):
564
+ st.code(content, language=_lang_for(path))
565
+ elif fn == "decompose":
566
+ tasks = args.get("subtasks", [])
567
+ with st.expander(f" {len(tasks)} subtasks", expanded=False):
568
+ for i, t in enumerate(tasks, 1):
569
+ task_text = (
570
+ t.get("task", t) if isinstance(t, dict) else str(t)
571
+ )
572
+ st.markdown(f"**{i}.** {task_text}")
573
+ elif fn in ("transfer_to_target", "transfer_to_reviewer"):
574
+ msg = args.get("message", "")
575
+ if len(msg) > 120:
576
+ with st.expander(" Full handoff message", expanded=False):
577
+ st.markdown(msg)
578
+ elif fn == "github_add_pr_comment":
579
+ comment = args.get("comment", args.get("body", ""))
580
+ pr = args.get("pr_number", "?")
581
+ if comment:
582
+ with st.expander(f" πŸ’¬ PR #{pr} comment", expanded=False):
583
+ st.markdown(comment)
584
+ elif fn == "github_approve_pull_request":
585
+ pr = args.get("pr_number", "?")
586
+ review_body = args.get("body", args.get("review_body", ""))
587
+ label = f" βœ… Approved PR #{pr}"
588
+ if review_body:
589
+ with st.expander(label, expanded=False):
590
+ st.markdown(review_body)
591
+ else:
592
+ st.markdown(
593
+ f'<div style="padding:2px 14px 2px 28px;font-size:0.85em;'
594
+ f'color:#16A34A"><b>{label}</b></div>',
595
+ unsafe_allow_html=True,
596
+ )
597
+ elif fn == "submit":
598
+ answer = args.get("answer", args.get("value", ""))
599
+ answer_str = str(answer).strip()
600
+ is_decline = any(
601
+ w in answer_str.lower()
602
+ for w in (
603
+ "declin",
604
+ "reject",
605
+ "not approv",
606
+ "refused",
607
+ "concerns",
608
+ )
609
+ )
610
+ icon = "❌" if is_decline else "βœ…"
611
+ label = f" {icon} Submit: {answer_str[:60]}{'…' if len(answer_str) > 60 else ''}"
612
+ if len(answer_str) > 60:
613
+ with st.expander(label, expanded=False):
614
+ st.markdown(answer_str)
615
+ else:
616
+ st.markdown(
617
+ f'<div style="padding:2px 14px 2px 28px;font-size:0.85em;'
618
+ f'color:#{"DC2626" if is_decline else "16A34A"}">'
619
+ f"<b>{label}</b></div>",
620
+ unsafe_allow_html=True,
621
+ )
622
+ elif fn == "bash":
623
+ cmd = args.get("cmd", args.get("command", ""))
624
+ with st.expander(
625
+ f" $ {cmd[:80]}{'…' if len(cmd) > 80 else ''}",
626
+ expanded=False,
627
+ ):
628
+ st.code(cmd, language="bash")
629
+ elif fn == "submit_profile":
630
+ strategy = args.get("strategy", "")
631
+ with st.expander(
632
+ " πŸ”¬ Repository strategy report", expanded=False
633
+ ):
634
+ st.markdown(strategy)
635
+ elif fn == "think":
636
+ thought = args.get(
637
+ "thought", args.get("thinking", args.get("content", ""))
638
+ )
639
+ thought_str = str(thought).strip()
640
+ short = thought_str[:60] + ("…" if len(thought_str) > 60 else "")
641
+ with st.expander(f" 🧠 {short}", expanded=False):
642
+ st.markdown(
643
+ f'<div style="background:#F3F4F611;border-left:3px solid #9CA3AF;'
644
+ f'padding:8px 12px;border-radius:0 4px 4px 0;'
645
+ f'color:#374151;font-size:0.9em;white-space:pre-wrap">'
646
+ f"{thought_str}</div>",
647
+ unsafe_allow_html=True,
648
+ )
649
+
650
+ # ── PR file diff ──────────────────────────────────────────────
651
+ elif k == "pr_diff":
652
+ files = step["files"]
653
+ with st.expander(
654
+ f"πŸ“‚ PR files ({len(files)} file{'s' if len(files) != 1 else ''})",
655
+ expanded=False,
656
+ ):
657
+ for f in files:
658
+ path = f["path"]
659
+ content = f["content"]
660
+ lang = _lang_for(path)
661
+ st.markdown(
662
+ f'<div style="font-size:0.82em;font-weight:600;'
663
+ f'color:#374151;padding:4px 0 2px 0">'
664
+ f"πŸ“„ <code>{path}</code></div>",
665
+ unsafe_allow_html=True,
666
+ )
667
+ st.code(content, language=lang)
668
+
669
+ # ── Tool result ───────────────────────────────────────────────
670
+ elif k == "tool_result":
671
+ fn = step["fn"]
672
+ if fn == "think":
673
+ continue # thought content already shown in the tool call block
674
+ result = step["result"]
675
+ color = TOOL_COLOR.get(fn, "#6B7280")
676
+ short = result[:120].replace("\n", " ")
677
+
678
+ label = f'← {fn}: {short}{"…" if len(result) > 120 else ""}'
679
+ with st.expander(label, expanded=False):
680
+ lang = "text"
681
+ if fn == "github_read_file":
682
+ lang = _lang_for(result.split("\n")[0].strip())
683
+ st.code(result, language=lang)
684
+
685
+ # ── Final score ───────────────────────────────────────────────
686
+ elif k == "score":
687
+ val = step.get("value", 0) or 0
688
+ answer = step.get("answer", "?")
689
+ expl = step.get("explanation", "")
690
+ color = "#16A34A" if val >= 1.0 else ("#D97706" if val > 0 else "#DC2626")
691
+ st.markdown("---")
692
+ st.markdown(
693
+ f'<div style="background:{color};color:#ffffff;padding:12px 18px;'
694
+ f'border-radius:8px;font-size:1.15em;font-weight:700;margin:8px 0">'
695
+ f"🏁 Verdict: {answer} &nbsp;·&nbsp; score {val}</div>",
696
+ unsafe_allow_html=True,
697
+ )
698
+ if expl:
699
+ st.markdown(
700
+ f'<div style="background:{color}18;border-left:4px solid {color};'
701
+ f'padding:8px 14px;border-radius:4px;color:#ffffff;margin-top:6px">{expl}</div>',
702
+ unsafe_allow_html=True,
703
+ )
704
+
705
+
706
+ # ── Main app ──────────────────────────────────────────────────────────────────
707
+
708
+
709
+ def main() -> None:
710
+ st.set_page_config(
711
+ page_title="github_red Trajectory Viewer",
712
+ layout="wide",
713
+ page_icon="πŸ”΄",
714
+ )
715
+
716
+ # ── Sidebar ───────────────────────────────────────────────────────
717
+ with st.sidebar:
718
+ st.title("πŸ”΄ github_red")
719
+ st.subheader("Trajectory Viewer")
720
+
721
+ log_files = sorted(glob.glob(str(LOGS_DIR / "*.eval")), reverse=True)
722
+ if not log_files:
723
+ st.error(f"No .eval files found in:\n`{LOGS_DIR}`")
724
+ return
725
+
726
+ selected = st.selectbox(
727
+ "Run log",
728
+ log_files,
729
+ format_func=lambda p: Path(p).stem[:50],
730
+ )
731
+
732
+ st.markdown("---")
733
+ st.markdown("**Legend**")
734
+ for role, color in ROLE_COLOR.items():
735
+ emoji = ROLE_EMOJI[role]
736
+ st.markdown(
737
+ f'<div style="border-left:3px solid {color};padding:2px 8px;'
738
+ f'margin:2px 0;font-size:0.9em">{emoji} {role}</div>',
739
+ unsafe_allow_html=True,
740
+ )
741
+
742
+ if not selected:
743
+ return
744
+
745
+ # ── Load ──────────────────────────────────────────────────────────
746
+ try:
747
+ with zipfile.ZipFile(selected) as z:
748
+ sample_files = [n for n in z.namelist() if n.startswith("samples/")]
749
+ if not sample_files:
750
+ st.error("No sample files found in this eval log.")
751
+ return
752
+
753
+ # Support multiple samples (tabs)
754
+ samples = {n: json.loads(z.read(n)) for n in sample_files}
755
+ except Exception as e:
756
+ st.error(f"Failed to load eval file: {e}")
757
+ return
758
+
759
+ # ── Verdict filter ────────────────────────────────────────────────
760
+ def _sample_verdict(sample: dict) -> str:
761
+ for sc in (sample.get("scores") or {}).values():
762
+ ans = sc.get("answer")
763
+ if ans:
764
+ return str(ans)
765
+ return "?"
766
+
767
+ all_verdicts = sorted({_sample_verdict(s) for s in samples.values()})
768
+ with st.sidebar:
769
+ st.markdown("---")
770
+ st.markdown("**Filter by verdict**")
771
+ selected_verdicts = {
772
+ v: st.checkbox(v, value=True, key=f"filter_{v}") for v in all_verdicts
773
+ }
774
+
775
+ filtered = {
776
+ n: s
777
+ for n, s in samples.items()
778
+ if selected_verdicts.get(_sample_verdict(s), True)
779
+ }
780
+ if not filtered:
781
+ st.warning("No samples match the current filter.")
782
+ return
783
+
784
+ # ── Header metrics ────────────────────────────────────────────────
785
+ if len(filtered) > 1:
786
+ tab_names = [
787
+ f"{Path(n).stem} β€” {_sample_verdict(s)}" for n, s in filtered.items()
788
+ ]
789
+ tabs = st.tabs(tab_names)
790
+ for tab, (_, sample) in zip(tabs, filtered.items()):
791
+ with tab:
792
+ _render_sample(sample)
793
+ else:
794
+ _render_sample(next(iter(filtered.values())))
795
+
796
+
797
+ def _render_sample(sample: dict) -> None:
798
+ atts = sample.get("attachments", {})
799
+ events = sample.get("events", [])
800
+ scores = sample.get("scores", {})
801
+
802
+ # Metrics row
803
+ score_val, score_ans = None, None
804
+ for sc in (scores.values() if isinstance(scores, dict) else []):
805
+ score_val = sc.get("value")
806
+ score_ans = sc.get("answer")
807
+ break
808
+
809
+ col1, col2, col3, col4 = st.columns(4)
810
+ col1.metric("Sample", sample.get("id", "?"))
811
+ col2.metric("Verdict", f"{score_ans}" if score_ans else "?")
812
+ col3.metric("Score", f"{score_val:.1f}" if score_val is not None else "?")
813
+ col4.metric("Time", f"{sample.get('total_time', 0):.0f}s")
814
+
815
+ role_usage = sample.get("role_usage", {})
816
+ if role_usage:
817
+ with st.expander("Token usage by role", expanded=False):
818
+ cols = st.columns(len(role_usage))
819
+ for col, (role, usage) in zip(cols, role_usage.items()):
820
+ total = usage.get("total_tokens", 0)
821
+ col.metric(role, f"{total:,}")
822
+
823
+ st.markdown("---")
824
+ steps = parse_events(events, atts)
825
+ render_nav(steps)
826
+ render_steps(steps)
827
+
828
+
829
+ if __name__ == "__main__":
830
+ main()