flozi00 commited on
Commit
c84bdd3
·
verified ·
1 Parent(s): dfe568e

space: compare tab — seed vs output diff with the reason for every edit

Browse files
BUILD.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
- "built_at": "2026-10-29T17:51:55",
3
- "commit": "e8bb2e0",
4
- "files": 52,
5
  "with_store": false,
6
  "with_card": true
7
  }
 
1
  {
2
+ "built_at": "2026-10-29T18:33:03",
3
+ "commit": "39f5347",
4
+ "files": 53,
5
  "with_store": false,
6
  "with_card": true
7
  }
data/dataset/dataset_card.json CHANGED
@@ -1,12 +1,12 @@
1
  {
2
- "traces": 4563,
3
  "languages": {
4
- "en": 4370,
5
  "de": 193
6
  },
7
  "language_pct": {
8
- "en": 95.8,
9
- "de": 4.2
10
  },
11
  "topic_language_matrix": {
12
  "languages": [
@@ -15,54 +15,54 @@
15
  ],
16
  "rows": {
17
  "review": {
18
- "en": 1399,
19
  "de": 80,
20
- "total": 1479
21
  },
22
  "implement": {
23
- "en": 876,
24
  "de": 43,
25
- "total": 919
26
  },
27
  "debug": {
28
- "en": 720,
29
  "de": 26,
30
- "total": 746
31
  },
32
  "research": {
33
- "en": 552,
34
  "de": 7,
35
- "total": 559
36
  },
37
  "other": {
38
- "en": 291,
39
  "de": 6,
40
- "total": 297
41
  },
42
  "test": {
43
- "en": 174,
44
  "de": 15,
45
- "total": 189
46
  },
47
  "ops": {
48
- "en": 108,
49
  "de": 4,
50
- "total": 112
51
  },
52
  "refactor": {
53
- "en": 99,
54
  "de": 3,
55
- "total": 102
56
  },
57
  "data-analysis": {
58
- "en": 90,
59
  "de": 6,
60
- "total": 96
61
  },
62
  "creative": {
63
- "en": 51,
64
  "de": 0,
65
- "total": 51
66
  },
67
  "office-docs": {
68
  "en": 10,
@@ -71,41 +71,41 @@
71
  }
72
  },
73
  "totals": {
74
- "en": 4370,
75
  "de": 193,
76
- "total": 4563
77
  }
78
  },
79
  "tools_and_prompt": {
80
  "tools": {
81
- "rows": 4563,
82
- "with_tools": 4557,
83
  "pct": 99.9,
84
  "by_source": {
85
- "derived-from-observed-calls": 4098,
86
- "claude-request-shape": 459,
87
  "none": 6
88
  }
89
  },
90
  "system_prompt": {
91
- "rows": 4563,
92
- "with_system": 1826,
93
- "pct": 40.0,
94
  "by_source": {
95
- "none": 2737,
96
  "raw-system-row": 663,
97
- "claude-request-shape": 459,
98
- "codex-base-instructions": 408,
99
  "in-trace": 292,
100
- "hermes-system-prompts": 4
101
  }
102
  }
103
  },
104
  "provenance": {
105
  "basis": "session-id prefix (the improver overwrites meta.source)",
106
  "counts": {
107
- "real-session": 2187,
108
- "subagent": 913,
109
  "nemotron-agentic-ka": 581,
110
  "split-segment": 508,
111
  "nemotron-agentic-tc": 370,
@@ -115,11 +115,11 @@
115
  "nemotron-swe-e8ff278b": 1
116
  },
117
  "pct": {
118
- "real-session": 47.9,
119
- "subagent": 20.0,
120
- "nemotron-agentic-ka": 12.7,
121
- "split-segment": 11.1,
122
- "nemotron-agentic-tc": 8.1,
123
  "nemotron-swe-3c63eaaa": 0.0,
124
  "nemotron-swe-6ca796dd": 0.0,
125
  "nemotron-swe-bc9ee6b7": 0.0,
@@ -127,105 +127,105 @@
127
  }
128
  },
129
  "agent_roles": {
130
- "orchestrator": 3650,
131
- "subagent": 913
132
  },
133
  "tasks": {
134
- "review": 1479,
135
- "implement": 919,
136
- "debug": 746,
137
- "research": 559,
138
- "other": 297,
139
- "test": 189,
140
- "ops": 112,
141
- "refactor": 102,
142
- "data-analysis": 96,
143
- "creative": 51,
144
  "office-docs": 13
145
  },
146
  "messages": {
147
- "total": 152211,
148
  "by_role": {
149
- "user": 5045,
150
- "assistant": 73803,
151
- "tool": 71537,
152
- "system": 1826
153
  },
154
  "per_trace": {
155
- "mean": 33.4,
156
- "median": 23,
157
  "p90": 68,
158
  "max": 274
159
  }
160
  },
161
  "tool_calls": {
162
- "total": 71537,
163
  "per_trace": {
164
- "mean": 15.7,
165
- "median": 10,
166
  "p90": 33,
167
  "max": 143
168
  },
169
  "traces_with_none": 6,
170
- "distinct_tools": 556,
171
  "top_tools": {
172
- "Bash": 26609,
173
- "exec": 13574,
174
- "terminal": 4730,
175
- "read_file": 3641,
176
- "Read": 3012,
177
- "Edit": 2493,
178
  "search_files": 2247,
179
- "execute_code": 1970,
180
  "tool_call": 1679,
181
- "Grep": 1007,
182
  "skill_view": 966,
183
- "patch": 875,
184
  "wait": 489,
185
- "unlock_discoverable_agent_tool": 457,
186
- "call_discoverable_agent_tool": 457
187
  }
188
  },
189
  "user_turns": {
190
  "per_trace": {
191
  "mean": 1.1,
192
- "median": 1,
193
  "p90": 1,
194
  "max": 18
195
  },
196
- "single_turn_traces": 4211,
197
- "multi_turn_traces": 352
198
  },
199
  "tokens": {
200
  "method": "chars/4 (prose proxy); scale by ~1.15 for a central estimate",
201
  "budget": 131072,
202
- "chars_total": 374416612,
203
- "est_total": 93604153,
204
  "per_trace": {
205
- "mean": 20513.4,
206
- "median": 16365,
207
- "p90": 46390,
208
  "max": 121769
209
  },
210
  "fits_budget": {
211
  "4096": {
212
- "traces": 1139,
213
- "pct": 25.0
214
  },
215
  "8192": {
216
- "traces": 1463,
217
- "pct": 32.1
218
  },
219
  "16384": {
220
- "traces": 2282,
221
- "pct": 50.0
222
  },
223
  "65536": {
224
- "traces": 4514,
225
- "pct": 98.9
226
  },
227
  "131072": {
228
- "traces": 4563,
229
  "pct": 100.0
230
  }
231
  }
@@ -234,68 +234,68 @@
234
  "supervision": {
235
  "contract": "loss on assistant spans only; tool/user/system spans are masked context",
236
  "chars_by_role": {
237
- "user": 7445197,
238
- "assistant": 13944527,
239
- "tool": 339128629,
240
- "system": 13898259
241
  },
242
  "assistant_share_pct": 3.7,
243
- "tool_share_pct": 90.6
244
  },
245
  "difficulty": {
246
  "basis": "tool calls in the ideal path (proxy, not a score)",
247
  "bands": {
248
  "D1": {
249
  "label": "one-shot / single edit",
250
- "traces": 136,
251
  "pct": 3.0
252
  },
253
  "D2": {
254
  "label": "short loop",
255
- "traces": 786,
256
- "pct": 17.2
257
  },
258
  "D3": {
259
  "label": "multi-step",
260
- "traces": 1384,
261
- "pct": 30.3
262
  },
263
  "D4": {
264
  "label": "long session",
265
- "traces": 1057,
266
  "pct": 23.2
267
  },
268
  "D5": {
269
  "label": "very long session",
270
- "traces": 1200,
271
- "pct": 26.3
272
  }
273
  },
274
  "tool_calls": {
275
- "mean": 15.7,
276
- "median": 10,
277
  "p90": 33,
278
  "max": 143
279
  },
280
  "messages": {
281
- "mean": 33.4,
282
- "median": 23,
283
  "p90": 68,
284
  "max": 274
285
  },
286
  "est_tokens": {
287
- "mean": 20513.4,
288
- "median": 16365,
289
- "p90": 46390,
290
  "max": 121769
291
  }
292
  },
293
  "safety_filter": {
294
  "applied": true,
295
- "traces_in": 4831,
296
- "traces_kept": 4563,
297
- "traces_excluded": 268,
298
- "excluded_secret": 239,
299
  "excluded_personal": 29,
300
  "rule": "drop any trace carrying credential material (passwords, tokens, private keys, connection strings with real credentials) or third-party personal data (private legal correspondence, home addresses, phone numbers, IBANs). Code, internal hostnames, business contact addresses and the owner's own identity stay.",
301
  "report": "safety_report.json"
@@ -304,7 +304,7 @@
304
  "classes": {
305
  "time_sensitive": 13,
306
  "time_anchored": 12,
307
- "not_time_sensitive": 4538
308
  },
309
  "labels": {
310
  "time_sensitive": "answer decays: temporal fact, no retrieval tool",
 
1
  {
2
+ "traces": 4694,
3
  "languages": {
4
+ "en": 4501,
5
  "de": 193
6
  },
7
  "language_pct": {
8
+ "en": 95.9,
9
+ "de": 4.1
10
  },
11
  "topic_language_matrix": {
12
  "languages": [
 
15
  ],
16
  "rows": {
17
  "review": {
18
+ "en": 1418,
19
  "de": 80,
20
+ "total": 1498
21
  },
22
  "implement": {
23
+ "en": 931,
24
  "de": 43,
25
+ "total": 974
26
  },
27
  "debug": {
28
+ "en": 750,
29
  "de": 26,
30
+ "total": 776
31
  },
32
  "research": {
33
+ "en": 559,
34
  "de": 7,
35
+ "total": 566
36
  },
37
  "other": {
38
+ "en": 295,
39
  "de": 6,
40
+ "total": 301
41
  },
42
  "test": {
43
+ "en": 180,
44
  "de": 15,
45
+ "total": 195
46
  },
47
  "ops": {
48
+ "en": 110,
49
  "de": 4,
50
+ "total": 114
51
  },
52
  "refactor": {
53
+ "en": 101,
54
  "de": 3,
55
+ "total": 104
56
  },
57
  "data-analysis": {
58
+ "en": 95,
59
  "de": 6,
60
+ "total": 101
61
  },
62
  "creative": {
63
+ "en": 52,
64
  "de": 0,
65
+ "total": 52
66
  },
67
  "office-docs": {
68
  "en": 10,
 
71
  }
72
  },
73
  "totals": {
74
+ "en": 4501,
75
  "de": 193,
76
+ "total": 4694
77
  }
78
  },
79
  "tools_and_prompt": {
80
  "tools": {
81
+ "rows": 4694,
82
+ "with_tools": 4688,
83
  "pct": 99.9,
84
  "by_source": {
85
+ "derived-from-observed-calls": 4199,
86
+ "claude-request-shape": 489,
87
  "none": 6
88
  }
89
  },
90
  "system_prompt": {
91
+ "rows": 4694,
92
+ "with_system": 1917,
93
+ "pct": 40.8,
94
  "by_source": {
95
+ "none": 2777,
96
  "raw-system-row": 663,
97
+ "claude-request-shape": 489,
98
+ "codex-base-instructions": 466,
99
  "in-trace": 292,
100
+ "hermes-system-prompts": 7
101
  }
102
  }
103
  },
104
  "provenance": {
105
  "basis": "session-id prefix (the improver overwrites meta.source)",
106
  "counts": {
107
+ "real-session": 2288,
108
+ "subagent": 943,
109
  "nemotron-agentic-ka": 581,
110
  "split-segment": 508,
111
  "nemotron-agentic-tc": 370,
 
115
  "nemotron-swe-e8ff278b": 1
116
  },
117
  "pct": {
118
+ "real-session": 48.7,
119
+ "subagent": 20.1,
120
+ "nemotron-agentic-ka": 12.4,
121
+ "split-segment": 10.8,
122
+ "nemotron-agentic-tc": 7.9,
123
  "nemotron-swe-3c63eaaa": 0.0,
124
  "nemotron-swe-6ca796dd": 0.0,
125
  "nemotron-swe-bc9ee6b7": 0.0,
 
127
  }
128
  },
129
  "agent_roles": {
130
+ "orchestrator": 3751,
131
+ "subagent": 943
132
  },
133
  "tasks": {
134
+ "review": 1498,
135
+ "implement": 974,
136
+ "debug": 776,
137
+ "research": 566,
138
+ "other": 301,
139
+ "test": 195,
140
+ "ops": 114,
141
+ "refactor": 104,
142
+ "data-analysis": 101,
143
+ "creative": 52,
144
  "office-docs": 13
145
  },
146
  "messages": {
147
+ "total": 155544,
148
  "by_role": {
149
+ "user": 5181,
150
+ "assistant": 75430,
151
+ "tool": 73016,
152
+ "system": 1917
153
  },
154
  "per_trace": {
155
+ "mean": 33.1,
156
+ "median": 23.0,
157
  "p90": 68,
158
  "max": 274
159
  }
160
  },
161
  "tool_calls": {
162
+ "total": 73016,
163
  "per_trace": {
164
+ "mean": 15.6,
165
+ "median": 10.0,
166
  "p90": 33,
167
  "max": 143
168
  },
169
  "traces_with_none": 6,
170
+ "distinct_tools": 560,
171
  "top_tools": {
172
+ "Bash": 26864,
173
+ "exec": 14121,
174
+ "terminal": 4837,
175
+ "read_file": 3645,
176
+ "Read": 3192,
177
+ "Edit": 2632,
178
  "search_files": 2247,
179
+ "execute_code": 1971,
180
  "tool_call": 1679,
181
+ "Grep": 1055,
182
  "skill_view": 966,
183
+ "patch": 880,
184
  "wait": 489,
185
+ "Write": 476,
186
+ "unlock_discoverable_agent_tool": 457
187
  }
188
  },
189
  "user_turns": {
190
  "per_trace": {
191
  "mean": 1.1,
192
+ "median": 1.0,
193
  "p90": 1,
194
  "max": 18
195
  },
196
+ "single_turn_traces": 4337,
197
+ "multi_turn_traces": 357
198
  },
199
  "tokens": {
200
  "method": "chars/4 (prose proxy); scale by ~1.15 for a central estimate",
201
  "budget": 131072,
202
+ "chars_total": 384072729,
203
+ "est_total": 96018182,
204
  "per_trace": {
205
+ "mean": 20455.1,
206
+ "median": 16305.0,
207
+ "p90": 46185,
208
  "max": 121769
209
  },
210
  "fits_budget": {
211
  "4096": {
212
+ "traces": 1150,
213
+ "pct": 24.5
214
  },
215
  "8192": {
216
+ "traces": 1504,
217
+ "pct": 32.0
218
  },
219
  "16384": {
220
+ "traces": 2356,
221
+ "pct": 50.2
222
  },
223
  "65536": {
224
+ "traces": 4645,
225
+ "pct": 99.0
226
  },
227
  "131072": {
228
+ "traces": 4694,
229
  "pct": 100.0
230
  }
231
  }
 
234
  "supervision": {
235
  "contract": "loss on assistant spans only; tool/user/system spans are masked context",
236
  "chars_by_role": {
237
+ "user": 7566460,
238
+ "assistant": 14219298,
239
+ "tool": 347006849,
240
+ "system": 15280122
241
  },
242
  "assistant_share_pct": 3.7,
243
+ "tool_share_pct": 90.3
244
  },
245
  "difficulty": {
246
  "basis": "tool calls in the ideal path (proxy, not a score)",
247
  "bands": {
248
  "D1": {
249
  "label": "one-shot / single edit",
250
+ "traces": 141,
251
  "pct": 3.0
252
  },
253
  "D2": {
254
  "label": "short loop",
255
+ "traces": 815,
256
+ "pct": 17.4
257
  },
258
  "D3": {
259
  "label": "multi-step",
260
+ "traces": 1437,
261
+ "pct": 30.6
262
  },
263
  "D4": {
264
  "label": "long session",
265
+ "traces": 1088,
266
  "pct": 23.2
267
  },
268
  "D5": {
269
  "label": "very long session",
270
+ "traces": 1213,
271
+ "pct": 25.8
272
  }
273
  },
274
  "tool_calls": {
275
+ "mean": 15.6,
276
+ "median": 10.0,
277
  "p90": 33,
278
  "max": 143
279
  },
280
  "messages": {
281
+ "mean": 33.1,
282
+ "median": 23.0,
283
  "p90": 68,
284
  "max": 274
285
  },
286
  "est_tokens": {
287
+ "mean": 20455.1,
288
+ "median": 16305.0,
289
+ "p90": 46185,
290
  "max": 121769
291
  }
292
  },
293
  "safety_filter": {
294
  "applied": true,
295
+ "traces_in": 4966,
296
+ "traces_kept": 4694,
297
+ "traces_excluded": 272,
298
+ "excluded_secret": 243,
299
  "excluded_personal": 29,
300
  "rule": "drop any trace carrying credential material (passwords, tokens, private keys, connection strings with real credentials) or third-party personal data (private legal correspondence, home addresses, phone numbers, IBANs). Code, internal hostnames, business contact addresses and the owner's own identity stay.",
301
  "report": "safety_report.json"
 
304
  "classes": {
305
  "time_sensitive": 13,
306
  "time_anchored": 12,
307
+ "not_time_sensitive": 4669
308
  },
309
  "labels": {
310
  "time_sensitive": "answer decays: temporal fact, no retrieval tool",
traceforge/space/app.py CHANGED
@@ -17,6 +17,8 @@ import json
17
  import os
18
  from typing import Any, Optional
19
 
 
 
20
  from traceforge.space.hashing import short_hash
21
  from traceforge.space.ingest import parse_records, read_upload
22
  from traceforge.space.jobs import get_worker
@@ -45,6 +47,25 @@ CSS = """
45
  .tf-head { font-size:13px; color:#8b949e; margin:2px 0 8px; }
46
  .tf-pill { padding:2px 8px; border-radius:999px; font-size:11px;
47
  border:1px solid #2a3140; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  """
49
 
50
 
@@ -326,6 +347,23 @@ def build_app(store: Optional[TraceStore] = None,
326
  return None, None
327
  return out.to_dict(), store.output_path(h)
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  # ---------------------------------------------------------------- UI
330
 
331
  with gr.Blocks(title="traceforge — ideal-world agent traces") as app:
@@ -448,6 +486,25 @@ def build_app(store: Optional[TraceStore] = None,
448
  pick.change(on_browse, inputs=[pick], outputs=[preview, dl])
449
  app.load(browse_choices, outputs=[pick])
450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  return app
452
 
453
 
 
17
  import os
18
  from typing import Any, Optional
19
 
20
+ from traceforge.space.diffview import build as build_diff
21
+ from traceforge.space.diffview import load_labels, render_html, triage_path
22
  from traceforge.space.hashing import short_hash
23
  from traceforge.space.ingest import parse_records, read_upload
24
  from traceforge.space.jobs import get_worker
 
47
  .tf-head { font-size:13px; color:#8b949e; margin:2px 0 8px; }
48
  .tf-pill { padding:2px 8px; border-radius:999px; font-size:11px;
49
  border:1px solid #2a3140; }
50
+ .tf-diff-sum { font-size:12px; color:#9aa4b2; margin:4px 0 12px; }
51
+ .tf-diff-row { border:1px solid #2a3140; border-radius:8px; margin:0 0 8px;
52
+ background:#0d1117; overflow:hidden; }
53
+ .tf-diff-row.tf-k-dropped { border-left:3px solid #f85149; }
54
+ .tf-diff-row.tf-k-edited { border-left:3px solid #d29922; }
55
+ .tf-diff-row.tf-k-added { border-left:3px solid #3fb950; }
56
+ .tf-diff-row.tf-k-kept { border-left:3px solid #2a3140; opacity:.72; }
57
+ .tf-diff-h { display:flex; gap:8px; align-items:center; flex-wrap:wrap;
58
+ padding:6px 10px; background:#161b22; font-size:11px;
59
+ color:#8b949e; }
60
+ .tf-idx { color:#6e7681; } .tf-role { color:#2f81f7; }
61
+ .tf-why { color:#9aa4b2; font-style:italic; }
62
+ .tf-diff-b { padding:8px 10px; font-size:12px; color:#c9d1d9;
63
+ white-space:pre-wrap; word-break:break-word; }
64
+ .tf-diff-b pre { margin:0; white-space:pre-wrap; font-size:12px; }
65
+ .tf-diff-b summary { cursor:pointer; color:#8b949e; }
66
+ .tf-diff-b del { background:#3d1418; color:#ffa198; text-decoration:none; }
67
+ .tf-diff-b ins { background:#12351f; color:#7ee787; text-decoration:none; }
68
+ .tf-more { color:#6e7681; font-style:italic; }
69
  """
70
 
71
 
 
347
  return None, None
348
  return out.to_dict(), store.output_path(h)
349
 
350
+ # ------------------------------------------------------------ compare
351
+
352
+ def on_compare(h, only):
353
+ if not h:
354
+ return '<div class="tf-head">pick a processed seed above</div>'
355
+ seed = store.load_seed(h)
356
+ if seed is None:
357
+ return '<div class="tf-head">no seed stored for that hash</div>'
358
+ out = store.load_output(h)
359
+ if out is None:
360
+ rec = store.get(h) or {}
361
+ why = rec.get("error") or "no output stored"
362
+ return (f'<div class="tf-head">no output for this hash — '
363
+ f'{_esc(why)}</div>')
364
+ labels = load_labels(triage_path(cfg, f"space-{h[:16]}"))
365
+ return render_html(build_diff(seed, out, labels), only=only)
366
+
367
  # ---------------------------------------------------------------- UI
368
 
369
  with gr.Blocks(title="traceforge — ideal-world agent traces") as app:
 
486
  pick.change(on_browse, inputs=[pick], outputs=[preview, dl])
487
  app.load(browse_choices, outputs=[pick])
488
 
489
+ with gr.Tab("Compare seed → output"):
490
+ gr.Markdown(
491
+ "One row per seed message. **removed** rows are dead ends the "
492
+ "ideal path routes around, **rewritten** rows are the same work "
493
+ "in a better shape, and the italic note is the triage verdict "
494
+ "that drove the edit. Unchanged rows are collapsed.")
495
+ with gr.Row():
496
+ pick2 = gr.Dropdown(label="processed seed (by hash)", choices=[],
497
+ scale=4)
498
+ btn_reload2 = gr.Button("Reload list", scale=1)
499
+ only_in = gr.Radio(choices=["all", "changed", "removed"],
500
+ value="all", label="show")
501
+ diff_out = gr.HTML()
502
+ btn_reload2.click(browse_choices, outputs=[pick2])
503
+ pick2.change(on_compare, inputs=[pick2, only_in], outputs=[diff_out])
504
+ only_in.change(on_compare, inputs=[pick2, only_in],
505
+ outputs=[diff_out])
506
+ app.load(browse_choices, outputs=[pick2])
507
+
508
  return app
509
 
510
 
traceforge/space/diffview.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seed-vs-output diff, with the reason each message changed.
2
+
3
+ The splice engine rewrites a trace by dropping dead ends, rephrasing
4
+ sloppy turns and compressing dumps. The output alone does not say *why*
5
+ a message is gone, which makes a rewrite hard to audit and impossible to
6
+ learn from. Triage already labels every source message
7
+ (keep/drop/rephrase/fact_check/summarize) with a short reason; this
8
+ module lines the seed up against the output and attaches that reason to
9
+ every row, so the Space can show the edit as a reviewable diff.
10
+
11
+ Alignment is a sequence match on (role, content, tool calls). The splice
12
+ engine emits output messages in source order, so kept turns match
13
+ exactly, a rewrite shows up as a replace block, and a drop as a delete
14
+ block. No provenance has to be threaded through the pipeline.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import difflib
20
+ import html
21
+ import json
22
+ import os
23
+ import re
24
+ from typing import Any, Optional
25
+
26
+ # Per-side render cap. A tool result can be megabytes; the diff is for
27
+ # reading, and the full text is one click away in Browse outputs.
28
+ MAX_CHARS = 6000
29
+
30
+ _TOKEN = re.compile(r"\s+|\w+|[^\w\s]")
31
+
32
+ KINDS = ("kept", "edited", "dropped", "added")
33
+
34
+
35
+ # ------------------------------------------------------------------ helpers
36
+
37
+ def _field(msg: Any, name: str, default: Any = None) -> Any:
38
+ """Read a field from a Message dataclass or a plain dict."""
39
+ if isinstance(msg, dict):
40
+ return msg.get(name, default)
41
+ return getattr(msg, name, default)
42
+
43
+
44
+ def messages_of(obj: Any) -> list:
45
+ """Accept a Trace, a dict with `messages`, or a bare message list."""
46
+ if obj is None:
47
+ return []
48
+ if isinstance(obj, (list, tuple)):
49
+ return list(obj)
50
+ if isinstance(obj, dict):
51
+ return list(obj.get("messages") or [])
52
+ return list(getattr(obj, "messages", None) or [])
53
+
54
+
55
+ def _calls(msg: Any) -> str:
56
+ out = []
57
+ for tc in (_field(msg, "tool_calls") or []):
58
+ name = _field(tc, "name", "") or ""
59
+ args = _field(tc, "arguments", "") or ""
60
+ out.append(f"{name}({args})")
61
+ return "|".join(out)
62
+
63
+
64
+ def _key(msg: Any) -> tuple:
65
+ """What has to be identical for two messages to count as the same turn."""
66
+ return (_field(msg, "role", "") or "",
67
+ (_field(msg, "content") or "").strip(),
68
+ _calls(msg))
69
+
70
+
71
+ def text_of(msg: Any) -> str:
72
+ """The readable body of a message: content, tool calls, tool name."""
73
+ parts = []
74
+ content = _field(msg, "content")
75
+ if content:
76
+ parts.append(str(content))
77
+ calls = _calls(msg)
78
+ if calls:
79
+ parts.append("→ " + calls.replace("|", "\n→ "))
80
+ name = _field(msg, "tool_name")
81
+ if name:
82
+ parts.append(f"[{name}]")
83
+ return "\n".join(parts)
84
+
85
+
86
+ def _clip(s: str, n: int = MAX_CHARS) -> tuple[str, int]:
87
+ if len(s) <= n:
88
+ return s, 0
89
+ return s[:n], len(s) - n
90
+
91
+
92
+ # ---------------------------------------------------------------- alignment
93
+
94
+ def _row(kind: str, src: Optional[int], dst: Optional[int],
95
+ seed: list, out: list) -> dict:
96
+ before = text_of(seed[src]) if src is not None else ""
97
+ after = text_of(out[dst]) if dst is not None else ""
98
+ msg = seed[src] if src is not None else (out[dst] if dst is not None else {})
99
+ return {
100
+ "kind": kind,
101
+ "src": src,
102
+ "dst": dst,
103
+ "role": _field(msg, "role", "") or "",
104
+ "before": before,
105
+ "after": after,
106
+ "action": "",
107
+ "reason": "",
108
+ }
109
+
110
+
111
+ def align(seed: Any, out: Any) -> list[dict]:
112
+ """Line the seed up against the output; one row per visible change."""
113
+ a, b = messages_of(seed), messages_of(out)
114
+ ka = [_key(m) for m in a]
115
+ kb = [_key(m) for m in b]
116
+ sm = difflib.SequenceMatcher(None, ka, kb, autojunk=False)
117
+ rows: list[dict] = []
118
+ for tag, i1, i2, j1, j2 in sm.get_opcodes():
119
+ if tag == "equal":
120
+ for k in range(i2 - i1):
121
+ rows.append(_row("kept", i1 + k, j1 + k, a, b))
122
+ elif tag == "delete":
123
+ for i in range(i1, i2):
124
+ rows.append(_row("dropped", i, None, a, b))
125
+ elif tag == "insert":
126
+ for j in range(j1, j2):
127
+ rows.append(_row("added", None, j, a, b))
128
+ else: # replace: pair positionally, then the leftovers
129
+ n = min(i2 - i1, j2 - j1)
130
+ for k in range(n):
131
+ rows.append(_row("edited", i1 + k, j1 + k, a, b))
132
+ for i in range(i1 + n, i2):
133
+ rows.append(_row("dropped", i, None, a, b))
134
+ for j in range(j1 + n, j2):
135
+ rows.append(_row("added", None, j, a, b))
136
+ return rows
137
+
138
+
139
+ # ------------------------------------------------------------------ reasons
140
+
141
+ def labels_from_record(rec: Any) -> dict[int, dict]:
142
+ """Normalise a triage record (or its label list) to {index: label}."""
143
+ if not rec:
144
+ return {}
145
+ items = rec.get("labels") if isinstance(rec, dict) else rec
146
+ out: dict[int, dict] = {}
147
+ for item in (items or []):
148
+ if not isinstance(item, dict):
149
+ continue
150
+ raw = item.get("i")
151
+ if raw is None:
152
+ continue
153
+ try:
154
+ i = int(raw)
155
+ except (TypeError, ValueError):
156
+ continue
157
+ out[i] = {"action": str(item.get("action") or ""),
158
+ "reason": str(item.get("reason") or "")}
159
+ return out
160
+
161
+
162
+ def load_labels(path: str) -> dict[int, dict]:
163
+ """Read a triage record from disk; {} when absent or unreadable."""
164
+ try:
165
+ with open(path, encoding="utf-8") as f:
166
+ return labels_from_record(json.load(f))
167
+ except (OSError, json.JSONDecodeError, TypeError):
168
+ return {}
169
+
170
+
171
+ def attach_reasons(rows: list[dict], labels: dict[int, dict]) -> list[dict]:
172
+ """Put the triage verdict on every row that came from a seed message."""
173
+ for row in rows:
174
+ src = row.get("src")
175
+ lab = labels.get(src) if src is not None else None
176
+ if lab:
177
+ row["action"] = lab.get("action") or ""
178
+ row["reason"] = lab.get("reason") or ""
179
+ elif row["kind"] == "added":
180
+ row["action"] = "added"
181
+ row["reason"] = "written by the rewrite; no seed message"
182
+ else:
183
+ row["action"] = row["action"] or "keep"
184
+ return rows
185
+
186
+
187
+ def build(seed: Any, out: Any, labels: Optional[dict[int, dict]] = None) -> dict:
188
+ """Rows + summary for one seed/output pair."""
189
+ rows = attach_reasons(align(seed, out), labels or {})
190
+ counts = {k: 0 for k in KINDS}
191
+ for row in rows:
192
+ counts[row["kind"]] = counts.get(row["kind"], 0) + 1
193
+ return {
194
+ "rows": rows,
195
+ "summary": {
196
+ "in": len(messages_of(seed)),
197
+ "out": len(messages_of(out)),
198
+ **counts,
199
+ },
200
+ }
201
+
202
+
203
+ # --------------------------------------------------------------------- diff
204
+
205
+ def word_diff(a: str, b: str) -> list[tuple[str, str]]:
206
+ """Token-level (op, text) segments: equal / delete / insert."""
207
+ ta, tb = _TOKEN.findall(a), _TOKEN.findall(b)
208
+ sm = difflib.SequenceMatcher(None, ta, tb, autojunk=False)
209
+ segs: list[tuple[str, str]] = []
210
+ for tag, i1, i2, j1, j2 in sm.get_opcodes():
211
+ if tag == "equal":
212
+ segs.append(("equal", "".join(ta[i1:i2])))
213
+ elif tag == "delete":
214
+ segs.append(("delete", "".join(ta[i1:i2])))
215
+ elif tag == "insert":
216
+ segs.append(("insert", "".join(tb[j1:j2])))
217
+ else:
218
+ segs.append(("delete", "".join(ta[i1:i2])))
219
+ segs.append(("insert", "".join(tb[j1:j2])))
220
+ return segs
221
+
222
+
223
+ def _esc(s: Any) -> str:
224
+ return html.escape(str(s if s is not None else ""))
225
+
226
+
227
+ def _inline_diff(before: str, after: str) -> str:
228
+ before, cut_a = _clip(before)
229
+ after, cut_b = _clip(after)
230
+ parts = []
231
+ for op, text in word_diff(before, after):
232
+ if op == "equal":
233
+ parts.append(_esc(text))
234
+ elif op == "delete":
235
+ parts.append(f"<del>{_esc(text)}</del>")
236
+ else:
237
+ parts.append(f"<ins>{_esc(text)}</ins>")
238
+ body = "".join(parts)
239
+ if cut_a or cut_b:
240
+ body += (f'<span class="tf-more">…[+{cut_a + cut_b} chars not shown]'
241
+ "</span>")
242
+ return body
243
+
244
+
245
+ _BADGE = {
246
+ "kept": ("tf-ok", "unchanged"),
247
+ "edited": ("tf-warn", "rewritten"),
248
+ "dropped": ("tf-bad", "removed"),
249
+ "added": ("tf-ok", "added"),
250
+ }
251
+
252
+
253
+ def _why(row: dict) -> str:
254
+ action = row.get("action") or ""
255
+ reason = row.get("reason") or ""
256
+ if row["kind"] == "kept":
257
+ return "kept as-is"
258
+ bits = []
259
+ if action and action not in ("keep", row["kind"]):
260
+ bits.append(f"plan: {action}")
261
+ if reason:
262
+ bits.append(reason)
263
+ return " — ".join(bits) or "no reason recorded"
264
+
265
+
266
+ def _row_html(row: dict) -> str:
267
+ cls, label = _BADGE.get(row["kind"], ("", row["kind"]))
268
+ idx = f'#{row["src"]}' if row.get("src") is not None else "new"
269
+ head = (f'<div class="tf-diff-h">'
270
+ f'<span class="tf-pill {cls}">{label}</span>'
271
+ f'<span class="tf-idx">{_esc(idx)}</span>'
272
+ f'<span class="tf-role">{_esc(row.get("role") or "")}</span>'
273
+ f'<span class="tf-why">{_esc(_why(row))}</span></div>')
274
+ if row["kind"] == "kept":
275
+ preview = (row["before"] or "").strip().splitlines()
276
+ first = preview[0][:110] if preview else ""
277
+ more = f" (+{len(preview) - 1} lines)" if len(preview) > 1 else ""
278
+ body = (f'<details class="tf-diff-b"><summary>{_esc(first)}'
279
+ f'{_esc(more)}</summary><pre>{_esc(_clip(row["before"])[0])}'
280
+ "</pre></details>")
281
+ elif row["kind"] == "dropped":
282
+ body = f'<div class="tf-diff-b"><del>{_esc(_clip(row["before"])[0])}</del></div>'
283
+ elif row["kind"] == "added":
284
+ body = f'<div class="tf-diff-b"><ins>{_esc(_clip(row["after"])[0])}</ins></div>'
285
+ else:
286
+ body = f'<div class="tf-diff-b">{_inline_diff(row["before"], row["after"])}</div>'
287
+ return f'<div class="tf-diff-row tf-k-{row["kind"]}">{head}{body}</div>'
288
+
289
+
290
+ def render_html(diff: dict, *, only: str = "all") -> str:
291
+ """The whole viewer body. `only` is all / changed / removed."""
292
+ rows = diff.get("rows") or []
293
+ if only == "changed":
294
+ rows = [r for r in rows if r["kind"] != "kept"]
295
+ elif only == "removed":
296
+ rows = [r for r in rows if r["kind"] == "dropped"]
297
+ s = diff.get("summary") or {}
298
+ if not s.get("in") and not s.get("out"):
299
+ return '<div class="tf-head">nothing to compare</div>'
300
+ head = (f'<div class="tf-diff-sum">{s.get("in", 0)} → {s.get("out", 0)} '
301
+ f'messages · <span class="tf-bad">{s.get("dropped", 0)} removed</span>'
302
+ f' · <span class="tf-warn">{s.get("edited", 0)} rewritten</span>'
303
+ f' · <span class="tf-ok">{s.get("added", 0)} added</span>'
304
+ f' · {s.get("kept", 0)} unchanged</div>')
305
+ if not rows:
306
+ return ('<div class="tf-wrap">' + head +
307
+ '<div class="tf-head">no rows match this filter</div></div>')
308
+ return ('<div class="tf-wrap">' + head +
309
+ "".join(_row_html(r) for r in rows) + "</div>")
310
+
311
+
312
+ def triage_path(cfg: Any, session_id: str) -> str:
313
+ """Where triage cached the labels for one session."""
314
+ return os.path.join(getattr(cfg, "triage_dir", ""), f"{session_id}.json")