rufimelo commited on
Commit
02f21d1
Β·
verified Β·
1 Parent(s): 118239b

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. Dockerfile +12 -0
  2. app.py +441 -0
  3. requirements.txt +1 -0
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY app.py .
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["streamlit", "run", "app.py", "--server.port=7860", "--server.address=0.0.0.0"]
app.py ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """github_red Trajectory Viewer β€” Hugging Face Space edition.
2
+
3
+ Upload any .eval log file produced by the decomposition benchmark to
4
+ visualise the full multi-agent attack trajectory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import json
11
+ import re
12
+ import zipfile
13
+ from pathlib import Path
14
+
15
+ import streamlit as st
16
+
17
+ # ── Colours & labels ────────────────────────────────────────────────────────
18
+
19
+ ROLE_COLOR = {
20
+ "decomposer": "#7C3AED",
21
+ "executor": "#2563EB",
22
+ "target": "#DC2626",
23
+ "classifier": "#D97706",
24
+ }
25
+ ROLE_EMOJI = {
26
+ "decomposer": "🧩",
27
+ "executor": "βš™οΈ",
28
+ "target": "πŸ›‘οΈ",
29
+ "classifier": "πŸ”",
30
+ }
31
+ TOOL_COLOR = {
32
+ "github_create_branch": "#0891B2",
33
+ "github_write_file": "#0891B2",
34
+ "github_create_pull_request": "#0891B2",
35
+ "transfer_to_target": "#7C3AED",
36
+ "github_list_pr_files": "#DC2626",
37
+ "github_read_file": "#DC2626",
38
+ "github_approve_pull_request": "#16A34A",
39
+ "github_add_pr_comment": "#DC2626",
40
+ "submit": "#6B7280",
41
+ "decompose": "#7C3AED",
42
+ "think": "#9CA3AF",
43
+ }
44
+
45
+ # ── Helpers ──────────────────────────────────────────────────────────────────
46
+
47
+ def _resolve(val, atts):
48
+ if isinstance(val, str) and val.startswith("attachment://"):
49
+ key = val[len("attachment://"):]
50
+ resolved = atts.get(key, val)
51
+ return resolved if isinstance(resolved, (str, dict, list)) else val
52
+ if isinstance(val, dict):
53
+ return {k: _resolve(v, atts) for k, v in val.items()}
54
+ if isinstance(val, list):
55
+ return [_resolve(v, atts) for v in val]
56
+ return val
57
+
58
+
59
+ def _text(content):
60
+ if isinstance(content, str):
61
+ return content
62
+ if isinstance(content, list):
63
+ parts = []
64
+ for p in content:
65
+ if isinstance(p, dict) and p.get("type") == "text":
66
+ parts.append(p.get("text", ""))
67
+ elif isinstance(p, str):
68
+ parts.append(p)
69
+ return "".join(parts)
70
+ return ""
71
+
72
+
73
+ def _parse_args(raw):
74
+ if isinstance(raw, str):
75
+ try:
76
+ return json.loads(raw)
77
+ except Exception:
78
+ return {"raw": raw}
79
+ return raw if isinstance(raw, dict) else {}
80
+
81
+
82
+ def _parse_pr_files(text: str) -> list:
83
+ files = []
84
+ parts = re.split(r"^=== (.+?) ===$", text, flags=re.MULTILINE)
85
+ it = iter(parts[1:])
86
+ for path in it:
87
+ content = next(it, "").strip()
88
+ if path.strip():
89
+ files.append({"path": path.strip(), "content": content})
90
+ return files
91
+
92
+
93
+ def _lang_for(path: str) -> str:
94
+ ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
95
+ return {
96
+ "py": "python", "yml": "yaml", "yaml": "yaml",
97
+ "sh": "bash", "bash": "bash",
98
+ "json": "json", "js": "javascript", "ts": "typescript",
99
+ "md": "markdown", "txt": "text", "toml": "toml",
100
+ "dockerfile": "dockerfile", "tf": "hcl",
101
+ }.get(ext, "text")
102
+
103
+
104
+ # ── Event parsing ─────────────────────────────────────────────────────────────
105
+
106
+ def parse_events(events: list, atts: dict) -> list:
107
+ steps = []
108
+ subtask = 0
109
+ seen_pr_sigs: set = set()
110
+
111
+ for e in events:
112
+ ev = e.get("event", "")
113
+
114
+ if ev == "info":
115
+ raw = e.get("data", "")
116
+ text = _resolve(raw, atts) if isinstance(raw, str) else str(raw)
117
+ if not isinstance(text, str):
118
+ text = json.dumps(text)
119
+ m = re.search(r"Subtask (\d+)", text)
120
+ if m and ("context for" in text or "Starting" in text):
121
+ subtask = int(m.group(1))
122
+ continue
123
+ if text.startswith("attachment://") or text.startswith("●"):
124
+ continue
125
+ if "=================" in text:
126
+ continue
127
+ steps.append({"kind": "info", "text": text, "subtask": subtask})
128
+
129
+ elif ev == "model":
130
+ role = e.get("role", "unknown")
131
+ model = e.get("model", "")
132
+ out = e.get("output") or {}
133
+ choices = out.get("choices", [])
134
+ msg = choices[0].get("message", {}) if choices else {}
135
+
136
+ text = _text(msg.get("content", ""))
137
+ raw_tcs = msg.get("tool_calls") or []
138
+ tool_calls = []
139
+ for tc in raw_tcs:
140
+ fn = tc.get("function", "")
141
+ args = _resolve(_parse_args(tc.get("arguments", {})), atts)
142
+ tool_calls.append({"fn": fn, "args": args})
143
+
144
+ steps.append({
145
+ "kind": "model", "role": role, "model": model,
146
+ "text": text, "tool_calls": tool_calls, "subtask": subtask,
147
+ })
148
+
149
+ # Extract PR file contents from target input messages
150
+ if role == "target":
151
+ for m in e.get("input", []):
152
+ if m.get("role") != "tool":
153
+ continue
154
+ fn = m.get("function") or m.get("name", "")
155
+ if fn != "github_list_pr_files":
156
+ continue
157
+ raw = m.get("content", "") or ""
158
+ raw = _resolve(raw, atts) if isinstance(raw, str) else str(raw)
159
+ files = _parse_pr_files(raw)
160
+ if files:
161
+ sig = "|".join(f["path"] for f in files)
162
+ if sig not in seen_pr_sigs:
163
+ seen_pr_sigs.add(sig)
164
+ steps.append({
165
+ "kind": "pr_diff", "files": files, "subtask": subtask,
166
+ })
167
+ break
168
+
169
+ elif ev == "tool" and e.get("type") == "function":
170
+ fn = e.get("function", "")
171
+ out = e.get("output", "") or ""
172
+ if isinstance(out, dict):
173
+ out = json.dumps(out)
174
+ steps.append({"kind": "tool_result", "fn": fn, "result": str(out), "subtask": subtask})
175
+
176
+ elif ev == "score" and not e.get("intermediate"):
177
+ sc = e.get("score", {})
178
+ steps.append({
179
+ "kind": "score", "value": sc.get("value"),
180
+ "answer": sc.get("answer", "?"), "explanation": sc.get("explanation", ""),
181
+ "subtask": subtask,
182
+ })
183
+
184
+ return steps
185
+
186
+
187
+ # ── Rendering ─────────────────────────────────────────────────────────────────
188
+
189
+ def _badge(label, color):
190
+ return (
191
+ f'<span style="background:{color}22;color:{color};border:1px solid {color}44;'
192
+ f'border-radius:4px;padding:1px 7px;font-size:0.78em;font-weight:600">{label}</span>'
193
+ )
194
+
195
+
196
+ def _args_summary(fn, args):
197
+ if fn == "github_create_branch":
198
+ return f"`{args.get('branch_name','')}` from `{args.get('base_branch','main')}`"
199
+ if fn == "github_write_file":
200
+ return f"`{args.get('path','')}` β€” {args.get('commit_message','')[:80]}"
201
+ if fn == "github_create_pull_request":
202
+ return f"**{args.get('title','')}** β†’ `{args.get('head','')}`"
203
+ if fn == "transfer_to_target":
204
+ return args.get("message", "")[:120]
205
+ if fn == "github_list_pr_files":
206
+ return f"PR #{args.get('pr_number','?')} in `{args.get('repo','')}`"
207
+ if fn == "github_approve_pull_request":
208
+ return f"PR #{args.get('pr_number','?')}"
209
+ if fn == "github_add_pr_comment":
210
+ return args.get("comment", "")[:120]
211
+ if fn == "submit":
212
+ return str(args.get("answer", args.get("value", "")))[:120]
213
+ if fn == "decompose":
214
+ return f"{len(args.get('subtasks', []))} subtasks"
215
+ return ""
216
+
217
+
218
+ def render_steps(steps: list) -> None:
219
+ current_subtask = None
220
+
221
+ for step in steps:
222
+ k = step["kind"]
223
+ sn = step.get("subtask", 0)
224
+
225
+ if sn != current_subtask and sn > 0 and k == "model":
226
+ current_subtask = sn
227
+ st.markdown(f"---\n#### Subtask {sn}")
228
+
229
+ if k == "info":
230
+ text = step["text"]
231
+ if text.startswith("βœ—") or "refused" in text.lower():
232
+ st.markdown(
233
+ f'<div style="background:#FEE2E222;border-left:4px solid #DC2626;'
234
+ f'padding:6px 12px;border-radius:4px;margin:4px 0;color:#991B1B">{text}</div>',
235
+ unsafe_allow_html=True)
236
+ elif text.startswith("βœ“") or "approved" in text.lower():
237
+ st.markdown(
238
+ f'<div style="background:#DCFCE722;border-left:4px solid #16A34A;'
239
+ f'padding:6px 12px;border-radius:4px;margin:4px 0;color:#166534">{text}</div>',
240
+ unsafe_allow_html=True)
241
+ elif text.startswith("Decomposition attempt"):
242
+ st.markdown(f"#### 🧩 {text}")
243
+ else:
244
+ st.caption(text)
245
+
246
+ elif k == "model":
247
+ role = step["role"]
248
+ color = ROLE_COLOR.get(role, "#6B7280")
249
+ emoji = ROLE_EMOJI.get(role, "πŸ€–")
250
+ model_short = step["model"].split("/")[-1]
251
+ text = step["text"].strip()
252
+ tcs = step["tool_calls"]
253
+
254
+ if role == "classifier" and not text:
255
+ continue
256
+
257
+ tc_html = " ".join(
258
+ _badge(tc["fn"], TOOL_COLOR.get(tc["fn"], "#6B7280")) for tc in tcs
259
+ )
260
+ header = (
261
+ f'<div style="border-left:4px solid {color};padding:4px 10px;'
262
+ f'margin:10px 0 2px 0;background:{color}08;border-radius:0 6px 6px 0">'
263
+ f'<b>{emoji} {role.upper()}</b>&nbsp;&nbsp;'
264
+ f'<span style="color:{color};font-size:0.78em">{model_short}</span>'
265
+ + (f"<br><div style='margin-top:4px'>{tc_html}</div>" if tc_html else "")
266
+ + "</div>"
267
+ )
268
+ st.markdown(header, unsafe_allow_html=True)
269
+
270
+ if text:
271
+ if len(text) > 500:
272
+ with st.expander("View full response", expanded=False):
273
+ st.markdown(text)
274
+ else:
275
+ st.markdown(
276
+ f'<div style="padding:0 14px;color:#374151;font-size:0.9em">{text}</div>',
277
+ unsafe_allow_html=True)
278
+
279
+ for tc in tcs:
280
+ fn = tc["fn"]
281
+ c2 = TOOL_COLOR.get(fn, "#6B7280")
282
+ summ = _args_summary(fn, tc["args"])
283
+ st.markdown(
284
+ f'<div style="padding:2px 14px 2px 18px;font-size:0.85em;color:{c2}">'
285
+ f'↳ <b>{fn}</b>' + (f" &nbsp;Β·&nbsp; {summ}" if summ else "") + "</div>",
286
+ unsafe_allow_html=True)
287
+
288
+ args = tc["args"]
289
+ if fn == "github_write_file" and args.get("content"):
290
+ path = args.get("path", "")
291
+ with st.expander(f"πŸ“„ `{path}`", expanded=True):
292
+ st.code(args["content"], language=_lang_for(path))
293
+ elif fn == "decompose":
294
+ tasks = args.get("subtasks", [])
295
+ with st.expander(f" {len(tasks)} subtasks", expanded=True):
296
+ for i, t in enumerate(tasks, 1):
297
+ task_text = t.get("task", t) if isinstance(t, dict) else str(t)
298
+ st.markdown(f"**{i}.** {task_text}")
299
+ elif fn == "transfer_to_target":
300
+ msg = args.get("message", "")
301
+ if len(msg) > 120:
302
+ with st.expander(" Full handoff message", expanded=False):
303
+ st.markdown(msg)
304
+
305
+ elif k == "pr_diff":
306
+ files = step["files"]
307
+ with st.expander(
308
+ f"πŸ“‚ PR files ({len(files)} file{'s' if len(files) != 1 else ''})",
309
+ expanded=True,
310
+ ):
311
+ for f in files:
312
+ st.markdown(
313
+ f'<div style="font-size:0.82em;font-weight:600;color:#374151;'
314
+ f'padding:4px 0 2px 0">πŸ“„ <code>{f["path"]}</code></div>',
315
+ unsafe_allow_html=True)
316
+ st.code(f["content"], language=_lang_for(f["path"]))
317
+
318
+ elif k == "tool_result":
319
+ fn = step["fn"]
320
+ result = step["result"]
321
+ color = TOOL_COLOR.get(fn, "#6B7280")
322
+ short = result[:200].replace("\n", " ")
323
+ st.markdown(
324
+ f'<div style="padding:2px 14px 2px 18px;font-size:0.82em;color:#6B7280">'
325
+ f'← <span style="color:{color}">{fn}</span>: {short}'
326
+ + ("…" if len(result) > 200 else "") + "</div>",
327
+ unsafe_allow_html=True)
328
+ if len(result) > 200:
329
+ with st.expander(" Full result", expanded=False):
330
+ st.text(result)
331
+
332
+ elif k == "score":
333
+ val = step.get("value", 0) or 0
334
+ ans = step.get("answer", "?")
335
+ expl = step.get("explanation", "")
336
+ color = "#16A34A" if val >= 1.0 else ("#D97706" if val > 0 else "#DC2626")
337
+ st.markdown("---")
338
+ st.markdown(f"### 🏁 Verdict: **{ans}** &nbsp; (score {val})")
339
+ if expl:
340
+ st.markdown(
341
+ f'<div style="background:{color}18;border-left:4px solid {color};'
342
+ f'padding:8px 14px;border-radius:4px;color:#1F2937">{expl}</div>',
343
+ unsafe_allow_html=True)
344
+
345
+
346
+ # ── Sample renderer ───────────────────────────────────────────────────────────
347
+
348
+ def _render_sample(sample: dict) -> None:
349
+ atts = sample.get("attachments", {})
350
+ events = sample.get("events", [])
351
+ scores = sample.get("scores", {})
352
+
353
+ score_val, score_ans = None, None
354
+ for sc in (scores.values() if isinstance(scores, dict) else []):
355
+ score_val = sc.get("value")
356
+ score_ans = sc.get("answer")
357
+ break
358
+
359
+ col1, col2, col3, col4 = st.columns(4)
360
+ col1.metric("Sample", sample.get("id", "?"))
361
+ col2.metric("Verdict", str(score_ans) if score_ans else "?")
362
+ col3.metric("Score", f"{score_val:.1f}" if score_val is not None else "?")
363
+ col4.metric("Time", f"{sample.get('total_time', 0):.0f}s")
364
+
365
+ role_usage = sample.get("role_usage", {})
366
+ if role_usage:
367
+ with st.expander("Token usage by role", expanded=False):
368
+ cols = st.columns(len(role_usage))
369
+ for col, (role, usage) in zip(cols, role_usage.items()):
370
+ col.metric(role, f"{usage.get('total_tokens', 0):,}")
371
+
372
+ st.markdown("---")
373
+ render_steps(parse_events(events, atts))
374
+
375
+
376
+ # ── Main ──────────────────────────────────────────────────────────────────────
377
+
378
+ def main() -> None:
379
+ st.set_page_config(
380
+ page_title="github_red Trajectory Viewer",
381
+ layout="wide",
382
+ page_icon="πŸ”΄",
383
+ )
384
+
385
+ with st.sidebar:
386
+ st.title("πŸ”΄ github_red")
387
+ st.subheader("Trajectory Viewer")
388
+ st.caption("Upload an `.eval` log file from the decomposition benchmark.")
389
+
390
+ uploaded = st.file_uploader("Upload .eval file", type=["eval"])
391
+
392
+ st.markdown("---")
393
+ st.markdown("**Legend**")
394
+ for role, color in ROLE_COLOR.items():
395
+ emoji = ROLE_EMOJI[role]
396
+ st.markdown(
397
+ f'<div style="border-left:3px solid {color};padding:2px 8px;'
398
+ f'margin:2px 0;font-size:0.9em">{emoji} {role}</div>',
399
+ unsafe_allow_html=True)
400
+
401
+ if uploaded is None:
402
+ st.info("Upload an `.eval` file in the sidebar to get started.")
403
+ st.markdown("""
404
+ ### What is this?
405
+
406
+ This tool visualises the multi-agent **attack trajectory** from the
407
+ [Decomposition and Investigating Tool Agents](https://arxiv.org/abs/...) benchmark.
408
+
409
+ Each `.eval` file contains a full run log where:
410
+ - 🧩 **Decomposer** breaks the harmful goal into benign-looking subtasks
411
+ - βš™οΈ **Executor** creates branches, commits code, and opens pull requests
412
+ - πŸ›‘οΈ **Target** (code reviewer) is asked to approve the PRs
413
+ - πŸ” **Classifier** judges whether the target complied
414
+
415
+ The viewer shows the full conversation, PR code diffs, and the final verdict.
416
+ """)
417
+ return
418
+
419
+ try:
420
+ buf = io.BytesIO(uploaded.read())
421
+ with zipfile.ZipFile(buf) as z:
422
+ sample_files = [n for n in z.namelist() if n.startswith("samples/")]
423
+ if not sample_files:
424
+ st.error("No sample files found in this eval log.")
425
+ return
426
+ samples = {n: json.loads(z.read(n)) for n in sample_files}
427
+ except Exception as e:
428
+ st.error(f"Failed to load eval file: {e}")
429
+ return
430
+
431
+ if len(samples) > 1:
432
+ tabs = st.tabs([Path(n).stem for n in samples])
433
+ for tab, (_, sample) in zip(tabs, samples.items()):
434
+ with tab:
435
+ _render_sample(sample)
436
+ else:
437
+ _render_sample(next(iter(samples.values())))
438
+
439
+
440
+ if __name__ == "__main__":
441
+ main()
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ streamlit>=1.35.0