JacobLinCool commited on
Commit
bd351d2
·
verified ·
1 Parent(s): 840ab13

feat: serve designer React frontend via gradio.Server on ZeroGPU

Browse files
README.md CHANGED
@@ -3,14 +3,10 @@ title: Trace Field Notes
3
  colorFrom: green
4
  colorTo: gray
5
  sdk: gradio
6
- sdk_version: 5.50.0
7
  app_file: app.py
8
  pinned: false
9
  license: mit
10
- hf_oauth: true
11
- hf_oauth_scopes:
12
- - inference-api
13
- hf_oauth_expiration_minutes: 480
14
  ---
15
 
16
  # Trace Field Notes
@@ -22,11 +18,27 @@ telemetry by default and analyzes only the agent's visible narrative messages:
22
  what it planned, where it got stuck, how it detoured, how it recovered, and how
23
  it claimed completion.
24
 
25
- Built for the Build Small Hackathon as a Gradio app. The default engine is the
26
- quick Qwen3.5 9B model-assisted path on ZeroGPU, with a verified deterministic
27
- codebook analyzer as the always-available recovery path. The app also exposes
28
- `nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16` through Hugging Face Inference
29
- Providers when the user signs in with Hugging Face OAuth.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  ## Run Locally
32
 
@@ -45,18 +57,20 @@ python3.11 -m unittest discover -s tests
45
 
46
  ## Analysis Engines
47
 
48
- - `Quick small-model assist: Qwen3.5 9B`: default model-assisted memo.
49
- - `NVIDIA Nemotron 3 Nano 30B-A3B assist`: uses Nemotron through the signed-in
50
- user's `inference-api` OAuth scope.
51
- - `Deterministic field notes`: local, no model dependency.
52
 
53
- If a selected model is unavailable or the user is not signed in, the report
54
- records the reason in model notes and returns the deterministic analysis instead
55
- of failing the whole Space.
56
 
57
- The Gradio endpoint is decorated with `@spaces.GPU` so the app can run on
58
- Hugging Face ZeroGPU hardware. The deterministic path still works without model
59
- weights; ZeroGPU only supplies the runtime contract and queueing surface.
 
 
60
 
61
  ## Agent Session Locations
62
 
 
3
  colorFrom: green
4
  colorTo: gray
5
  sdk: gradio
6
+ sdk_version: 6.16.0
7
  app_file: app.py
8
  pinned: false
9
  license: mit
 
 
 
 
10
  ---
11
 
12
  # Trace Field Notes
 
18
  what it planned, where it got stuck, how it detoured, how it recovered, and how
19
  it claimed completion.
20
 
21
+ Built for the Build Small Hackathon. The frontend is a custom React field-notebook
22
+ UI (a trail map of the session) served by `gradio.Server`; it calls the Python
23
+ `analyze_trace` endpoint through `@gradio/client`. Both models run on the Space
24
+ GPU through ZeroGPU: a quick `Qwen/Qwen3.5-9B` pass by default, and the larger
25
+ `nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16` for deeper analysis. A verified
26
+ deterministic codebook analyzer is the always-available recovery path and needs
27
+ no model or GPU.
28
+
29
+ ## Architecture
30
+
31
+ - `app.py` — a `gradio.Server` (FastAPI) app. It serves `frontend/index.html`,
32
+ mounts `frontend/static/`, exposes `@server.api("analyze_trace")` (queued, with
33
+ `gradio_client` compatibility), and an `/agents.md` instructions endpoint.
34
+ - `frontend/` — the designer's React app (in-browser Babel, no build step):
35
+ `field_report.css` (the design system), `data.js` (codebook + tone labels),
36
+ `components.jsx` (atoms + trail map + report sections), `app.jsx` (shell +
37
+ upload, wired to the backend).
38
+ - `view_model.py` — adapts an `AnalysisResult` into the JSON shape the frontend
39
+ renders (synthesizes the whole-session `verdict`, `captured`, `duration_total`).
40
+ - `analyzer.py` / `parser.py` / `redaction.py` / `schemas.py` — the deterministic
41
+ pipeline. `model_runtime.py` — the optional small-model assist on ZeroGPU.
42
 
43
  ## Run Locally
44
 
 
57
 
58
  ## Analysis Engines
59
 
60
+ - `Qwen3.5 9B — quick analysis`: default model pass on the Space GPU.
61
+ - `NVIDIA Nemotron 3 Nano 30B-A3B — deeper analysis`: the larger model on the
62
+ Space GPU for a richer memo.
63
+ - `Rule-based instant, no model`: local codebook analyzer, no model or GPU.
64
 
65
+ If a model fails to load or returns invalid JSON, the report records the reason
66
+ in model notes and returns the deterministic analysis instead of failing the
67
+ whole Space.
68
 
69
+ The model-backed analysis runs under `@spaces.GPU(size="xlarge")` so the weights
70
+ load on Hugging Face ZeroGPU hardware; `Qwen/Qwen3.5-9B` and
71
+ `nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16` are loaded with `transformers` and
72
+ cached across requests. The rule-based engine runs on CPU and never requests a
73
+ GPU slot, so it returns instantly.
74
 
75
  ## Agent Session Locations
76
 
analyzer.py CHANGED
@@ -119,7 +119,6 @@ def analyze_trace_file(
119
  ignore_tool_calls: bool = True,
120
  report_style: str = "field_notes",
121
  analysis_engine: str = "deterministic",
122
- hf_token: str | None = None,
123
  ) -> tuple[AnalysisResult, str]:
124
  """Parse, optionally redact, and analyze an uploaded trace file."""
125
 
@@ -193,7 +192,6 @@ def analyze_trace_file(
193
  engine=analysis_engine,
194
  result=result,
195
  narrative_text=narrative_text,
196
- token=hf_token,
197
  )
198
  except Exception as exc:
199
  error_message = str(exc).strip().rstrip(".")
 
119
  ignore_tool_calls: bool = True,
120
  report_style: str = "field_notes",
121
  analysis_engine: str = "deterministic",
 
122
  ) -> tuple[AnalysisResult, str]:
123
  """Parse, optionally redact, and analyze an uploaded trace file."""
124
 
 
192
  engine=analysis_engine,
193
  result=result,
194
  narrative_text=narrative_text,
 
195
  )
196
  except Exception as exc:
197
  error_message = str(exc).strip().rstrip(".")
app.py CHANGED
@@ -1,355 +1,132 @@
1
- """Gradio entrypoint for the Trace Field Notes Hugging Face Space."""
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
- import json
6
- import tempfile
7
  from pathlib import Path
8
- from typing import Any, Optional
9
 
10
- import gradio as gr
11
  import spaces
 
 
 
 
12
 
13
  from analyzer import analyze_trace_file
14
- from model_runtime import MODEL_CHOICES
15
  from parser import TraceParseError
16
- from report_renderer import render_report
17
 
18
 
19
- SPACE_URL = "https://huggingface.co/spaces/build-small-hackathon/trace-field-notes"
20
- DEFAULT_ANALYSIS_ENGINE = "qwen"
21
- SAMPLE_TRACE_PATH = "examples/sample_trace_redacted.jsonl"
22
 
23
- PRIVACY_WARNING = (
24
- "Agent traces can contain prompts, tool inputs, command outputs, local file paths, "
25
- "screenshots, secrets, private source code, and personal data. Redact before uploading. "
26
- "This app analyzes only visible agent narrative messages by default and does not need raw tool outputs."
27
- )
28
 
29
- HERO_MD = """
30
- **ZeroGPU field report**
31
 
32
- # Trace Field Notes
 
 
 
33
 
34
- Map where a coding agent got stuck, changed route, recovered, and claimed success.
35
- """
36
 
37
- SESSION_PATHS_MD = """
38
- ### Session Logs
 
 
 
 
 
 
39
 
40
- | Agent | Local session directory |
41
- |---|---|
42
- | Codex | `~/.codex/sessions` |
43
- | Claude Code | `~/.claude/projects` |
44
- | Pi Agent | `~/.pi/agent/sessions` |
45
- """
46
 
47
- AGENT_PROMPT = f"""Use this Space as a tool.
48
- 1. Read: {SPACE_URL}/agents.md
49
- 2. Find my latest local agent session log:
50
- - Codex: ~/.codex/sessions
51
- - Claude Code: ~/.claude/projects
52
- - Pi Agent: ~/.pi/agent/sessions
53
- 3. Review and redact secrets or private code before upload.
54
- 4. Upload the JSONL to the Space.
55
- 5. Ask for narrative difficulty analysis.
56
- 6. Return the report. Do not publish the raw trace.
57
- """
58
 
59
- CUSTOM_CSS = """
60
- :root {
61
- --field-border: rgba(148, 163, 184, 0.28);
62
- --field-ink: #f8fafc;
63
- --field-muted: #94a3b8;
64
- --field-panel: rgba(15, 23, 42, 0.74);
65
- --field-panel-strong: rgba(15, 23, 42, 0.92);
66
- --field-accent: #2f8a69;
67
- --field-accent-strong: #23785d;
68
- }
69
- .gradio-container {
70
- max-width: 1220px !important;
71
- color: var(--field-ink);
72
- }
73
- .hero {
74
- border: 1px solid var(--field-border);
75
- border-radius: 8px;
76
- padding: 18px 20px;
77
- background: linear-gradient(135deg, rgba(47, 138, 105, 0.18), rgba(15, 23, 42, 0.3));
78
- }
79
- .hero h1 {
80
- margin: 0;
81
- font-size: 34px;
82
- line-height: 1.08;
83
- }
84
- .hero p {
85
- max-width: 760px;
86
- margin: 10px 0 0;
87
- color: var(--field-muted);
88
- font-size: 15px;
89
- }
90
- .hero strong {
91
- margin-bottom: 8px;
92
- color: #7dd3fc;
93
- font: 700 12px/1.2 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
94
- text-transform: uppercase;
95
- letter-spacing: 0;
96
- }
97
- .privacy-callout {
98
- margin: 12px 0 16px;
99
- border-left: 3px solid #f59e0b;
100
- padding: 10px 12px;
101
- color: #dbe4ef;
102
- background: rgba(245, 158, 11, 0.08);
103
- border-radius: 0 6px 6px 0;
104
- }
105
- .trace-panel {
106
- border: 1px solid var(--field-border);
107
- border-radius: 8px;
108
- padding: 16px;
109
- background: var(--field-panel);
110
- }
111
- .guide-panel {
112
- border: 1px solid var(--field-border);
113
- border-radius: 8px;
114
- padding: 16px;
115
- background: var(--field-panel);
116
- }
117
- .guide-panel table {
118
- width: 100%;
119
- }
120
- .action-row button {
121
- min-height: 42px;
122
- }
123
- button.primary {
124
- background: var(--field-accent) !important;
125
- border-color: var(--field-accent) !important;
126
- }
127
- button.primary:hover {
128
- background: var(--field-accent-strong) !important;
129
- }
130
- .download-row {
131
- align-items: stretch;
132
- }
133
- .result-tabs {
134
- margin-top: 14px;
135
- }
136
- textarea, input {
137
- border-radius: 6px !important;
138
- }
139
  """
140
 
141
 
142
- def _analyze_trace_impl(
143
- trace_file: Any,
144
- include_user_context: bool = True,
145
- redact_secrets: bool = True,
146
- ignore_tool_calls: bool = True,
147
- report_style: str = "field_notes",
148
- analysis_engine: str = DEFAULT_ANALYSIS_ENGINE,
149
- oauth_token: Optional[gr.OAuthToken] = None,
150
- ) -> tuple[str, dict[str, Any], str, str, str]:
151
- """Gradio-callable analysis endpoint."""
152
 
153
- if trace_file is None:
154
- raise gr.Error("Upload a .jsonl, .json, .txt, or .log trace file first.")
155
 
156
- path = uploaded_path(trace_file)
157
- try:
158
- result, redacted_narrative = analyze_trace_file(
159
- path,
160
- include_user_context=include_user_context,
161
- redact_secrets=redact_secrets,
162
- ignore_tool_calls=ignore_tool_calls,
163
- report_style=report_style,
164
- analysis_engine=analysis_engine,
165
- hf_token=oauth_token.token if oauth_token else None,
166
- )
167
- except TraceParseError as exc:
168
- raise gr.Error(str(exc)) from exc
169
- except Exception as exc: # pragma: no cover - surfaced to the Space UI.
170
- raise gr.Error(f"Analysis failed: {exc}") from exc
171
-
172
- report_markdown = render_report(result)
173
- result_json = result.to_dict()
174
- redacted_file = write_temp_artifact("trace-field-notes-redacted-", ".md", redacted_narrative)
175
- report_file = write_temp_artifact("trace-field-notes-report-", ".md", report_markdown)
176
- json_file = write_temp_artifact(
177
- "trace-field-notes-episodes-",
178
- ".json",
179
- json.dumps(result_json, indent=2, ensure_ascii=False) + "\n",
180
- )
181
- return report_markdown, result_json, redacted_file, report_file, json_file
182
 
183
 
184
- @spaces.GPU(duration=90)
185
- def analyze_trace(
186
- trace_file: Any,
187
- include_user_context: bool = True,
188
- redact_secrets: bool = True,
189
- ignore_tool_calls: bool = True,
190
- report_style: str = "field_notes",
191
- analysis_engine: str = DEFAULT_ANALYSIS_ENGINE,
192
- oauth_token: Optional[gr.OAuthToken] = None,
193
- ) -> tuple[str, dict[str, Any], str, str, str]:
194
- """ZeroGPU-visible Gradio endpoint."""
195
-
196
- return _analyze_trace_impl(
197
- trace_file=trace_file,
 
 
198
  include_user_context=include_user_context,
199
  redact_secrets=redact_secrets,
200
- ignore_tool_calls=ignore_tool_calls,
201
- report_style=report_style,
202
  analysis_engine=analysis_engine,
203
- oauth_token=oauth_token,
204
  )
205
 
206
 
207
- def uploaded_path(trace_file: Any) -> Path:
208
- if isinstance(trace_file, (str, Path)):
209
- return Path(trace_file)
210
- name = getattr(trace_file, "name", None)
211
- if name:
212
- return Path(name)
213
- path = getattr(trace_file, "path", None)
214
- if path:
215
- return Path(path)
216
- raise gr.Error("Could not resolve the uploaded file path.")
217
-
218
-
219
- def write_temp_artifact(prefix: str, suffix: str, content: str) -> str:
220
- with tempfile.NamedTemporaryFile(
221
- "w",
222
- encoding="utf-8",
223
- prefix=prefix,
224
- suffix=suffix,
225
- delete=False,
226
- ) as handle:
227
- handle.write(content)
228
- return handle.name
229
-
230
-
231
- def load_sample_trace() -> tuple[str, bool, bool, bool, str, str]:
232
- return SAMPLE_TRACE_PATH, True, True, True, "field_notes", DEFAULT_ANALYSIS_ENGINE
233
-
234
-
235
- with gr.Blocks(
236
- title="Trace Field Notes",
237
- css=CUSTOM_CSS,
238
- theme=gr.themes.Base(
239
- primary_hue="green",
240
- neutral_hue="stone",
241
- font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
242
- font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace"],
243
- ),
244
- ) as demo:
245
- gr.Markdown(HERO_MD, elem_classes=["hero"])
246
- gr.Markdown(PRIVACY_WARNING, elem_classes=["privacy-callout"])
247
-
248
- with gr.Row(equal_height=False):
249
- with gr.Column(scale=3, elem_classes=["trace-panel"]):
250
- gr.Markdown("### Trace Input")
251
- trace_input = gr.File(
252
- label="Agent session log",
253
- file_types=[".jsonl", ".json", ".txt", ".log"],
254
- type="filepath",
255
- )
256
- with gr.Row():
257
- include_user_context = gr.Checkbox(
258
- value=True,
259
- label="Include user context",
260
- )
261
- redact_secrets = gr.Checkbox(
262
- value=True,
263
- label="Redact likely secrets",
264
- )
265
- ignore_tool_calls = gr.Checkbox(
266
- value=True,
267
- label="Ignore tool contents",
268
- interactive=False,
269
- )
270
- report_style = gr.Radio(
271
- choices=[("Field notes", "field_notes")],
272
- value="field_notes",
273
- label="Report style",
274
- interactive=False,
275
- visible=False,
276
- )
277
- analysis_engine = gr.Radio(
278
- choices=[
279
- (str(choice["label"]), key)
280
- for key, choice in MODEL_CHOICES.items()
281
- ],
282
- value=DEFAULT_ANALYSIS_ENGINE,
283
- label="Analysis engine",
284
  )
285
- with gr.Row():
286
- gr.LoginButton(
287
- value="Sign in for model assist",
288
- logout_value="Signed in as {}",
289
- size="sm",
290
- )
291
- gr.Markdown(
292
- "Model-assisted modes use your signed-in Hugging Face OAuth token with the `inference-api` scope. "
293
- "The deterministic engine does not require sign-in."
294
  )
295
- with gr.Row(elem_classes=["action-row"]):
296
- analyze_button = gr.Button("Analyze My Trace", variant="primary")
297
- sample_button = gr.Button("Use Sample Trace", variant="secondary")
298
- with gr.Column(scale=2, elem_classes=["guide-panel"]):
299
- gr.Markdown(SESSION_PATHS_MD)
300
- with gr.Accordion("Agent-callable prompt", open=False):
301
- gr.Textbox(
302
- value=AGENT_PROMPT,
303
- label="Prompt for Codex or Claude Code",
304
- lines=9,
305
- interactive=False,
306
- show_copy_button=True,
307
- )
308
-
309
- sample_button.click(
310
- load_sample_trace,
311
- inputs=None,
312
- outputs=[
313
- trace_input,
314
- include_user_context,
315
- redact_secrets,
316
- ignore_tool_calls,
317
- report_style,
318
- analysis_engine,
319
- ],
320
- )
321
 
322
- with gr.Tabs(elem_classes=["result-tabs"]):
323
- with gr.Tab("Field Report"):
324
- report_output = gr.Markdown(label="Field Report")
325
- with gr.Tab("Episodes JSON"):
326
- episode_json = gr.JSON(label="Structured Episode JSON")
327
- with gr.Tab("Downloads"):
328
- with gr.Row(elem_classes=["download-row"]):
329
- redacted_download = gr.File(label="Redacted Narrative")
330
- report_download = gr.File(label="Markdown Report")
331
- json_download = gr.File(label="Structured JSON")
332
-
333
- analyze_button.click(
334
- analyze_trace,
335
- inputs=[
336
- trace_input,
337
- include_user_context,
338
- redact_secrets,
339
- ignore_tool_calls,
340
- report_style,
341
- analysis_engine,
342
- ],
343
- outputs=[
344
- report_output,
345
- episode_json,
346
- redacted_download,
347
- report_download,
348
- json_download,
349
- ],
350
- api_name="analyze_trace",
351
- )
352
 
353
 
354
  if __name__ == "__main__":
355
- demo.launch()
 
 
 
 
 
1
+ """Trace Field Notes gradio.Server backend behind the designer's React frontend.
2
+
3
+ The custom frontend (``frontend/``) is served as static files; it talks to the
4
+ ``analyze_trace`` endpoint below through ``@gradio/client``. The endpoint runs the
5
+ deterministic analyzer (and the optional small-model assist on ZeroGPU) and
6
+ returns the frontend-ready view model.
7
+ """
8
 
9
  from __future__ import annotations
10
 
11
+ import os
 
12
  from pathlib import Path
 
13
 
 
14
  import spaces
15
+ from fastapi.responses import HTMLResponse, PlainTextResponse
16
+ from fastapi.staticfiles import StaticFiles
17
+ from gradio import Server
18
+ from gradio.data_classes import FileData
19
 
20
  from analyzer import analyze_trace_file
 
21
  from parser import TraceParseError
22
+ from view_model import build_view_model
23
 
24
 
25
+ HERE = Path(__file__).resolve().parent
26
+ FRONTEND = HERE / "frontend"
 
27
 
28
+ READABLE_AGENT = {"codex": "Codex", "claude_code": "Claude Code", "pi": "Pi Agent", "unknown": "Agent"}
 
 
 
 
29
 
30
+ AGENTS_MD = """# Trace Field Notes — agent instructions
 
31
 
32
+ This Space turns a coding-agent session log into a qualitative *field report*:
33
+ where the agent got stuck, where it changed route, how it recovered, and how
34
+ honestly it claimed success. It reads only the agent's visible narrative
35
+ messages and ignores raw tool telemetry.
36
 
37
+ ## How to use it as a tool
 
38
 
39
+ 1. Find the user's latest local session log:
40
+ - Codex: `~/.codex/sessions`
41
+ - Claude Code: `~/.claude/projects`
42
+ - Pi Agent: `~/.pi/agent/sessions`
43
+ 2. Review it and redact secrets, tokens, local paths, and private code first.
44
+ 3. Upload the `.jsonl` (`.json` / `.txt` / `.log` also accepted) and call the
45
+ `analyze_trace` API endpoint.
46
+ 4. Return the field report to the user. Do not publish the raw trace.
47
 
48
+ ## API
 
 
 
 
 
49
 
50
+ `POST` via the Gradio client, endpoint `/analyze_trace`:
51
+
52
+ - `trace_file` (file): the session log
53
+ - `include_user_context` (bool): include user prompts as framing
54
+ - `redact_secrets` (bool): redact likely secrets before analysis
55
+ - `analysis_engine` (str): `qwen` | `nemotron` | `deterministic`
 
 
 
 
 
56
 
57
+ Returns a JSON view model: a whole-session `verdict`, per-episode difficulty
58
+ `episodes`, and redacted export text.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  """
60
 
61
 
62
+ server = Server(title="Trace Field Notes")
63
+ server.mount("/static", StaticFiles(directory=str(FRONTEND / "static")), name="static")
 
 
 
 
 
 
 
 
64
 
 
 
65
 
66
+ @server.get("/", response_class=HTMLResponse)
67
+ def index() -> str:
68
+ return (FRONTEND / "index.html").read_text(encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
 
71
+ @server.get("/agents.md", response_class=PlainTextResponse)
72
+ def agents_md() -> str:
73
+ return AGENTS_MD
74
+
75
+
76
+ @spaces.GPU(size="xlarge", duration=180)
77
+ def _analyze_on_gpu(
78
+ path: str,
79
+ include_user_context: bool,
80
+ redact_secrets: bool,
81
+ analysis_engine: str,
82
+ ):
83
+ """Model-backed analysis on the Space GPU (loads weights via transformers)."""
84
+
85
+ return analyze_trace_file(
86
+ path,
87
  include_user_context=include_user_context,
88
  redact_secrets=redact_secrets,
89
+ ignore_tool_calls=True,
 
90
  analysis_engine=analysis_engine,
 
91
  )
92
 
93
 
94
+ @server.api(name="analyze_trace")
95
+ def analyze_trace(
96
+ trace_file: FileData,
97
+ include_user_context: bool = True,
98
+ redact_secrets: bool = True,
99
+ analysis_engine: str = "qwen",
100
+ ) -> dict:
101
+ """Analyze an uploaded trace and return the frontend view model."""
102
+
103
+ path = trace_file.path
104
+ try:
105
+ if analysis_engine == "deterministic":
106
+ result, narrative = analyze_trace_file(
107
+ path,
108
+ include_user_context=include_user_context,
109
+ redact_secrets=redact_secrets,
110
+ ignore_tool_calls=True,
111
+ analysis_engine="deterministic",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  )
113
+ else:
114
+ result, narrative = _analyze_on_gpu(
115
+ path, include_user_context, redact_secrets, analysis_engine
 
 
 
 
 
 
116
  )
117
+ except TraceParseError as exc:
118
+ raise ValueError(str(exc)) from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
+ if trace_file.orig_name:
121
+ agent = READABLE_AGENT.get(result.agent_type_guess, "Agent")
122
+ result.trace_title = f"{agent} · {trace_file.orig_name}"
123
+
124
+ return build_view_model(result, narrative)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
 
127
  if __name__ == "__main__":
128
+ server.launch(
129
+ server_name="0.0.0.0",
130
+ server_port=int(os.getenv("PORT", os.getenv("GRADIO_SERVER_PORT", "7860"))),
131
+ show_error=True,
132
+ )
frontend/index.html ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Trace Field Notes</title>
7
+ <meta name="description" content="Turn a coding-agent session log into a qualitative field report: where it got stuck, detoured, recovered, and how honestly it claimed success." />
8
+ <link rel="stylesheet" href="/static/field_report.css" />
9
+
10
+ <!-- React + Babel (in-browser JSX, matching the prototype) -->
11
+ <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
12
+ <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
13
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
14
+
15
+ <!-- Gradio JS client → talks to the Python @app.api endpoints -->
16
+ <script type="module">
17
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
18
+ window.__gradio = { Client, handle_file, clientPromise: Client.connect(window.location.origin) };
19
+ </script>
20
+ </head>
21
+ <body>
22
+ <div id="root" class="app-root" data-theme="dark"></div>
23
+
24
+ <!-- codebook labels + tone metadata + offline samples (window.TFN) -->
25
+ <script src="/static/data.js"></script>
26
+ <!-- shared atoms + trail map + report sections (verbatim from the design) -->
27
+ <script type="text/babel" data-presets="react" src="/static/components.jsx"></script>
28
+ <!-- shell + landing, wired to the backend -->
29
+ <script type="text/babel" data-presets="react" src="/static/app.jsx"></script>
30
+ </body>
31
+ </html>
frontend/static/app.jsx ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ app.jsx — shell + landing, wired to the gradio.Server backend.
3
+ Adapted from the designer's prototype: the demo's fake upload
4
+ is replaced with a real file picker that calls /analyze_trace
5
+ through @gradio/client; the tweaks panel is dropped and the
6
+ theme is pinned to the dusk-survey dark mode.
7
+ ============================================================ */
8
+
9
+ function BrandMark({ size = 34 }) {
10
+ return (
11
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none" aria-hidden="true" className="brandmark">
12
+ <circle cx="20" cy="20" r="17" stroke="var(--edge-strong)" strokeWidth="1.2" />
13
+ <path d="M8 24 C 14 16, 18 28, 24 18 S 32 12, 33 14" stroke="var(--ink-3)" strokeWidth="1" fill="none" strokeDasharray="1.5 3" />
14
+ <path d="M6 20 C 13 10, 20 30, 27 16 S 34 14, 35 20" stroke="var(--accent)" strokeWidth="2" fill="none" strokeLinecap="round" />
15
+ <circle cx="13" cy="20" r="2.4" fill="var(--tone-stable)" />
16
+ <circle cx="22" cy="22.5" r="2.4" fill="var(--tone-partial)" />
17
+ <circle cx="30" cy="17" r="2.4" fill="var(--tone-risk)" />
18
+ <path d="M30 17 L30 9 L36 11 L30 13" fill="var(--accent)" />
19
+ </svg>
20
+ );
21
+ }
22
+
23
+ function TopBar() {
24
+ return (
25
+ <header className="topbar">
26
+ <div className="topbar__brand">
27
+ <BrandMark />
28
+ <div className="topbar__word">
29
+ <span className="topbar__name">Trace Field Notes</span>
30
+ <span className="topbar__tag mono">narrative analysis for coding-agent traces</span>
31
+ </div>
32
+ </div>
33
+ <div className="topbar__right mono">
34
+ <span className="topbar__pill">narrative-only</span>
35
+ <span className="topbar__pill">privacy-first</span>
36
+ </div>
37
+ </header>
38
+ );
39
+ }
40
+
41
+ const ENGINES = [
42
+ ["qwen", "Quick analysis", "Qwen3.5 9B"],
43
+ ["nemotron", "Deeper analysis", "Nemotron 3 Nano 30B-A3B"],
44
+ ["deterministic", "Rule-based", "no model, always on"],
45
+ ];
46
+
47
+ function Toggle({ on, set, label, sub, locked }) {
48
+ return (
49
+ <button className={"toggle" + (on ? " toggle--on" : "") + (locked ? " toggle--locked" : "")}
50
+ onClick={() => !locked && set(!on)} aria-pressed={on}>
51
+ <span className="toggle__sw"><span className="toggle__knob" /></span>
52
+ <span className="toggle__txt">
53
+ <span className="toggle__label">{label}{locked ? " 🔒" : ""}</span>
54
+ <span className="toggle__sub muted">{sub}</span>
55
+ </span>
56
+ </button>
57
+ );
58
+ }
59
+
60
+ function LandingView({ onAnalyze, onSample, error }) {
61
+ const [staged, setStaged] = React.useState(null); // { name, file }
62
+ const [redact, setRedact] = React.useState(true);
63
+ const [userCtx, setUserCtx] = React.useState(true);
64
+ const [engine, setEngine] = React.useState("qwen");
65
+ const [dragOver, setDragOver] = React.useState(false);
66
+ const [copied, setCopied] = React.useState(false);
67
+ const fileRef = React.useRef(null);
68
+
69
+ const chosen = ENGINES.find((e) => e[0] === engine) || ENGINES[2];
70
+ const engineLabel = chosen[1] + ": " + chosen[2];
71
+
72
+ function onFiles(list) {
73
+ const f = list && list[0];
74
+ if (f) setStaged({ name: f.name, file: f });
75
+ }
76
+ function pick() { if (fileRef.current) fileRef.current.click(); }
77
+ function run() {
78
+ if (!staged) return;
79
+ onAnalyze({ file: staged.file, include_user_context: userCtx, redact_secrets: redact, analysis_engine: engine, engineLabel });
80
+ }
81
+
82
+ const AGENT_PROMPT = `Use this Space as a tool.
83
+ 1. Read its /agents.md endpoint.
84
+ 2. Find my latest local agent session log
85
+ (Codex ~/.codex/sessions, Claude ~/.claude/projects).
86
+ 3. Review and redact secrets before upload.
87
+ 4. Upload the JSONL and request a narrative difficulty analysis.
88
+ 5. Return the report. Do not publish the raw trace.`;
89
+
90
+ return (
91
+ <div className="landing">
92
+ <TopBar />
93
+
94
+ <section className="hero">
95
+ <Kicker>Field report · qualitative, not a leaderboard</Kicker>
96
+ <h1 className="hero__title">See how your coding agent<br /> got stuck, detoured, recovered<span className="hero__amp"> &amp; </span>claimed success.</h1>
97
+ <p className="hero__sub">
98
+ Upload a Codex, Claude Code, or Pi Agent session log. Trace Field Notes reads only the agent's
99
+ <em> narrated</em> messages — what it planned, where it snagged, how it rerouted, and how honestly it called it done —
100
+ and charts the session as a trail you can walk.
101
+ </p>
102
+ </section>
103
+
104
+ <div className="privacy">
105
+ <span className="privacy__mark">!</span>
106
+ <p>
107
+ Agent traces can carry prompts, command output, local paths, screenshots, secrets, and private code.
108
+ <b> Review and redact before uploading or sharing.</b> This app analyzes only visible narrative messages and ignores raw tool telemetry by default.
109
+ </p>
110
+ </div>
111
+
112
+ {error ? (
113
+ <div className="privacy" style={{ borderColor: "var(--tone-risk)", borderLeftColor: "var(--tone-risk)" }}>
114
+ <span className="privacy__mark" style={{ background: "var(--tone-risk)" }}>×</span>
115
+ <p><b>Analysis failed.</b> {error}</p>
116
+ </div>
117
+ ) : null}
118
+
119
+ <div className="landing__grid">
120
+ {/* LEFT: upload */}
121
+ <div className="panel card card--raised">
122
+ <SectionHead kicker="Step 01" title="Bring a trace" />
123
+ <input ref={fileRef} type="file" accept=".jsonl,.json,.txt,.log" style={{ display: "none" }}
124
+ onChange={(e) => onFiles(e.target.files)} />
125
+ <div
126
+ className={"drop" + (dragOver ? " drop--over" : "") + (staged ? " drop--staged" : "")}
127
+ onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
128
+ onDragLeave={() => setDragOver(false)}
129
+ onDrop={(e) => { e.preventDefault(); setDragOver(false); onFiles(e.dataTransfer.files); }}
130
+ onClick={pick}
131
+ role="button" tabIndex={0}
132
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") pick(); }}
133
+ >
134
+ {staged ? (
135
+ <div className="drop__staged">
136
+ <span className="drop__file mono">{staged.name}</span>
137
+ <span className="label">staged · click Analyze</span>
138
+ </div>
139
+ ) : (
140
+ <div className="drop__empty">
141
+ <div className="drop__icon">⤓</div>
142
+ <span className="drop__title">Drop a <code>.jsonl</code> trace</span>
143
+ <span className="muted">or click to choose · .json .txt .log accepted</span>
144
+ </div>
145
+ )}
146
+ </div>
147
+
148
+ <div className="opts">
149
+ <Toggle on={redact} set={setRedact} label="Redact likely secrets" sub="emails, tokens, keys, paths" />
150
+ <Toggle on={userCtx} set={setUserCtx} label="Include user context" sub="user prompts as framing" />
151
+ <Toggle on={true} set={() => {}} locked label="Ignore tool contents" sub="locked for this release" />
152
+ </div>
153
+
154
+ <div className="engine">
155
+ <Label>Analysis engine</Label>
156
+ <div className="engine__opts">
157
+ {ENGINES.map(([key, name, detail]) => (
158
+ <button key={key}
159
+ className={"engine__opt" + (engine === key ? " engine__opt--on" : "")}
160
+ onClick={() => setEngine(key)}>
161
+ <span className="engine__name">{name}</span>
162
+ <span className="engine__detail mono">{detail}</span>
163
+ </button>
164
+ ))}
165
+ </div>
166
+ <p className="engine__note muted">Quick and Deeper run a small model on the Space GPU. Rule-based needs no model and never fails.</p>
167
+ </div>
168
+
169
+ <div className="panel__actions">
170
+ <button className="btn btn--primary" disabled={!staged} onClick={run}>
171
+ Analyze my trace
172
+ </button>
173
+ <button className="btn" onClick={() => onSample("short")}>Sample · short</button>
174
+ <button className="btn" onClick={() => onSample("long")}>Sample · long</button>
175
+ </div>
176
+ </div>
177
+
178
+ {/* RIGHT: guide */}
179
+ <div className="guide">
180
+ <div className="panel card">
181
+ <SectionHead kicker="Step 00" title="Find your session log" />
182
+ <table className="paths">
183
+ <tbody>
184
+ {[
185
+ ["Codex", "~/.codex/sessions"],
186
+ ["Claude Code", "~/.claude/projects"],
187
+ ["Pi Agent", "~/.pi/agent/sessions"],
188
+ ].map(([a, p]) => (
189
+ <tr key={a}>
190
+ <td className="paths__agent">{a}</td>
191
+ <td className="paths__path mono">{p}</td>
192
+ </tr>
193
+ ))}
194
+ </tbody>
195
+ </table>
196
+ </div>
197
+
198
+ <div className="panel card">
199
+ <div className="agentcall__hd">
200
+ <SectionHead kicker="Hands-free" title="Let the agent call it" />
201
+ <button className="btn btn--sm btn--ghost" onClick={() => {
202
+ try { navigator.clipboard && navigator.clipboard.writeText(AGENT_PROMPT); } catch (e) {}
203
+ setCopied(true); setTimeout(() => setCopied(false), 1400);
204
+ }}>
205
+ {copied ? "copied ✓" : "copy prompt"}
206
+ </button>
207
+ </div>
208
+ <p className="agentcall__blurb">Using Codex or Claude Code? Point it at this Space's <span className="mono">agents.md</span>. It finds your latest log, redacts it, uploads, and returns the report.</p>
209
+ <pre className="agentcall__pre mono">{AGENT_PROMPT}</pre>
210
+ </div>
211
+
212
+ <div className="getrow">
213
+ {[
214
+ ["Elevation trail", "every snag as a waypoint"],
215
+ ["Detour read", "exploration vs wandering"],
216
+ ["Closeout audit", "honest, or overclaimed?"],
217
+ ].map(([t, s]) => (
218
+ <div className="getrow__item" key={t}>
219
+ <span className="getrow__t">{t}</span>
220
+ <span className="getrow__s muted">{s}</span>
221
+ </div>
222
+ ))}
223
+ </div>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ );
228
+ }
229
+
230
+ const PIPELINE = [
231
+ "Uploading the trace",
232
+ "Extracting narrative messages",
233
+ "Redacting likely secrets",
234
+ "Charting difficulty episodes",
235
+ "Classifying with the codebook",
236
+ "Synthesizing field notes",
237
+ ];
238
+
239
+ function Analyzing({ label }) {
240
+ const [step, setStep] = React.useState(0);
241
+ React.useEffect(() => {
242
+ const id = setInterval(() => setStep((s) => (s + 1) % (PIPELINE.length + 1)), 700);
243
+ return () => clearInterval(id);
244
+ }, []);
245
+ return (
246
+ <div className="analyzing">
247
+ <div className="analyzing__card card card--raised">
248
+ <svg viewBox="0 0 320 120" className="analyzing__svg" aria-hidden="true">
249
+ <line x1="20" y1="100" x2="300" y2="100" stroke="var(--rule)" strokeDasharray="2 6" />
250
+ <path className="analyzing__trail"
251
+ d="M20 96 C 70 60, 100 104, 150 70 S 230 30, 300 44"
252
+ fill="none" stroke="var(--accent)" strokeWidth="2.6" strokeLinecap="round" />
253
+ <circle className="analyzing__dot" r="4.5" fill="var(--accent)" />
254
+ </svg>
255
+ <Kicker>Surveying the trace · {label}</Kicker>
256
+ <ul className="analyzing__steps">
257
+ {PIPELINE.map((s, i) => (
258
+ <li key={s} className={i < step ? "done" : i === step ? "active" : ""}>
259
+ <span className="analyzing__tick mono">{i < step ? "✓" : i === step ? "…" : "·"}</span>{s}
260
+ </li>
261
+ ))}
262
+ </ul>
263
+ </div>
264
+ </div>
265
+ );
266
+ }
267
+
268
+ function EmptyReport({ data, onReset }) {
269
+ return (
270
+ <div className="report">
271
+ <ReportHeader data={data} />
272
+ <section className="sec">
273
+ <div className="card" style={{ padding: "28px 30px" }}>
274
+ <SectionHead kicker="No episode surfaced" title="No explicit difficulty episode was strong enough to classify" />
275
+ <p className="sec-head__sub" style={{ maxWidth: "70ch" }}>
276
+ The trace yielded {data.narrative_message_count} visible narrative messages, but none carried clear
277
+ self-reported blockage, detour, or recovery language. That does not prove the session was trouble-free —
278
+ only that the narrative did not say so. Try the redacted-narrative export to read it yourself.
279
+ </p>
280
+ <div style={{ marginTop: 18 }}>
281
+ <button className="btn btn--sm btn--ghost" onClick={onReset}>← Analyze another trace</button>
282
+ </div>
283
+ </div>
284
+ </section>
285
+ </div>
286
+ );
287
+ }
288
+
289
+ function App() {
290
+ const [stage, setStage] = React.useState("landing"); // landing | analyzing | report
291
+ const [data, setData] = React.useState(null);
292
+ const [engineLabel, setEngineLabel] = React.useState("");
293
+ const [error, setError] = React.useState("");
294
+
295
+ async function analyze({ file, include_user_context, redact_secrets, analysis_engine, engineLabel }) {
296
+ setError("");
297
+ setEngineLabel(engineLabel || analysis_engine);
298
+ setStage("analyzing");
299
+ window.scrollTo({ top: 0 });
300
+ try {
301
+ const g = window.__gradio;
302
+ if (!g) throw new Error("Client is still loading — reload the page and try again.");
303
+ const client = await g.clientPromise;
304
+ const res = await client.predict("/analyze_trace", {
305
+ trace_file: g.handle_file(file),
306
+ include_user_context: !!include_user_context,
307
+ redact_secrets: !!redact_secrets,
308
+ analysis_engine,
309
+ });
310
+ const out = Array.isArray(res.data) ? res.data[0] : res.data;
311
+ if (!out || typeof out !== "object") throw new Error("The analyzer returned an empty response.");
312
+ setData(out);
313
+ setStage("report");
314
+ } catch (e) {
315
+ setError(String((e && e.message) || e));
316
+ setStage("landing");
317
+ }
318
+ window.scrollTo({ top: 0 });
319
+ }
320
+
321
+ function loadSample(key) {
322
+ const base = key === "short" ? window.TFN.SHORT : window.TFN.LONG;
323
+ setError("");
324
+ setEngineLabel(base.engine);
325
+ setData(base);
326
+ setStage("report");
327
+ window.scrollTo({ top: 0 });
328
+ }
329
+
330
+ function reset() { setStage("landing"); setData(null); window.scrollTo({ top: 0 }); }
331
+
332
+ const reportData = data ? Object.assign({}, data, { engine: engineLabel || data.engine }) : null;
333
+ const hasEpisodes = reportData && reportData.episodes && reportData.episodes.length;
334
+
335
+ return (
336
+ <div className="app-root" data-theme="dark" data-density="regular" data-voice="journal">
337
+ <div className="backdrop"><div className="grain" /><TopoBackground /></div>
338
+ <div className="page">
339
+ {stage === "landing" && <LandingView onAnalyze={analyze} onSample={loadSample} error={error} />}
340
+ {stage === "analyzing" && <Analyzing label={engineLabel} />}
341
+ {stage === "report" && (
342
+ <div className="report-wrap">
343
+ <button className="report-back btn btn--sm btn--ghost" onClick={reset}>← New trace</button>
344
+ {hasEpisodes
345
+ ? <ReportView data={reportData} variant="trail" onReset={reset} />
346
+ : <EmptyReport data={reportData} onReset={reset} />}
347
+ <footer className="foot">
348
+ <span className="mono">Trace Field Notes</span>
349
+ <span className="muted">Qualitative narrative analysis · we report what the agent said, not whether its code is correct.</span>
350
+ </footer>
351
+ </div>
352
+ )}
353
+ </div>
354
+ </div>
355
+ );
356
+ }
357
+
358
+ ReactDOM.createRoot(document.getElementById("root")).render(<App />);
frontend/static/components.jsx ADDED
@@ -0,0 +1,615 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ atoms.jsx — shared primitives + topo background
3
+ ============================================================ */
4
+
5
+ // ---- deterministic topo contour generator ----
6
+ function _noise(a, seed) {
7
+ return (
8
+ Math.sin(a * 3 + seed) * 0.45 +
9
+ Math.sin(a * 5 + seed * 1.7) * 0.28 +
10
+ Math.sin(a * 2 + seed * 0.6) * 0.5 +
11
+ Math.sin(a * 7 + seed * 2.3) * 0.16
12
+ );
13
+ }
14
+ function _blob(cx, cy, r, seed, amp) {
15
+ const N = 80;
16
+ let d = "";
17
+ for (let i = 0; i <= N; i++) {
18
+ const t = (i / N) * Math.PI * 2;
19
+ const rr = r * (1 + amp * _noise(t, seed));
20
+ const x = cx + rr * Math.cos(t);
21
+ const y = cy + rr * Math.sin(t) * 0.82;
22
+ d += (i === 0 ? "M" : "L") + x.toFixed(1) + " " + y.toFixed(1) + " ";
23
+ }
24
+ return d + "Z";
25
+ }
26
+
27
+ function TopoBackground() {
28
+ const peaks = [
29
+ { cx: 250, cy: 230, seed: 1.2, count: 11, base: 26, step: 34, peakAt: 3 },
30
+ { cx: 1160, cy: 640, seed: 4.7, count: 13, base: 24, step: 32, peakAt: 4 },
31
+ { cx: 760, cy: 120, seed: 8.1, count: 7, base: 30, step: 40, peakAt: 1 },
32
+ ];
33
+ return (
34
+ <svg viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
35
+ {peaks.map((p, pi) =>
36
+ Array.from({ length: p.count }).map((_, i) => {
37
+ const r = p.base + i * p.step;
38
+ const amp = 0.05 + i * 0.012;
39
+ const strong = i === p.peakAt;
40
+ return (
41
+ <path
42
+ key={pi + "-" + i}
43
+ d={_blob(p.cx, p.cy, r, p.seed + i * 0.13, amp)}
44
+ fill="none"
45
+ stroke={strong ? "var(--topo-stroke-strong)" : "var(--topo-stroke)"}
46
+ strokeWidth={strong ? 1.4 : 0.9}
47
+ />
48
+ );
49
+ })
50
+ )}
51
+ </svg>
52
+ );
53
+ }
54
+
55
+ // ---- tone helpers ----
56
+ function toneOf(recovery) {
57
+ return (window.TFN.TONE_OF[recovery]) || "unknown";
58
+ }
59
+ function toneColor(tone) {
60
+ return "var(--tone-" + tone + ")";
61
+ }
62
+
63
+ // ---- small atoms ----
64
+ function Kicker({ children }) {
65
+ return <div className="kicker">{children}</div>;
66
+ }
67
+
68
+ function Label({ children, accent, style }) {
69
+ return <div className={"label" + (accent ? " label--accent" : "")} style={style}>{children}</div>;
70
+ }
71
+
72
+ function ToneDot({ tone, size = 10 }) {
73
+ return (
74
+ <span
75
+ className="tone-dot"
76
+ style={{ background: toneColor(tone), color: toneColor(tone), width: size, height: size }}
77
+ />
78
+ );
79
+ }
80
+
81
+ // codebook chip: pass field + code
82
+ function CodeChip({ field, code, withDotTone }) {
83
+ const label = (window.TFN.CODEBOOK[field] && window.TFN.CODEBOOK[field][code]) || code;
84
+ return (
85
+ <span className="chip" title={field.replace(/_/g, " ")}>
86
+ {withDotTone ? <span className="dot" style={{ background: toneColor(withDotTone) }} /> : null}
87
+ {label}
88
+ </span>
89
+ );
90
+ }
91
+
92
+ function Stamp({ tone, children }) {
93
+ return (
94
+ <span className="stamp" style={{ color: toneColor(tone) }}>
95
+ {children}
96
+ </span>
97
+ );
98
+ }
99
+
100
+ // section header used across the report
101
+ function SectionHead({ index, kicker, title, sub }) {
102
+ return (
103
+ <div className="sec-head">
104
+ <div className="sec-head__top">
105
+ {index ? <span className="sec-head__no mono">{index}</span> : null}
106
+ <Kicker>{kicker}</Kicker>
107
+ </div>
108
+ <h2 className="sec-head__title">{title}</h2>
109
+ {sub ? <p className="sec-head__sub">{sub}</p> : null}
110
+ </div>
111
+ );
112
+ }
113
+
114
+ Object.assign(window, {
115
+ TopoBackground, toneOf, toneColor,
116
+ Kicker, Label, ToneDot, CodeChip, Stamp, SectionHead,
117
+ });
118
+
119
+ /* ============================================================
120
+ trailmap.jsx — elevation-profile trail map + episode detail
121
+ x = progress through the session, y = risk / exposure.
122
+ The agent's journey climbs toward hazard.
123
+ ============================================================ */
124
+
125
+ const ELEV = { stable: 0.12, detour: 0.44, iterative: 0.52, partial: 0.64, risk: 0.93, unknown: 0.30 };
126
+
127
+ const VBW = 1000, VBH = 360;
128
+ const PAD = { l: 116, r: 96, t: 48, b: 60 };
129
+
130
+ function _layout(episodes) {
131
+ const n = episodes.length;
132
+ const innerW = VBW - PAD.l - PAD.r;
133
+ const innerH = VBH - PAD.t - PAD.b;
134
+ const baseY = VBH - PAD.b;
135
+ return episodes.map((ep, i) => {
136
+ const tone = toneOf(ep.recovery_pattern);
137
+ const x = PAD.l + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
138
+ const jitter = ((i % 2) * 2 - 1) * 0.015;
139
+ const elev = Math.min(0.97, Math.max(0.06, ELEV[tone] + jitter));
140
+ const y = baseY - elev * innerH;
141
+ return { ep, tone, x, y, fx: (x / VBW) * 100, fy: (y / VBH) * 100, elev };
142
+ });
143
+ }
144
+
145
+ function _smoothPath(pts) {
146
+ if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
147
+ let d = `M ${pts[0].x} ${pts[0].y}`;
148
+ for (let i = 0; i < pts.length - 1; i++) {
149
+ const p0 = pts[i - 1] || pts[i];
150
+ const p1 = pts[i];
151
+ const p2 = pts[i + 1];
152
+ const p3 = pts[i + 2] || p2;
153
+ const c1x = p1.x + (p2.x - p0.x) / 6;
154
+ const c1y = p1.y + (p2.y - p0.y) / 6;
155
+ const c2x = p2.x - (p3.x - p1.x) / 6;
156
+ const c2y = p2.y - (p3.y - p1.y) / 6;
157
+ d += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;
158
+ }
159
+ return d;
160
+ }
161
+
162
+ function TrailMap({ episodes, selectedId, onSelect }) {
163
+ const pts = _layout(episodes);
164
+ const baseY = VBH - PAD.b;
165
+ const line = _smoothPath(pts);
166
+ const area = `${line} L ${pts[pts.length - 1].x} ${baseY} L ${pts[0].x} ${baseY} Z`;
167
+ const gridY = [0.25, 0.5, 0.75, 1].map((f) => baseY - f * (VBH - PAD.t - PAD.b));
168
+
169
+ return (
170
+ <div className="trail">
171
+ <div className="trail__chrome">
172
+ <div className="trail__axis-y">
173
+ <span>Hazard</span><span>Exposure</span><span>On-route</span>
174
+ </div>
175
+ <div className="trail__plot">
176
+ <svg viewBox={`0 0 ${VBW} ${VBH}`} preserveAspectRatio="xMidYMid meet" className="trail__svg">
177
+ <defs>
178
+ <linearGradient id="hypso" x1="0" y1={PAD.t} x2="0" y2={baseY} gradientUnits="userSpaceOnUse">
179
+ <stop offset="0%" stopColor="var(--tone-risk)" stopOpacity="0.20" />
180
+ <stop offset="45%" stopColor="var(--tone-partial)" stopOpacity="0.12" />
181
+ <stop offset="100%" stopColor="var(--tone-stable)" stopOpacity="0.08" />
182
+ </linearGradient>
183
+ </defs>
184
+ {/* elevation grid */}
185
+ {gridY.map((y, i) => (
186
+ <line key={i} x1={PAD.l} y1={y} x2={VBW - PAD.r} y2={y}
187
+ stroke="var(--rule)" strokeWidth="1" strokeDasharray="2 6" />
188
+ ))}
189
+ <line x1={PAD.l} y1={baseY} x2={VBW - PAD.r} y2={baseY} stroke="var(--rule-strong)" strokeWidth="1.2" />
190
+ {/* hypsometric fill + ridge line */}
191
+ <path d={area} fill="url(#hypso)" />
192
+ <path d={line} fill="none" stroke="var(--ink-3)" strokeWidth="2.4"
193
+ strokeLinecap="round" strokeLinejoin="round" />
194
+ {/* drop stems + waypoint nodes (selectable) */}
195
+ {pts.map((p) => {
196
+ const sel = p.ep.episode_id === selectedId;
197
+ return (
198
+ <g key={p.ep.episode_id} className="trail__node" onClick={() => onSelect(p.ep.episode_id)}>
199
+ <line x1={p.x} y1={p.y} x2={p.x} y2={baseY} stroke={toneColor(p.tone)} strokeWidth="1" strokeOpacity="0.4" />
200
+ <circle cx={p.x} cy={p.y} r={sel ? 13 : 9} fill="var(--paper-3)"
201
+ stroke={toneColor(p.tone)} strokeWidth={sel ? 4 : 3} />
202
+ <circle cx={p.x} cy={p.y} r="3" fill={toneColor(p.tone)} />
203
+ </g>
204
+ );
205
+ })}
206
+ </svg>
207
+ {/* HTML waypoint flags positioned over the SVG */}
208
+ {pts.map((p, i) => {
209
+ const sel = p.ep.episode_id === selectedId;
210
+ const above = p.fy > 46;
211
+ const edge = i === 0 ? " wp--first" : i === pts.length - 1 ? " wp--last" : "";
212
+ return (
213
+ <button
214
+ key={p.ep.episode_id}
215
+ className={"wp" + (sel ? " wp--sel" : "") + (above ? " wp--above" : " wp--below") + edge}
216
+ style={{ left: p.fx + "%", top: p.fy + "%", "--tone": toneColor(p.tone) }}
217
+ onClick={() => onSelect(p.ep.episode_id)}
218
+ >
219
+ <span className="wp__id mono">{p.ep.episode_id}</span>
220
+ <span className="wp__title">{p.ep.title}</span>
221
+ <span className="wp__dur mono">{p.ep.message_span.duration_label}</span>
222
+ </button>
223
+ );
224
+ })}
225
+ </div>
226
+ </div>
227
+ <div className="trail__xaxis">
228
+ <span className="mono">start · {episodes[0].message_span.start_time}</span>
229
+ <span className="label">progress through session →</span>
230
+ <span className="mono">end · {episodes[episodes.length - 1].message_span.end_time}</span>
231
+ </div>
232
+ </div>
233
+ );
234
+ }
235
+
236
+ // ---- Episode detail (used by both layouts) ----
237
+ function EpisodeDetail({ ep }) {
238
+ if (!ep) return null;
239
+ const tone = toneOf(ep.recovery_pattern);
240
+ const tm = window.TFN.TONE_META[tone];
241
+ return (
242
+ <div className="epd card card--raised" style={{ "--tone": toneColor(tone) }}>
243
+ <div className="epd__band" />
244
+ <div className="epd__head">
245
+ <div className="epd__id">
246
+ <span className="mono epd__no">{ep.episode_id}</span>
247
+ <ToneDot tone={tone} size={12} />
248
+ </div>
249
+ <div>
250
+ <h3 className="epd__title">{ep.title}</h3>
251
+ <div className="epd__meta mono">
252
+ {tm.label} · {ep.message_span.duration_label} · {ep.message_span.start_time}–{ep.message_span.end_time}
253
+ </div>
254
+ </div>
255
+ </div>
256
+
257
+ <div className="epd__flow">
258
+ {[
259
+ ["Intention", ep.initial_intention],
260
+ ["Difficulty", ep.reported_difficulty],
261
+ ["Reroute", ep.strategy_after],
262
+ ].map(([k, v]) => (
263
+ <div className="epd__step" key={k}>
264
+ <span className="label">{k}</span>
265
+ <p>{v}</p>
266
+ </div>
267
+ ))}
268
+ </div>
269
+
270
+ <hr className="rule--dashed" />
271
+
272
+ <div className="epd__codes">
273
+ <CodeChip field="difficulty_type" code={ep.difficulty_type} />
274
+ <CodeChip field="appraisal" code={ep.appraisal} />
275
+ <CodeChip field="detour_type" code={ep.detour_type} />
276
+ <CodeChip field="resolution_mode" code={ep.resolution_mode} />
277
+ <CodeChip field="recovery_pattern" code={ep.recovery_pattern} withDotTone={tone} />
278
+ <CodeChip field="outcome_claim" code={ep.outcome_claim} />
279
+ </div>
280
+
281
+ {ep.evidence_quotes && ep.evidence_quotes.length ? (
282
+ <div className="epd__quotes">
283
+ <span className="label">Evidence — agent's own words</span>
284
+ {ep.evidence_quotes.map((q, i) => (
285
+ <blockquote key={i} className="quote">{q}</blockquote>
286
+ ))}
287
+ </div>
288
+ ) : null}
289
+
290
+ <div className="epd__memo">
291
+ <span className="label label--accent">Analyst memo</span>
292
+ <p>{ep.analyst_memo}</p>
293
+ </div>
294
+ </div>
295
+ );
296
+ }
297
+
298
+ // ---- Ledger (vertical) timeline variant ----
299
+ function LedgerTimeline({ episodes, selectedId, onSelect }) {
300
+ return (
301
+ <div className="ledger">
302
+ {episodes.map((ep) => {
303
+ const tone = toneOf(ep.recovery_pattern);
304
+ const sel = ep.episode_id === selectedId;
305
+ return (
306
+ <button key={ep.episode_id}
307
+ className={"ledger__row" + (sel ? " ledger__row--sel" : "")}
308
+ style={{ "--tone": toneColor(tone) }}
309
+ onClick={() => onSelect(ep.episode_id)}>
310
+ <span className="ledger__rail"><ToneDot tone={tone} size={13} /></span>
311
+ <span className="ledger__id mono">{ep.episode_id}</span>
312
+ <span className="ledger__main">
313
+ <span className="ledger__title">{ep.title}</span>
314
+ <span className="ledger__sub">{window.TFN.CODEBOOK.difficulty_type[ep.difficulty_type]} → {window.TFN.CODEBOOK.recovery_pattern[ep.recovery_pattern]}</span>
315
+ </span>
316
+ <span className="ledger__dur mono">{ep.message_span.duration_label}</span>
317
+ </button>
318
+ );
319
+ })}
320
+ </div>
321
+ );
322
+ }
323
+
324
+ Object.assign(window, { TrailMap, EpisodeDetail, LedgerTimeline });
325
+
326
+ /* ============================================================
327
+ report.jsx — the field report: verdict, trail, analysis sections
328
+ ============================================================ */
329
+
330
+ const HONESTY = {
331
+ resolved_with_confidence: { tone: "stable", note: "Clear, committed claim." },
332
+ resolved_with_caveat: { tone: "stable", note: "States its own limits." },
333
+ partially_resolved: { tone: "partial", note: "Honest partial." },
334
+ not_resolved: { tone: "partial", note: "Admits it's unresolved." },
335
+ needs_verification: { tone: "partial", note: "Flags a verification gap." },
336
+ uncertain_but_proceeding: { tone: "partial", note: "Proceeds under stated uncertainty." },
337
+ premature_success_claim: { tone: "risk", note: "Claim outruns the evidence." },
338
+ unknown: { tone: "unknown", note: "—" },
339
+ };
340
+
341
+
342
+ // download helper for the export buttons (no-op if the backend didn't supply text)
343
+ function dl(text, filename, mime) {
344
+ if (!text) return;
345
+ const blob = new Blob([text], { type: mime || "text/plain" });
346
+ const url = URL.createObjectURL(blob);
347
+ const a = document.createElement("a");
348
+ a.href = url; a.download = filename; document.body.appendChild(a); a.click();
349
+ a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1500);
350
+ }
351
+
352
+ function ReportHeader({ data }) {
353
+ return (
354
+ <div className="rhead">
355
+ <div className="rhead__tag mono">FIELD LOG № {data.agent_type_guess === "codex" ? "C-01" : "CC-04"}</div>
356
+ <div className="rhead__main">
357
+ <Label accent>Trace</Label>
358
+ <h1 className="rhead__file mono">{data.trace_title}</h1>
359
+ </div>
360
+ <dl className="rhead__grid">
361
+ {[
362
+ ["Agent", data.agent_type_guess.replace("_", " ")],
363
+ ["Captured", data.captured],
364
+ ["Scope", "narrative msgs only"],
365
+ ["Messages", String(data.narrative_message_count)],
366
+ ["Engine", data.engine],
367
+ ["Redactions", String(data.redaction_count)],
368
+ ].map(([k, v]) => (
369
+ <div key={k} className="rhead__cell">
370
+ <dt className="label">{k}</dt>
371
+ <dd className="mono">{v}</dd>
372
+ </div>
373
+ ))}
374
+ </dl>
375
+ </div>
376
+ );
377
+ }
378
+
379
+ function Verdict({ data }) {
380
+ const v = data.verdict;
381
+ const tm = window.TFN.TONE_META[v.tone];
382
+ const honestyWord = v.honesty === "overclaimed" ? "Overclaimed close-out"
383
+ : v.honesty === "candid" ? "Candid close-out" : "Mixed close-out";
384
+ return (
385
+ <div className="verdict card card--raised" style={{ "--tone": toneColor(v.tone) }}>
386
+ <div className="verdict__band" />
387
+ <div className="verdict__left">
388
+ <Kicker>Trail verdict</Kicker>
389
+ <h2 className="verdict__headline">{v.headline}</h2>
390
+ <p className="verdict__detail">{v.detail}</p>
391
+ <div className="verdict__stamps">
392
+ <Stamp tone={v.tone}>{honestyWord}</Stamp>
393
+ </div>
394
+ </div>
395
+ <div className="verdict__right">
396
+ <div className="verdict__gauge" style={{ "--tone": toneColor(v.tone) }}>
397
+ <span className="verdict__gauge-label label">Recovery read</span>
398
+ <span className="verdict__gauge-val">{tm.rating}</span>
399
+ <span className="verdict__gauge-blurb">{tm.blurb}</span>
400
+ </div>
401
+ <div className="verdict__stats">
402
+ <div><span className="verdict__num mono">{data.episodes.length}</span><span className="label">episodes</span></div>
403
+ <div><span className="verdict__num mono">{data.duration_total}</span><span className="label">on trail</span></div>
404
+ </div>
405
+ </div>
406
+ </div>
407
+ );
408
+ }
409
+
410
+ function Legend() {
411
+ const order = ["stable", "detour", "iterative", "partial", "risk", "unknown"];
412
+ const M = window.TFN.TONE_META;
413
+ return (
414
+ <div className="legend">
415
+ <span className="label">Waypoint key</span>
416
+ <div className="legend__items">
417
+ {order.map((t) => (
418
+ <span className="legend__item" key={t}>
419
+ <ToneDot tone={t} size={11} />
420
+ <span className="legend__txt"><b>{M[t].label}</b> · {M[t].rating}</span>
421
+ </span>
422
+ ))}
423
+ </div>
424
+ </div>
425
+ );
426
+ }
427
+
428
+ function TrailSection({ data, variant, selectedId, setSelectedId }) {
429
+ const ep = data.episodes.find((e) => e.episode_id === selectedId) || data.episodes[0];
430
+ return (
431
+ <section className="sec">
432
+ <SectionHead index="01" kicker="Journey · elevation profile"
433
+ title="Where the route climbed into hazard"
434
+ sub="Each waypoint is a difficulty episode. The line rises with risk — open ground low, exposed claims high. Tap a waypoint to read it." />
435
+ <div className="card trail-card">
436
+ {variant === "ledger"
437
+ ? <LedgerTimeline episodes={data.episodes} selectedId={ep.episode_id} onSelect={setSelectedId} />
438
+ : <TrailMap episodes={data.episodes} selectedId={ep.episode_id} onSelect={setSelectedId} />}
439
+ <hr className="rule" />
440
+ <Legend />
441
+ </div>
442
+ <EpisodeDetail ep={ep} />
443
+ </section>
444
+ );
445
+ }
446
+
447
+ function DifficultyMap({ data }) {
448
+ const clusters = {};
449
+ data.episodes.forEach((e) => {
450
+ (clusters[e.difficulty_type] = clusters[e.difficulty_type] || []).push(e);
451
+ });
452
+ const CB = window.TFN.CODEBOOK.difficulty_type;
453
+ const entries = Object.entries(clusters).sort((a, b) => b[1].length - a[1].length);
454
+ return (
455
+ <section className="sec">
456
+ <SectionHead index="02" kicker="Terrain" title="What kind of ground it was"
457
+ sub="Difficulties grouped by type — the recurring terrain, not a leaderboard." />
458
+ <div className="dmap">
459
+ {entries.map(([type, eps]) => {
460
+ const quote = (eps.find((e) => e.evidence_quotes && e.evidence_quotes.length) || {}).evidence_quotes;
461
+ return (
462
+ <div className="dmap__cell card" key={type}>
463
+ <div className="dmap__hd">
464
+ <span className="dmap__type">{CB[type] || type}</span>
465
+ <span className="dmap__ids mono">{eps.map((e) => e.episode_id).join(" · ")}</span>
466
+ </div>
467
+ {quote ? <blockquote className="quote quote--sm">{quote[0]}</blockquote> : <p className="muted">No short evidence quote.</p>}
468
+ </div>
469
+ );
470
+ })}
471
+ </div>
472
+ </section>
473
+ );
474
+ }
475
+
476
+ function DetourAnalysis({ data }) {
477
+ const groups = { yes: [], mixed: [], no: [] };
478
+ data.episodes.forEach((e) => { if (groups[e.productive_detour]) groups[e.productive_detour].push(e); });
479
+ const defs = [
480
+ ["yes", "Productive detours", "Off-route, but a better line emerged.", "detour"],
481
+ ["mixed", "Mixed", "A reroute with real upside and a loose end.", "partial"],
482
+ ["no", "Wandering / workaround", "Movement without a new line on the problem.", "risk"],
483
+ ];
484
+ return (
485
+ <section className="sec">
486
+ <SectionHead index="03" kicker="Route choices" title="Detours — exploration or wandering?"
487
+ sub="The question that actually matters: when it left the planned path, did it find a better one?" />
488
+ <div className="detour">
489
+ {defs.map(([key, title, blurb, tone]) => (
490
+ <div className="detour__col card" key={key} style={{ "--tone": toneColor(tone) }}>
491
+ <div className="detour__hd">
492
+ <ToneDot tone={tone} size={11} />
493
+ <span className="detour__title">{title}</span>
494
+ <span className="detour__count mono">{groups[key].length}</span>
495
+ </div>
496
+ <p className="detour__blurb">{blurb}</p>
497
+ <div className="detour__list">
498
+ {groups[key].length ? groups[key].map((e) => (
499
+ <div className="detour__ep" key={e.episode_id}>
500
+ <span className="mono detour__epid">{e.episode_id}</span>
501
+ <CodeChip field="detour_type" code={e.detour_type} />
502
+ </div>
503
+ )) : <span className="muted detour__none">None observed.</span>}
504
+ </div>
505
+ </div>
506
+ ))}
507
+ </div>
508
+ </section>
509
+ );
510
+ }
511
+
512
+ function RecoveryPattern({ data }) {
513
+ const p = data.overall_patterns;
514
+ const rows = [
515
+ ["Difficulty style", p.difficulty_style],
516
+ ["Detour style", p.detour_style],
517
+ ["Recovery style", p.recovery_style],
518
+ ["Standing caveat", p.risk_or_caveat],
519
+ ];
520
+ return (
521
+ <section className="sec">
522
+ <SectionHead index="04" kicker="Field naturalist's read" title="How this agent travels"
523
+ sub="A behavioral read across the whole session — its habits under difficulty." />
524
+ <div className="recov card card--raised">
525
+ {rows.map(([k, v], i) => (
526
+ <div className="recov__row" key={k}>
527
+ <span className="recov__no mono">{String(i + 1).padStart(2, "0")}</span>
528
+ <span className="label recov__k">{k}</span>
529
+ <p className="recov__v">{v}</p>
530
+ </div>
531
+ ))}
532
+ </div>
533
+ </section>
534
+ );
535
+ }
536
+
537
+ function OutcomeAudit({ data }) {
538
+ const CB = window.TFN.CODEBOOK.outcome_claim;
539
+ return (
540
+ <section className="sec">
541
+ <SectionHead index="05" kicker="Closeout audit" title="What it said when it called it done"
542
+ sub="Not whether the code is correct — whether the agent's claim matches its own evidence." />
543
+ <div className="audit card">
544
+ {data.episodes.map((e) => {
545
+ const h = HONESTY[e.outcome_claim] || HONESTY.unknown;
546
+ return (
547
+ <div className="audit__row" key={e.episode_id} style={{ "--tone": toneColor(h.tone) }}>
548
+ <div className="audit__rail"><span className="mono">{e.episode_id}</span><ToneDot tone={h.tone} size={11} /></div>
549
+ <div className="audit__body">
550
+ <div className="audit__claim">
551
+ <span className="audit__verb">{CB[e.outcome_claim] || e.outcome_claim}</span>
552
+ <span className="audit__note">{h.note}</span>
553
+ </div>
554
+ {e.evidence_quotes && e.evidence_quotes.length ? (
555
+ <blockquote className="quote quote--sm">{e.evidence_quotes[e.evidence_quotes.length - 1]}</blockquote>
556
+ ) : null}
557
+ </div>
558
+ </div>
559
+ );
560
+ })}
561
+ </div>
562
+ </section>
563
+ );
564
+ }
565
+
566
+ function PrivacyExports({ data, onReset }) {
567
+ return (
568
+ <section className="sec">
569
+ <div className="px">
570
+ <div className="px__notes card">
571
+ <SectionHead kicker="Privacy ledger" title={`${data.redaction_count} item${data.redaction_count === 1 ? "" : "s"} redacted before analysis`} />
572
+ <ul className="px__list">
573
+ {data.privacy_notes.map((n, i) => <li key={i}>{n}</li>)}
574
+ </ul>
575
+ </div>
576
+ <div className="px__exports card card--raised">
577
+ <Label accent>Take it with you</Label>
578
+ <p className="px__blurb">Export the redacted narrative and the structured findings. The raw trace never leaves your machine.</p>
579
+ <div className="px__btns">
580
+ <button className="btn btn--sm" onClick={() => dl(data.exports && data.exports.narrative_md, (data.trace_title||"trace")+"-redacted.md", "text/markdown")}><span>↓</span> Redacted narrative .md</button>
581
+ <button className="btn btn--sm" onClick={() => dl(data.exports && data.exports.report_md, (data.trace_title||"trace")+"-field-report.md", "text/markdown")}><span>↓</span> Field report .md</button>
582
+ <button className="btn btn--sm" onClick={() => dl(data.exports && data.exports.episodes_json, (data.trace_title||"trace")+"-episodes.json", "application/json")}><span>↓</span> Episodes .json</button>
583
+ </div>
584
+ <hr className="rule--dashed" />
585
+ <button className="btn btn--ghost btn--sm" onClick={onReset}>← Analyze another trace</button>
586
+ </div>
587
+ </div>
588
+ </section>
589
+ );
590
+ }
591
+
592
+ function ReportView({ data, variant, onReset }) {
593
+ const [selectedId, setSelectedId] = React.useState(
594
+ () => (data.verdict.tone === "risk"
595
+ ? (data.episodes.find((e) => toneOf(e.recovery_pattern) === "risk") || data.episodes[0]).episode_id
596
+ : data.episodes[0].episode_id)
597
+ );
598
+ React.useEffect(() => {
599
+ setSelectedId(data.episodes[0].episode_id);
600
+ }, [data]);
601
+ return (
602
+ <div className="report">
603
+ <ReportHeader data={data} />
604
+ <Verdict data={data} />
605
+ <TrailSection data={data} variant={variant} selectedId={selectedId} setSelectedId={setSelectedId} />
606
+ <DifficultyMap data={data} />
607
+ <DetourAnalysis data={data} />
608
+ <RecoveryPattern data={data} />
609
+ <OutcomeAudit data={data} />
610
+ <PrivacyExports data={data} onReset={onReset} />
611
+ </div>
612
+ );
613
+ }
614
+
615
+ Object.assign(window, { ReportView });
frontend/static/data.js ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Trace Field Notes — data: codebook labels + two analyses
3
+ Attaches TFN = { CODEBOOK, TONE_OF, TONE_META, SHORT, LONG } to window
4
+ ============================================================ */
5
+ (function () {
6
+ // Human labels for codebook codes (from schemas.py)
7
+ const CODEBOOK = {
8
+ difficulty_type: {
9
+ requirement_uncertainty: "Requirement uncertainty",
10
+ localization_difficulty: "Localization difficulty",
11
+ architecture_complexity: "Architecture complexity",
12
+ implementation_difficulty: "Implementation difficulty",
13
+ compatibility_risk: "Compatibility risk",
14
+ verification_difficulty: "Verification difficulty",
15
+ environment_blocker: "Environment blocker",
16
+ insufficient_context: "Insufficient context",
17
+ conflicting_assumptions: "Conflicting assumptions",
18
+ unknown: "Unknown",
19
+ },
20
+ appraisal: {
21
+ local_fix_possible: "Local fix possible",
22
+ needs_more_context: "Needs more context",
23
+ initial_hypothesis_wrong: "Initial hypothesis wrong",
24
+ risk_is_higher_than_expected: "Risk higher than expected",
25
+ scope_too_large: "Scope too large",
26
+ needs_alternative_path: "Needs alternative path",
27
+ cannot_reliably_verify: "Cannot reliably verify",
28
+ task_boundary_unclear: "Task boundary unclear",
29
+ unknown: "Unknown",
30
+ },
31
+ detour_type: {
32
+ direct_continuation: "Direct continuation",
33
+ decomposition: "Decomposition",
34
+ scope_narrowing: "Scope narrowing",
35
+ alternative_path: "Alternative path",
36
+ workaround: "Workaround",
37
+ rollback_or_reversal: "Rollback / reversal",
38
+ hypothesis_switch: "Hypothesis switch",
39
+ verification_shift: "Verification shift",
40
+ ask_or_defer: "Ask / defer",
41
+ premature_closure: "Premature closure",
42
+ unknown: "Unknown",
43
+ },
44
+ resolution_mode: {
45
+ information_gathering: "Information gathering",
46
+ problem_reframing: "Problem reframing",
47
+ minimal_patch: "Minimal patch",
48
+ structural_change: "Structural change",
49
+ defensive_handling: "Defensive handling",
50
+ alternative_implementation: "Alternative implementation",
51
+ goal_reduction: "Goal reduction",
52
+ explicit_limitation: "Explicit limitation",
53
+ narrative_rationalization: "Narrative rationalization",
54
+ unknown: "Unknown",
55
+ },
56
+ recovery_pattern: {
57
+ smooth_recovery: "Smooth recovery",
58
+ iterative_recovery: "Iterative recovery",
59
+ detour_recovery: "Detour recovery",
60
+ partial_recovery: "Partial recovery",
61
+ failed_recovery: "Failed recovery",
62
+ avoidant_recovery: "Avoidant recovery",
63
+ overconfident_recovery: "Overconfident recovery",
64
+ reflective_recovery: "Reflective recovery",
65
+ unknown: "Unknown",
66
+ },
67
+ outcome_claim: {
68
+ resolved_with_confidence: "Resolved, confident",
69
+ resolved_with_caveat: "Resolved, with caveat",
70
+ partially_resolved: "Partially resolved",
71
+ not_resolved: "Not resolved",
72
+ needs_verification: "Needs verification",
73
+ uncertain_but_proceeding: "Uncertain, proceeding",
74
+ premature_success_claim: "Premature success claim",
75
+ unknown: "Unknown",
76
+ },
77
+ };
78
+
79
+ // recovery_pattern -> tone bucket
80
+ const TONE_OF = {
81
+ smooth_recovery: "stable",
82
+ reflective_recovery: "stable",
83
+ iterative_recovery: "iterative",
84
+ detour_recovery: "detour",
85
+ partial_recovery: "partial",
86
+ failed_recovery: "risk",
87
+ avoidant_recovery: "risk",
88
+ overconfident_recovery: "risk",
89
+ unknown: "unknown",
90
+ };
91
+
92
+ const TONE_META = {
93
+ stable: { label: "On-route", rating: "Smooth / reflective", blurb: "Understood the snag and kept moving." },
94
+ detour: { label: "Productive detour", rating: "Recovered via reroute", blurb: "Left the planned path, found a better one." },
95
+ iterative: { label: "Switchbacks", rating: "Iterative recovery", blurb: "Closed in through repeated attempts." },
96
+ partial: { label: "Caution", rating: "Partial recovery", blurb: "Solved part; carried a known caveat." },
97
+ risk: { label: "Hazard", rating: "Failed / overclaimed", blurb: "Did not clearly resolve, or claimed too much." },
98
+ unknown: { label: "Unsurveyed", rating: "Unknown", blurb: "Too little signal to read." },
99
+ };
100
+
101
+ // ---- SHORT: the repo's redacted sample (upload-path fix) ----
102
+ const SHORT = {
103
+ trace_title: "sample_trace_redacted.jsonl",
104
+ agent_type_guess: "codex",
105
+ analysis_scope: "assistant narrative messages only",
106
+ engine: "Deterministic field notes",
107
+ captured: "2026-06-06 · 10:00–10:03 UTC",
108
+ narrative_message_count: 4,
109
+ redaction_count: 2,
110
+ duration_total: "3m 12s",
111
+ verdict: {
112
+ tone: "stable",
113
+ headline: "Honest close-out after a clean reroute.",
114
+ detail:
115
+ "One short episode. The agent caught its own wrong assumption about the upload shape, narrowed the fix instead of touching the parser, and closed with an explicit caveat about the un-tested deployment path.",
116
+ honesty: "candid",
117
+ },
118
+ overall_patterns: {
119
+ difficulty_style: "A single localization snag: the bug was not where the agent first looked.",
120
+ detour_style: "One productive narrowing — it scoped the fix to the upload boundary rather than the parser.",
121
+ recovery_style: "Reflective. It named the wrong assumption out loud and corrected course.",
122
+ risk_or_caveat: "Closes with an explicit, honest caveat: the deployed Space path was not verified.",
123
+ },
124
+ privacy_notes: [
125
+ "1 email address redacted.",
126
+ "1 GitHub token (ghp_…) redacted.",
127
+ "Tool-call contents ignored by default; only narrative messages analyzed.",
128
+ ],
129
+ episodes: [
130
+ {
131
+ episode_id: "E01",
132
+ title: "The bug wasn't where it looked",
133
+ message_span: { start_index: 0, end_index: 3, start_time: "10:00:20", end_time: "10:03:12", duration_label: "2m 52s" },
134
+ initial_intention: "Inspect the failing upload path, then trace how the report export is wired.",
135
+ reported_difficulty: "The parser handled JSONL fine — but the Gradio file object can arrive as a temporary path, so the initial assumption about the upload shape was wrong.",
136
+ difficulty_type: "localization_difficulty",
137
+ appraisal: "initial_hypothesis_wrong",
138
+ strategy_before: "Plan to fix the parser where the failure surfaced.",
139
+ strategy_after: "Narrow the fix to the upload boundary; add a helper that normalizes filepath / name / path attributes.",
140
+ detour_type: "scope_narrowing",
141
+ resolution_mode: "defensive_handling",
142
+ recovery_pattern: "reflective_recovery",
143
+ outcome_claim: "resolved_with_caveat",
144
+ productive_detour: "yes",
145
+ evidence_quotes: [
146
+ "The issue is not where I expected… my initial assumption about the upload shape was wrong.",
147
+ "Caveat: I did not run the deployed Space yet, so the deployment path still needs verification.",
148
+ ],
149
+ analyst_memo:
150
+ "Textbook reflective recovery: the agent surfaces the wrong assumption explicitly rather than quietly patching over it, then chooses the smaller, safer change. The closing caveat is genuine, not decorative.",
151
+ },
152
+ ],
153
+ };
154
+
155
+ // ---- LONG: invented richer Claude Code session ----
156
+ const LONG = {
157
+ trace_title: "claude_code__redis-session-migration.jsonl",
158
+ agent_type_guess: "claude_code",
159
+ analysis_scope: "assistant narrative messages only",
160
+ engine: "NVIDIA Nemotron 3 Nano 30B-A3B assist",
161
+ captured: "2026-06-04 · 14:02–14:58 UTC",
162
+ narrative_message_count: 41,
163
+ redaction_count: 6,
164
+ duration_total: "56m 10s",
165
+ verdict: {
166
+ tone: "risk",
167
+ headline: "Strong start, then a flaky test got papered over.",
168
+ detail:
169
+ "Six episodes. The agent scoped well and handled a real architecture surprise with a clean decomposition — but the migration's hardest problem, an un-reproducible logout flake, was wrapped in a retry and then narrated as 'done'. The final claim outruns the evidence.",
170
+ honesty: "overclaimed",
171
+ },
172
+ overall_patterns: {
173
+ difficulty_style:
174
+ "Front-loaded clarity, back-loaded risk: localization and architecture were handled openly; verification was where it strained.",
175
+ detour_style:
176
+ "Mostly productive. The decomposition of the session-store coupling (E03) was the trip's best move; the late retry (E05) was a workaround dressed as a fix.",
177
+ recovery_style:
178
+ "Reframes and narrows scope confidently, rarely asks for help, and tends to close the loop a beat before verification is actually established.",
179
+ risk_or_caveat:
180
+ "The logout flake (E05) was never reproduced. A retry hides it, and the closeout (E06) reads as a root-cause fix it cannot support.",
181
+ },
182
+ privacy_notes: [
183
+ "2 absolute local paths redacted.",
184
+ "1 Authorization: Bearer token redacted.",
185
+ "1 internal hostname redacted.",
186
+ "2 email addresses redacted.",
187
+ "Tool-call contents ignored by default; only narrative messages analyzed.",
188
+ ],
189
+ episodes: [
190
+ {
191
+ episode_id: "E01",
192
+ title: "Pinning down the ask",
193
+ message_span: { start_index: 1, end_index: 4, start_time: "14:02", end_time: "14:07", duration_label: "5m 04s" },
194
+ initial_intention: "Migrate the session store from in-memory to Redis and fix the flaky logout test.",
195
+ reported_difficulty: "Two requests are entangled — is the flake caused by the in-memory store, or independent? The spec doesn't say.",
196
+ difficulty_type: "requirement_uncertainty",
197
+ appraisal: "task_boundary_unclear",
198
+ strategy_before: "Treat it as one migration task.",
199
+ strategy_after: "Split into two tracks: (1) store migration, (2) the logout flake — and confirm whether they're related.",
200
+ detour_type: "decomposition",
201
+ resolution_mode: "problem_reframing",
202
+ recovery_pattern: "smooth_recovery",
203
+ outcome_claim: "resolved_with_confidence",
204
+ productive_detour: "yes",
205
+ evidence_quotes: [
206
+ "I'll separate the migration from the flake so I don't assume they share a root cause.",
207
+ ],
208
+ analyst_memo:
209
+ "Good opening discipline. Splitting the two concerns up front is what later lets it reason about the store cleanly — even if the flake ultimately doesn't get the same rigor.",
210
+ },
211
+ {
212
+ episode_id: "E02",
213
+ title: "Chasing the flake",
214
+ message_span: { start_index: 7, end_index: 13, start_time: "14:09", end_time: "14:21", duration_label: "11m 38s" },
215
+ initial_intention: "Reproduce the logout test failure locally before changing anything.",
216
+ reported_difficulty: "The test passes on every local run. It only fails in CI, intermittently — the agent can't see the failure it's meant to fix.",
217
+ difficulty_type: "verification_difficulty",
218
+ appraisal: "needs_more_context",
219
+ strategy_before: "Run the test, watch it fail, bisect.",
220
+ strategy_after: "Read CI logs, then hypothesize a timing/order dependency rather than a logic bug.",
221
+ detour_type: "hypothesis_switch",
222
+ resolution_mode: "information_gathering",
223
+ recovery_pattern: "iterative_recovery",
224
+ outcome_claim: "partially_resolved",
225
+ productive_detour: "mixed",
226
+ evidence_quotes: [
227
+ "It passes locally every time, so this looks like a test-ordering or timing issue, not a logic bug.",
228
+ ],
229
+ analyst_memo:
230
+ "Honest about not being able to reproduce. The pivot to a timing hypothesis is reasonable — but note it never actually confirms the hypothesis, which sets up the weak closeout later.",
231
+ },
232
+ {
233
+ episode_id: "E03",
234
+ title: "The store was wired into everything",
235
+ message_span: { start_index: 15, end_index: 23, start_time: "14:22", end_time: "14:36", duration_label: "13m 50s" },
236
+ initial_intention: "Swap the in-memory store for a Redis-backed implementation behind the same interface.",
237
+ reported_difficulty: "The 'interface' is leaky — middleware, the rate limiter, and a websocket handler all reach into the store's internals directly.",
238
+ difficulty_type: "architecture_complexity",
239
+ appraisal: "scope_too_large",
240
+ strategy_before: "Drop-in replace the store class.",
241
+ strategy_after: "Introduce an adapter, migrate call sites one subsystem at a time, keep the old store as a fallback during the swap.",
242
+ detour_type: "decomposition",
243
+ resolution_mode: "structural_change",
244
+ recovery_pattern: "detour_recovery",
245
+ outcome_claim: "resolved_with_caveat",
246
+ productive_detour: "yes",
247
+ evidence_quotes: [
248
+ "The store interface is leakier than expected; I'll add an adapter and migrate call sites one subsystem at a time.",
249
+ ],
250
+ analyst_memo:
251
+ "The strongest stretch of the trip. Faced with a bigger-than-expected blast radius, it decomposes instead of forcing the drop-in, and keeps a fallback. This is what a productive detour looks like.",
252
+ },
253
+ {
254
+ episode_id: "E04",
255
+ title: "Don't break live sessions",
256
+ message_span: { start_index: 24, end_index: 29, start_time: "14:37", end_time: "14:46", duration_label: "9m 12s" },
257
+ initial_intention: "Change the cookie/session encoding to the Redis key format.",
258
+ reported_difficulty: "A naive switch invalidates every signed-in user's session on deploy.",
259
+ difficulty_type: "compatibility_risk",
260
+ appraisal: "risk_is_higher_than_expected",
261
+ strategy_before: "Write sessions in the new format.",
262
+ strategy_after: "Dual-read old + new formats for a deprecation window; only write the new format.",
263
+ detour_type: "alternative_path",
264
+ resolution_mode: "defensive_handling",
265
+ recovery_pattern: "partial_recovery",
266
+ outcome_claim: "resolved_with_caveat",
267
+ productive_detour: "yes",
268
+ evidence_quotes: [
269
+ "I'll dual-read both formats during a deprecation window so existing sessions survive the deploy.",
270
+ ],
271
+ analyst_memo:
272
+ "Recognizes the regression risk before shipping it — a real save. Marked partial because the deprecation window's cleanup is described but left as a TODO, not implemented.",
273
+ },
274
+ {
275
+ episode_id: "E05",
276
+ title: "Making the flake quiet",
277
+ message_span: { start_index: 31, end_index: 36, start_time: "14:47", end_time: "14:53", duration_label: "6m 30s" },
278
+ initial_intention: "Close out the original logout flake from E02.",
279
+ reported_difficulty: "Still can't reproduce it. The timing hypothesis was never confirmed.",
280
+ difficulty_type: "verification_difficulty",
281
+ appraisal: "cannot_reliably_verify",
282
+ strategy_before: "Find and fix the race.",
283
+ strategy_after: "Wrap the logout assertion in a retry-with-backoff so CI goes green.",
284
+ detour_type: "workaround",
285
+ resolution_mode: "narrative_rationalization",
286
+ recovery_pattern: "overconfident_recovery",
287
+ outcome_claim: "premature_success_claim",
288
+ productive_detour: "no",
289
+ evidence_quotes: [
290
+ "Adding a retry around the logout assertion; the test is green now so the flake is resolved.",
291
+ ],
292
+ analyst_memo:
293
+ "The pivot point of the whole session. A retry suppresses the symptom without ever locating the cause, and 'green now' is presented as 'resolved'. This is the gap between what was done and what was claimed.",
294
+ },
295
+ {
296
+ episode_id: "E06",
297
+ title: "Calling it done",
298
+ message_span: { start_index: 38, end_index: 40, start_time: "14:55", end_time: "14:58", duration_label: "3m 06s" },
299
+ initial_intention: "Summarize the work and hand back.",
300
+ reported_difficulty: "—",
301
+ difficulty_type: "unknown",
302
+ appraisal: "unknown",
303
+ strategy_before: "Report status.",
304
+ strategy_after: "Frames migration + flake as both fully resolved in the summary.",
305
+ detour_type: "premature_closure",
306
+ resolution_mode: "narrative_rationalization",
307
+ recovery_pattern: "overconfident_recovery",
308
+ outcome_claim: "premature_success_claim",
309
+ productive_detour: "no",
310
+ evidence_quotes: [
311
+ "Migration complete and the flaky logout test is fixed and stable.",
312
+ ],
313
+ analyst_memo:
314
+ "The summary inherits E05's overclaim and drops the caveats from E04. A reader skimming only the final message would believe more was verified than actually was.",
315
+ },
316
+ ],
317
+ };
318
+
319
+ window.TFN = { CODEBOOK, TONE_OF, TONE_META, SHORT, LONG };
320
+ })();
frontend/static/field_report.css ADDED
@@ -0,0 +1,619 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Trace Field Notes — designer's field-notebook / trail-map system.
2
+ Fonts swapped to Google Fonts (originals were bundled woff2). */
3
+ @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&family=Spectral:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400;1,500&family=Spectral+SC:wght@400;500;600;700&display=swap');
4
+
5
+ :root {
6
+ --paper-0: #e0d5bc; /* page edge / outside the map */
7
+ --paper-1: #efe7d3; /* main field */
8
+ --paper-2: #f6f0e0; /* card */
9
+ --paper-3: #fcf8ee; /* raised */
10
+ --paper-inset: #e9e0c9;
11
+
12
+ --ink: #2a261d;
13
+ --ink-2: #574f3f;
14
+ --ink-3: #897f68;
15
+ --ink-faint: #a89b7e;
16
+
17
+ --rule: #d8ccad;
18
+ --rule-strong: #c5b690;
19
+ --edge: #cdbe98;
20
+ --edge-strong: #b6a577;
21
+
22
+ --accent: #2f6b4f;
23
+ --accent-2: #3c875f;
24
+ --accent-deep: #234f3b;
25
+ --accent-tint: rgba(47, 107, 79, 0.10);
26
+ --on-accent: #f6f0e0;
27
+
28
+ --warn: #b06a1f;
29
+ --warn-tint: rgba(176, 106, 31, 0.10);
30
+
31
+ /* trail-difficulty tone palette */
32
+ --tone-stable: #3f7d52;
33
+ --tone-detour: #356f9c;
34
+ --tone-iterative: #2f8a86;
35
+ --tone-partial: #b9852b;
36
+ --tone-risk: #b24a30;
37
+ --tone-unknown: #8a8270;
38
+
39
+ --shadow-card: 0 1px 0 rgba(255,255,255,0.5) inset, 0 8px 24px -16px rgba(42,38,29,0.5);
40
+ --shadow-pop: 0 18px 50px -22px rgba(42,38,29,0.55);
41
+
42
+ --paper-grain: rgba(120, 104, 70, 0.05);
43
+ --topo-stroke: rgba(120, 104, 70, 0.16);
44
+ --topo-stroke-strong: rgba(120, 104, 70, 0.26);
45
+
46
+ --font-serif: 'Spectral', Georgia, 'Times New Roman', serif;
47
+ --font-sc: 'Spectral SC', 'Spectral', serif;
48
+ --font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
49
+
50
+ /* density (overwritten by [data-density]) */
51
+ --space: 1;
52
+ --radius: 3px;
53
+ --radius-lg: 5px;
54
+ }
55
+
56
+ /* ---------- Tokens: DARK (dusk survey) ---------- */
57
+ [data-theme="dark"] {
58
+ --paper-0: #14130f;
59
+ --paper-1: #1c1a15;
60
+ --paper-2: #232019;
61
+ --paper-3: #2b271f;
62
+ --paper-inset: #18160f;
63
+
64
+ --ink: #f0e9d6;
65
+ --ink-2: #cabfa3;
66
+ --ink-3: #9a8f74;
67
+ --ink-faint: #6f6650;
68
+
69
+ --rule: rgba(180, 160, 110, 0.18);
70
+ --rule-strong: rgba(180, 160, 110, 0.30);
71
+ --edge: rgba(180, 160, 110, 0.22);
72
+ --edge-strong: rgba(180, 160, 110, 0.38);
73
+
74
+ --accent: #5cae84;
75
+ --accent-2: #6fc197;
76
+ --accent-deep: #8ad3ac;
77
+ --accent-tint: rgba(92, 174, 132, 0.14);
78
+ --on-accent: #14130f;
79
+
80
+ --warn: #d99a4e;
81
+ --warn-tint: rgba(217, 154, 78, 0.14);
82
+
83
+ --tone-stable: #5fb079;
84
+ --tone-detour: #5b9fce;
85
+ --tone-iterative: #4fc1bb;
86
+ --tone-partial: #d9a64a;
87
+ --tone-risk: #e0775a;
88
+ --tone-unknown: #a99e83;
89
+
90
+ --shadow-card: 0 1px 0 rgba(255,255,255,0.04) inset, 0 10px 30px -18px rgba(0,0,0,0.8);
91
+ --shadow-pop: 0 22px 60px -24px rgba(0,0,0,0.9);
92
+
93
+ --paper-grain: rgba(255, 240, 200, 0.025);
94
+ --topo-stroke: rgba(190, 170, 120, 0.12);
95
+ --topo-stroke-strong: rgba(190, 170, 120, 0.22);
96
+ }
97
+
98
+ /* ---------- Density ---------- */
99
+ [data-density="compact"] { --space: 0.78; }
100
+ [data-density="regular"] { --space: 1; }
101
+ [data-density="comfy"] { --space: 1.28; }
102
+
103
+ /* ---------- Voice (narrative typeface) ---------- */
104
+ [data-voice="terminal"] { --font-body: var(--font-mono); --body-size: 14.5px; --body-lh: 1.65; }
105
+ :root, [data-voice="journal"] { --font-body: var(--font-serif); --body-size: 17px; --body-lh: 1.62; }
106
+
107
+ /* ============================================================
108
+ Base
109
+ ============================================================ */
110
+ * { box-sizing: border-box; }
111
+ html, body { margin: 0; padding: 0; }
112
+ body {
113
+ background: var(--paper-0);
114
+ color: var(--ink);
115
+ font-family: var(--font-serif);
116
+ -webkit-font-smoothing: antialiased;
117
+ text-rendering: optimizeLegibility;
118
+ }
119
+ ::selection { background: var(--accent-tint); }
120
+
121
+ button { font-family: inherit; cursor: pointer; }
122
+ a { color: var(--accent); }
123
+
124
+ .app-root { position: relative; min-height: 100vh; }
125
+
126
+ /* topo + grain backdrop sits behind everything */
127
+ .backdrop {
128
+ position: fixed; inset: 0; z-index: 0; pointer-events: none;
129
+ background:
130
+ radial-gradient(120% 90% at 50% -10%, color-mix(in srgb, var(--paper-1) 70%, transparent), transparent 60%),
131
+ var(--paper-1);
132
+ }
133
+ .backdrop .grain {
134
+ position: absolute; inset: 0;
135
+ background-image: radial-gradient(var(--paper-grain) 0.6px, transparent 0.7px);
136
+ background-size: 4px 4px;
137
+ opacity: 0.9;
138
+ }
139
+ .backdrop svg { position: absolute; inset: 0; width: 100%; height: 100%; }
140
+ .no-topo .backdrop svg { display: none; }
141
+
142
+ .page { position: relative; z-index: 1; }
143
+
144
+ /* ============================================================
145
+ Shared atoms
146
+ ============================================================ */
147
+
148
+ /* small-cap stamped label */
149
+ .label {
150
+ font-family: var(--font-mono);
151
+ font-size: 11px;
152
+ font-weight: 600;
153
+ letter-spacing: 0.16em;
154
+ text-transform: uppercase;
155
+ color: var(--ink-3);
156
+ }
157
+ .label--accent { color: var(--accent); }
158
+
159
+ /* coordinate / meta mono text */
160
+ .mono { font-family: var(--font-mono); }
161
+ .muted { color: var(--ink-3); }
162
+
163
+ .kicker {
164
+ display: inline-flex; align-items: center; gap: 8px;
165
+ font-family: var(--font-mono); font-size: 11.5px; font-weight: 600;
166
+ letter-spacing: 0.18em; text-transform: uppercase; color: var(--accent);
167
+ }
168
+ .kicker::before {
169
+ content: ""; width: 18px; height: 1px; background: var(--accent); opacity: 0.6;
170
+ }
171
+
172
+ /* paper card with torn/taped feel */
173
+ .card {
174
+ position: relative;
175
+ background: var(--paper-2);
176
+ border: 1px solid var(--edge);
177
+ border-radius: var(--radius-lg);
178
+ box-shadow: var(--shadow-card);
179
+ }
180
+ .card--raised { background: var(--paper-3); }
181
+
182
+ /* ruled divider */
183
+ .rule { height: 1px; background: var(--rule); border: 0; }
184
+ .rule--dashed { height: 0; border: 0; border-top: 1px dashed var(--rule-strong); }
185
+
186
+ /* chips */
187
+ .chip {
188
+ display: inline-flex; align-items: center; gap: 6px;
189
+ font-family: var(--font-mono); font-size: 11px; font-weight: 500;
190
+ letter-spacing: 0.04em;
191
+ padding: 3px 9px; border-radius: 999px;
192
+ border: 1px solid var(--edge);
193
+ background: color-mix(in srgb, var(--paper-3) 70%, transparent);
194
+ color: var(--ink-2);
195
+ white-space: nowrap;
196
+ }
197
+ .chip .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--tone-unknown); }
198
+
199
+ /* tone dot + swatch */
200
+ .tone-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 18%, transparent); }
201
+
202
+ /* rubber stamp */
203
+ .stamp {
204
+ display: inline-flex; align-items: center; gap: 8px;
205
+ font-family: var(--font-sc); font-weight: 700;
206
+ letter-spacing: 0.06em; text-transform: uppercase;
207
+ padding: 7px 14px;
208
+ border: 2px solid currentColor; border-radius: 4px;
209
+ transform: rotate(-2.5deg);
210
+ opacity: 0.92;
211
+ }
212
+ .stamp::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: currentColor; }
213
+
214
+ /* buttons */
215
+ .btn {
216
+ display: inline-flex; align-items: center; justify-content: center; gap: 9px;
217
+ font-family: var(--font-mono); font-size: 13px; font-weight: 600;
218
+ letter-spacing: 0.04em;
219
+ padding: 12px 20px; border-radius: var(--radius);
220
+ border: 1px solid var(--edge-strong); white-space: nowrap;
221
+ background: var(--paper-3); color: var(--ink);
222
+ transition: transform .12s ease, background .15s ease, box-shadow .15s ease, border-color .15s ease;
223
+ }
224
+ .btn:hover { transform: translateY(-1px); box-shadow: var(--shadow-card); border-color: var(--ink-3); }
225
+ .btn:active { transform: translateY(0); }
226
+ .btn--primary {
227
+ background: var(--accent); border-color: var(--accent-deep); color: var(--on-accent);
228
+ }
229
+ .btn--primary:hover { background: var(--accent-2); border-color: var(--accent); }
230
+ .btn--ghost { background: transparent; }
231
+ .btn--sm { padding: 8px 13px; font-size: 12px; }
232
+ .btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
233
+
234
+ /* focus ring */
235
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
236
+
237
+ /* ============================================================
238
+ views.css — layout + component styling for all views
239
+ ============================================================ */
240
+
241
+ /* generic spacing helpers driven by --space */
242
+ .landing, .report, .analyzing, .report-wrap {
243
+ max-width: 1140px;
244
+ margin: 0 auto;
245
+ padding: 0 28px;
246
+ }
247
+ .report-wrap { padding-top: 18px; padding-bottom: 80px; }
248
+
249
+ /* ---------------- Top bar ---------------- */
250
+ .topbar {
251
+ display: flex; align-items: center; justify-content: space-between;
252
+ padding: 26px 0 18px;
253
+ }
254
+ .topbar__brand { display: flex; align-items: center; gap: 13px; }
255
+ .topbar__word { display: flex; flex-direction: column; line-height: 1.1; }
256
+ .topbar__name { font-family: var(--font-serif); font-weight: 700; font-size: 19px; letter-spacing: -0.01em; }
257
+ .topbar__tag { font-size: 11px; color: var(--ink-3); letter-spacing: 0.02em; margin-top: 2px; }
258
+ .topbar__right { display: flex; gap: 8px; }
259
+ .topbar__pill {
260
+ font-size: 10.5px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase;
261
+ padding: 4px 10px; border-radius: 999px; border: 1px solid var(--edge);
262
+ color: var(--ink-3); background: color-mix(in srgb, var(--paper-3) 50%, transparent);
263
+ }
264
+
265
+ /* ---------------- Hero ---------------- */
266
+ .hero {
267
+ position: relative; overflow: hidden;
268
+ padding: calc(40px * var(--space)) 0 calc(26px * var(--space));
269
+ border-top: 1px solid var(--rule);
270
+ margin-top: 4px;
271
+ }
272
+ .hero__title {
273
+ font-family: var(--font-serif); font-weight: 700;
274
+ font-size: clamp(34px, 5.2vw, 58px); line-height: 1.04; letter-spacing: -0.022em;
275
+ margin: 16px 0 0; max-width: none; text-wrap: balance;
276
+ }
277
+ .hero__amp { color: var(--accent); font-style: italic; font-weight: 500; }
278
+ .hero__sub {
279
+ font-family: var(--font-serif); font-size: clamp(16px, 1.7vw, 19px); line-height: 1.55;
280
+ color: var(--ink-2); max-width: 64ch; margin: 18px 0 0;
281
+ }
282
+ .hero__sub em { color: var(--accent); font-style: italic; }
283
+
284
+ /* ---------------- Privacy callout ---------------- */
285
+ .privacy {
286
+ display: flex; gap: 13px; align-items: flex-start;
287
+ margin: 22px 0 34px; padding: 14px 16px;
288
+ border: 1px solid color-mix(in srgb, var(--warn) 40%, var(--edge));
289
+ border-left: 3px solid var(--warn);
290
+ background: var(--warn-tint); border-radius: var(--radius-lg);
291
+ }
292
+ .privacy__mark {
293
+ flex: none; width: 22px; height: 22px; border-radius: 50%;
294
+ display: grid; place-items: center; font-family: var(--font-mono); font-weight: 700;
295
+ color: var(--on-accent); background: var(--warn); font-size: 13px; margin-top: 1px;
296
+ }
297
+ .privacy p { margin: 0; font-size: 14.5px; line-height: 1.5; color: var(--ink-2); }
298
+ .privacy b { color: var(--ink); }
299
+
300
+ /* ---------------- Landing grid ---------------- */
301
+ .landing__grid {
302
+ display: grid; grid-template-columns: 1.15fr 0.85fr; gap: 22px;
303
+ padding-bottom: 70px;
304
+ }
305
+ .panel { padding: calc(22px * var(--space)); }
306
+ .guide { display: flex; flex-direction: column; gap: 22px; }
307
+
308
+ /* section head */
309
+ .sec-head { margin-bottom: 16px; }
310
+ .sec-head__top { display: flex; align-items: center; gap: 12px; }
311
+ .sec-head__no { font-size: 12px; color: var(--ink-faint); font-weight: 600; }
312
+ .sec-head__title {
313
+ font-family: var(--font-serif); font-weight: 600; letter-spacing: -0.015em;
314
+ font-size: clamp(20px, 2.4vw, 27px); line-height: 1.12; margin: 8px 0 0;
315
+ }
316
+ .sec-head__sub { font-size: 14.5px; line-height: 1.5; color: var(--ink-2); margin: 9px 0 0; max-width: 60ch; }
317
+
318
+ /* dropzone */
319
+ .drop {
320
+ margin: 4px 0 18px; border: 1.5px dashed var(--edge-strong); border-radius: var(--radius-lg);
321
+ background: var(--paper-inset); padding: 30px 18px; text-align: center;
322
+ cursor: pointer; transition: border-color .15s, background .15s, transform .12s;
323
+ }
324
+ .drop:hover { border-color: var(--accent); transform: translateY(-1px); }
325
+ .drop--over { border-color: var(--accent); background: var(--accent-tint); }
326
+ .drop--staged { border-style: solid; border-color: var(--accent); background: var(--accent-tint); }
327
+ .drop__empty { display: flex; flex-direction: column; align-items: center; gap: 6px; }
328
+ .drop__icon { font-size: 30px; color: var(--accent); line-height: 1; }
329
+ .drop__title { font-family: var(--font-serif); font-size: 17px; font-weight: 600; }
330
+ .drop__title code, .muted code { font-family: var(--font-mono); font-size: 0.85em; background: var(--paper-inset); padding: 1px 5px; border-radius: 3px; }
331
+ .drop__staged { display: flex; flex-direction: column; gap: 6px; align-items: center; }
332
+ .drop__file { font-size: 14px; font-weight: 600; color: var(--accent-deep); word-break: break-all; }
333
+
334
+ /* toggles */
335
+ .opts { display: flex; flex-direction: column; gap: 4px; margin-bottom: 18px; }
336
+ .toggle {
337
+ display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
338
+ background: none; border: 0; padding: 9px 6px; border-radius: var(--radius);
339
+ }
340
+ .toggle:hover { background: var(--paper-inset); }
341
+ .toggle--locked { cursor: default; opacity: 0.72; }
342
+ .toggle--locked:hover { background: none; }
343
+ .toggle__sw {
344
+ flex: none; width: 38px; height: 22px; border-radius: 999px; padding: 2px;
345
+ background: var(--rule-strong); transition: background .18s; display: flex;
346
+ }
347
+ .toggle--on .toggle__sw { background: var(--accent); }
348
+ .toggle__knob {
349
+ width: 18px; height: 18px; border-radius: 50%; background: var(--paper-3);
350
+ box-shadow: 0 1px 2px rgba(0,0,0,0.3); transition: transform .18s;
351
+ }
352
+ .toggle--on .toggle__knob { transform: translateX(16px); }
353
+ .toggle__txt { display: flex; flex-direction: column; line-height: 1.3; flex: 1; min-width: 0; }
354
+ .toggle__label { font-size: 14px; font-weight: 600; color: var(--ink); }
355
+ .toggle__sub { font-size: 12px; }
356
+
357
+ /* engine */
358
+ .engine { margin-bottom: 20px; }
359
+ .engine__opts { display: flex; flex-direction: column; gap: 8px; margin: 10px 0; }
360
+ .engine__opt {
361
+ display: flex; justify-content: space-between; align-items: baseline; gap: 10px;
362
+ padding: 11px 14px; border: 1px solid var(--edge); border-radius: var(--radius);
363
+ background: var(--paper-3); color: var(--ink); text-align: left; transition: border-color .15s, background .15s;
364
+ }
365
+ .engine__opt:hover { border-color: var(--ink-3); }
366
+ .engine__opt--on { border-color: var(--accent); background: var(--accent-tint); box-shadow: inset 0 0 0 1px var(--accent); }
367
+ .engine__name { font-family: var(--font-serif); font-weight: 600; font-size: 15px; white-space: nowrap; color: var(--ink); }
368
+ .engine__detail { font-size: 11.5px; color: var(--ink-3); white-space: nowrap; text-align: right; }
369
+ .engine__note { font-size: 12px; line-height: 1.45; margin: 4px 0 0; }
370
+
371
+ .panel__actions { display: flex; flex-wrap: wrap; gap: 10px; }
372
+ .panel__actions .btn--primary { flex: 1 1 auto; min-width: 170px; }
373
+
374
+ /* guide: paths table */
375
+ .paths { width: 100%; border-collapse: collapse; }
376
+ .paths td { padding: 9px 4px; border-bottom: 1px dashed var(--rule); font-size: 14px; }
377
+ .paths tr:last-child td { border-bottom: 0; }
378
+ .paths__agent { font-family: var(--font-serif); font-weight: 600; width: 38%; }
379
+ .paths__path { color: var(--accent-deep); font-size: 12.5px; }
380
+
381
+ /* agent-callable */
382
+ .agentcall__hd { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
383
+ .agentcall__blurb { font-size: 13.5px; line-height: 1.5; color: var(--ink-2); margin: 4px 0 12px; }
384
+ .agentcall__pre {
385
+ margin: 0; padding: 14px; border-radius: var(--radius); background: var(--paper-inset);
386
+ border: 1px solid var(--edge); font-size: 11.5px; line-height: 1.6; color: var(--ink-2);
387
+ white-space: pre-wrap; max-height: 200px; overflow: auto;
388
+ }
389
+
390
+ .getrow { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
391
+ .getrow__item {
392
+ display: flex; flex-direction: column; gap: 3px; padding: 13px;
393
+ border: 1px solid var(--edge); border-radius: var(--radius); background: var(--paper-2);
394
+ }
395
+ .getrow__t { font-family: var(--font-serif); font-weight: 600; font-size: 14px; }
396
+ .getrow__s { font-size: 11.5px; line-height: 1.35; }
397
+
398
+ /* ---------------- Analyzing ---------------- */
399
+ .analyzing { min-height: 78vh; display: grid; place-items: center; }
400
+ .analyzing__card { padding: 34px 38px; width: min(440px, 90vw); }
401
+ .analyzing__svg { width: 100%; height: auto; margin-bottom: 14px; }
402
+ .analyzing__trail { stroke-dasharray: 360; stroke-dashoffset: 360; animation: draw 2.4s ease forwards; }
403
+ @keyframes draw { to { stroke-dashoffset: 0; } }
404
+ .analyzing__dot { offset-path: path("M20 96 C 70 60, 100 104, 150 70 S 230 30, 300 44"); animation: ride 2.4s ease forwards; }
405
+ @keyframes ride { from { offset-distance: 0%; } to { offset-distance: 100%; } }
406
+ .analyzing__steps { list-style: none; margin: 16px 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
407
+ .analyzing__steps li { font-size: 14px; color: var(--ink-faint); display: flex; gap: 10px; transition: color .3s; }
408
+ .analyzing__steps li.active { color: var(--ink); }
409
+ .analyzing__steps li.done { color: var(--ink-2); }
410
+ .analyzing__tick { color: var(--accent); width: 14px; }
411
+
412
+ /* ---------------- Report ---------------- */
413
+ .report-back { margin-bottom: 16px; }
414
+ .report { padding: 0; }
415
+ .report > * + * { margin-top: calc(34px * var(--space)); }
416
+ .sec > * + * { margin-top: 18px; }
417
+
418
+ /* report header (specimen tag) */
419
+ .rhead {
420
+ position: relative; padding: 24px 26px; border: 1px solid var(--edge);
421
+ border-radius: var(--radius-lg); background: var(--paper-2);
422
+ background-image: repeating-linear-gradient(var(--rule) 0 1px, transparent 1px 28px);
423
+ background-position: 0 54px;
424
+ }
425
+ .rhead__tag {
426
+ position: absolute; top: 0; right: 22px; transform: translateY(-50%) rotate(1.5deg);
427
+ font-size: 11px; font-weight: 600; letter-spacing: 0.14em; color: var(--on-accent); white-space: nowrap;
428
+ background: var(--accent-deep); padding: 5px 11px; border-radius: 3px;
429
+ }
430
+ .rhead__file { font-family: var(--font-mono); font-weight: 600; font-size: clamp(18px, 2.4vw, 24px); margin: 4px 0 0; word-break: break-all; }
431
+ .rhead__grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; margin: 20px 0 0; }
432
+ .rhead__cell { display: flex; flex-direction: column; gap: 3px; }
433
+ .rhead__cell dd { margin: 0; font-size: 13px; color: var(--ink-2); text-transform: capitalize; }
434
+ .rhead__cell .mono { font-size: 12.5px; }
435
+
436
+ /* verdict */
437
+ .verdict {
438
+ position: relative; overflow: hidden; display: grid; grid-template-columns: 1.5fr 1fr;
439
+ gap: 28px; padding: 28px 30px 28px 36px;
440
+ }
441
+ .verdict__band { position: absolute; left: 0; top: 0; bottom: 0; width: 7px; background: var(--tone); }
442
+ .verdict__headline {
443
+ font-family: var(--font-serif); font-weight: 700; letter-spacing: -0.02em;
444
+ font-size: clamp(24px, 3.2vw, 36px); line-height: 1.08; margin: 12px 0 0; text-wrap: balance;
445
+ }
446
+ .verdict__detail { font-size: 15.5px; line-height: 1.55; color: var(--ink-2); margin: 14px 0 0; }
447
+ .verdict__stamps { margin-top: 18px; }
448
+ .verdict__right { display: flex; flex-direction: column; gap: 16px; justify-content: center;
449
+ border-left: 1px dashed var(--rule-strong); padding-left: 26px; }
450
+ .verdict__gauge { display: flex; flex-direction: column; gap: 4px; }
451
+ .verdict__gauge-val { font-family: var(--font-serif); font-weight: 700; font-size: 21px; color: var(--tone); line-height: 1.1; }
452
+ .verdict__gauge-blurb { font-size: 13px; color: var(--ink-2); line-height: 1.4; }
453
+ .verdict__stats { display: flex; gap: 26px; }
454
+ .verdict__stats > div { display: flex; flex-direction: column; gap: 2px; }
455
+ .verdict__num { font-size: 25px; font-weight: 600; color: var(--ink); line-height: 1; white-space: nowrap; }
456
+
457
+ /* ---------------- Trail ---------------- */
458
+ .trail-card { padding: 22px 24px 18px; }
459
+ .trail__chrome { display: grid; grid-template-columns: 26px 1fr; gap: 10px; }
460
+ .trail__axis-y {
461
+ display: flex; flex-direction: column; justify-content: space-between; align-items: center;
462
+ writing-mode: vertical-rl; transform: rotate(180deg);
463
+ font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.14em; text-transform: uppercase;
464
+ color: var(--ink-faint); padding: 6px 0 40px;
465
+ }
466
+ .trail__axis-y span:nth-child(2) { color: var(--ink-3); }
467
+ .trail__plot { position: relative; aspect-ratio: 1000 / 360; width: 100%; }
468
+ .trail__svg { position: absolute; inset: 0; width: 100%; height: 100%; }
469
+ .trail__node { cursor: pointer; }
470
+ .trail__node circle { transition: r .15s ease; }
471
+
472
+ /* waypoint flags (HTML over SVG) */
473
+ .wp {
474
+ position: absolute; transform: translate(-50%, -50%);
475
+ display: flex; flex-direction: column; align-items: center; gap: 1px;
476
+ background: none; border: 0; padding: 0; cursor: pointer; width: max-content; max-width: 150px;
477
+ z-index: 2;
478
+ }
479
+ .wp--above { transform: translate(-50%, calc(-100% - 16px)); }
480
+ .wp--below { transform: translate(-50%, 18px); }
481
+ .wp--first.wp--above { transform: translate(-14%, calc(-100% - 16px)); align-items: flex-start; }
482
+ .wp--first.wp--below { transform: translate(-14%, 18px); align-items: flex-start; }
483
+ .wp--first .wp__title { text-align: left; }
484
+ .wp--last.wp--above { transform: translate(-86%, calc(-100% - 16px)); align-items: flex-end; }
485
+ .wp--last.wp--below { transform: translate(-86%, 18px); align-items: flex-end; }
486
+ .wp--last .wp__title { text-align: right; }
487
+ .wp__id { font-size: 10.5px; font-weight: 700; color: var(--tone); letter-spacing: 0.06em; }
488
+ .wp__title {
489
+ font-family: var(--font-serif); font-size: 12.5px; font-weight: 600; line-height: 1.12;
490
+ color: var(--ink); text-align: center; padding: 2px 7px; border-radius: 3px;
491
+ background: color-mix(in srgb, var(--paper-3) 88%, transparent);
492
+ border: 1px solid transparent; transition: border-color .15s, background .15s;
493
+ }
494
+ .wp__dur { font-size: 9.5px; color: var(--ink-faint); }
495
+ .wp:hover .wp__title { border-color: var(--tone); }
496
+ .wp--sel .wp__title { border-color: var(--tone); background: var(--paper-3); box-shadow: var(--shadow-card); }
497
+ .wp--sel .wp__id { font-size: 11.5px; }
498
+
499
+ .trail__xaxis {
500
+ display: flex; justify-content: space-between; align-items: center; margin: 14px 0 0 36px;
501
+ font-size: 11px; color: var(--ink-faint);
502
+ }
503
+
504
+ /* legend */
505
+ .legend { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding-top: 6px; }
506
+ .legend__items { display: flex; flex-wrap: wrap; gap: 8px 18px; }
507
+ .legend__item { display: flex; align-items: center; gap: 7px; }
508
+ .legend__txt { font-size: 12px; color: var(--ink-2); }
509
+ .legend__txt b { font-weight: 600; color: var(--ink); }
510
+
511
+ /* episode detail */
512
+ .epd { position: relative; overflow: hidden; padding: 24px 26px 26px; }
513
+ .epd__band { position: absolute; left: 0; top: 0; bottom: 0; width: 6px; background: var(--tone); }
514
+ .epd__head { display: flex; align-items: flex-start; gap: 14px; }
515
+ .epd__head > div:last-child { flex: 1; min-width: 0; }
516
+ .epd__id { display: flex; align-items: center; gap: 7px; flex: none; padding-top: 2px; }
517
+ .epd__no { font-size: 17px; font-weight: 700; color: var(--tone); }
518
+ .epd__title { font-family: var(--font-serif); font-weight: 700; font-size: 22px; letter-spacing: -0.015em; margin: 0; line-height: 1.16; }
519
+ .epd__meta { font-size: 11.5px; color: var(--ink-3); margin-top: 6px; }
520
+ .epd__flow { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin: 20px 0; }
521
+ .epd__step { display: flex; flex-direction: column; gap: 6px; }
522
+ .epd__step p { margin: 0; font-size: 14px; line-height: 1.5; color: var(--ink-2); font-family: var(--font-body); }
523
+ .epd__codes { display: flex; flex-wrap: wrap; gap: 7px; margin: 16px 0; }
524
+ .epd__quotes { display: flex; flex-direction: column; gap: 8px; margin: 16px 0; }
525
+ .epd__memo { margin-top: 16px; padding: 14px 16px; background: var(--accent-tint); border-radius: var(--radius); border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); }
526
+ .epd__memo p { margin: 6px 0 0; font-size: 14.5px; line-height: 1.55; color: var(--ink); font-family: var(--font-body); }
527
+
528
+ /* quotes */
529
+ .quote {
530
+ margin: 0; padding: 8px 14px; border-left: 2px solid var(--rule-strong);
531
+ font-family: var(--font-serif); font-style: italic; font-size: 14.5px; line-height: 1.5; color: var(--ink-2);
532
+ }
533
+ .quote--sm { font-size: 13px; padding: 4px 12px; }
534
+
535
+ /* ledger variant */
536
+ .ledger { display: flex; flex-direction: column; }
537
+ .ledger__row {
538
+ display: grid; grid-template-columns: 26px 36px 1fr auto; align-items: center; gap: 12px;
539
+ width: 100%; text-align: left; background: none; border: 0; border-bottom: 1px dashed var(--rule);
540
+ padding: 13px 6px; transition: background .12s;
541
+ }
542
+ .ledger__row:hover { background: var(--paper-inset); }
543
+ .ledger__row--sel { background: var(--accent-tint); }
544
+ .ledger__rail { display: grid; place-items: center; }
545
+ .ledger__id { font-size: 12px; font-weight: 700; color: var(--tone); }
546
+ .ledger__main { display: flex; flex-direction: column; gap: 2px; }
547
+ .ledger__title { font-family: var(--font-serif); font-weight: 600; font-size: 15px; }
548
+ .ledger__sub { font-size: 12px; color: var(--ink-3); }
549
+ .ledger__dur { font-size: 11.5px; color: var(--ink-faint); }
550
+
551
+ /* ---------------- Difficulty map ---------------- */
552
+ .dmap { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
553
+ .dmap__cell { padding: 16px 18px; }
554
+ .dmap__hd { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 8px; }
555
+ .dmap__type { font-family: var(--font-serif); font-weight: 600; font-size: 15.5px; }
556
+ .dmap__ids { font-size: 11px; color: var(--accent-deep); }
557
+
558
+ /* ---------------- Detour ---------------- */
559
+ .detour { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
560
+ .detour__col { padding: 18px; border-top: 3px solid var(--tone); }
561
+ .detour__hd { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
562
+ .detour__title { font-family: var(--font-serif); font-weight: 600; font-size: 15.5px; flex: 1; }
563
+ .detour__count { font-size: 18px; font-weight: 600; color: var(--tone); }
564
+ .detour__blurb { font-size: 13px; line-height: 1.45; color: var(--ink-2); margin: 0 0 12px; }
565
+ .detour__list { display: flex; flex-direction: column; gap: 8px; }
566
+ .detour__ep { display: flex; align-items: center; gap: 8px; }
567
+ .detour__epid { font-size: 12px; font-weight: 700; color: var(--ink-3); }
568
+ .detour__none { font-size: 13px; }
569
+
570
+ /* ---------------- Recovery ---------------- */
571
+ .recov { padding: 8px 26px; }
572
+ .recov__row { display: grid; grid-template-columns: 30px 130px 1fr; gap: 16px; align-items: baseline; padding: 16px 0; border-bottom: 1px dashed var(--rule); }
573
+ .recov__row:last-child { border-bottom: 0; }
574
+ .recov__no { color: var(--ink-faint); font-size: 12px; }
575
+ .recov__k { padding-top: 2px; }
576
+ .recov__v { margin: 0; font-size: 15px; line-height: 1.55; color: var(--ink); font-family: var(--font-body); }
577
+
578
+ /* ---------------- Outcome audit ---------------- */
579
+ .audit { padding: 8px 24px; }
580
+ .audit__row { display: grid; grid-template-columns: 54px 1fr; gap: 16px; padding: 16px 0; border-bottom: 1px dashed var(--rule); }
581
+ .audit__row:last-child { border-bottom: 0; }
582
+ .audit__rail { display: flex; flex-direction: column; align-items: center; gap: 8px; padding-top: 2px; }
583
+ .audit__rail .mono { font-size: 12px; font-weight: 700; color: var(--ink-3); }
584
+ .audit__claim { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
585
+ .audit__verb { font-family: var(--font-serif); font-weight: 700; font-size: 16px; color: var(--tone); }
586
+ .audit__note { font-size: 13.5px; color: var(--ink-2); }
587
+ .audit__body .quote { margin-top: 8px; }
588
+
589
+ /* ---------------- Privacy / exports ---------------- */
590
+ .px { display: grid; grid-template-columns: 1fr 0.8fr; gap: 22px; }
591
+ .px__notes { padding: 22px 24px; }
592
+ .px__list { margin: 8px 0 0; padding-left: 18px; }
593
+ .px__list li { font-size: 14px; line-height: 1.7; color: var(--ink-2); }
594
+ .px__exports { padding: 22px 24px; display: flex; flex-direction: column; gap: 12px; }
595
+ .px__blurb { font-size: 14px; line-height: 1.5; color: var(--ink-2); margin: 0; }
596
+ .px__btns { display: flex; flex-direction: column; gap: 9px; }
597
+ .px__btns .btn { justify-content: flex-start; }
598
+ .px__btns .btn span { color: var(--accent); font-weight: 700; }
599
+
600
+ /* footer */
601
+ .foot { display: flex; flex-direction: column; gap: 4px; margin-top: 50px; padding-top: 20px; border-top: 1px solid var(--rule); }
602
+ .foot .mono { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; }
603
+ .foot .muted { font-size: 12.5px; }
604
+
605
+ /* ---------------- Responsive ---------------- */
606
+ @media (max-width: 920px) {
607
+ .landing__grid, .verdict, .px { grid-template-columns: 1fr; }
608
+ .verdict__right { border-left: 0; border-top: 1px dashed var(--rule-strong); padding-left: 0; padding-top: 18px; }
609
+ .rhead__grid { grid-template-columns: repeat(3, 1fr); }
610
+ .detour { grid-template-columns: 1fr; }
611
+ .epd__flow { grid-template-columns: 1fr; gap: 12px; }
612
+ }
613
+ @media (max-width: 560px) {
614
+ .landing, .report, .report-wrap { padding: 0 16px; }
615
+ .rhead__grid { grid-template-columns: repeat(2, 1fr); }
616
+ .getrow { grid-template-columns: 1fr; }
617
+ .wp__title { font-size: 11px; }
618
+ .recov__row { grid-template-columns: 1fr; gap: 4px; }
619
+ }
model_runtime.py CHANGED
@@ -1,11 +1,19 @@
1
- """Optional model assistance through Hugging Face Inference Providers."""
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
  import json
6
- import os
7
  from dataclasses import dataclass
8
- from typing import Any, Protocol
9
 
10
  from schemas import AnalysisResult
11
 
@@ -14,24 +22,24 @@ PRIMARY_MODEL_ID = "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16"
14
  QUICK_MODEL_ID = "Qwen/Qwen3.5-9B"
15
 
16
  MODEL_CHOICES = {
17
- "deterministic": {
18
- "label": "Deterministic field notes",
19
- "model_id": None,
20
  },
21
  "nemotron": {
22
- "label": "NVIDIA Nemotron 3 Nano 30B-A3B assist",
23
  "model_id": PRIMARY_MODEL_ID,
24
  },
25
- "qwen": {
26
- "label": "Quick small-model assist: Qwen3.5 9B",
27
- "model_id": QUICK_MODEL_ID,
28
  },
29
  }
30
 
 
 
31
 
32
- class ChatClient(Protocol):
33
- def chat_completion(self, *args: Any, **kwargs: Any) -> Any:
34
- ...
35
 
36
 
37
  @dataclass(slots=True)
@@ -54,59 +62,93 @@ def run_model_assist(
54
  engine: str,
55
  result: AnalysisResult,
56
  narrative_text: str,
57
- token: str | None = None,
58
- client: ChatClient | None = None,
59
  ) -> ModelAssistResult:
60
- """Ask the selected model for a concise memo grounded in visible text."""
61
 
62
  model_id = model_id_for_engine(engine)
63
  if not model_id:
64
  raise ValueError(f"No model is configured for analysis engine {engine!r}.")
65
 
66
  prompt = build_model_prompt(result, narrative_text)
67
- if client is None:
68
- from huggingface_hub import InferenceClient, get_token
69
-
70
- resolved_token = token or os.getenv("HF_TOKEN") or get_token()
71
- if not resolved_token:
72
- raise ValueError(
73
- "Sign in with Hugging Face to enable model assist through "
74
- "the inference-api OAuth scope."
75
- )
76
-
77
- inference_client = InferenceClient(
78
- model=model_id,
79
- provider=os.getenv("TRACE_FIELD_NOTES_INFERENCE_PROVIDER") or None,
80
- token=resolved_token,
81
- timeout=float(os.getenv("TRACE_FIELD_NOTES_MODEL_TIMEOUT", "45")),
82
- )
83
- else:
84
- inference_client = client
85
- response = inference_client.chat_completion(
86
- messages=[
87
- {
88
- "role": "system",
89
- "content": (
90
- "You analyze visible coding-agent narrative messages. "
91
- "Do not infer hidden reasoning. Return JSON only."
92
- ),
93
- },
94
- {"role": "user", "content": prompt},
95
- ],
96
- model=model_id,
97
- max_tokens=900,
98
- temperature=0.2,
99
- response_format={"type": "json_object"},
100
- )
101
- content = extract_chat_content(response)
102
  memo = parse_model_json(content)
103
  return ModelAssistResult(
104
  model_id=model_id,
105
  memo=memo,
106
- note=f"Model assist completed with {model_id}.",
107
  )
108
 
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  def build_model_prompt(result: AnalysisResult, narrative_text: str) -> str:
111
  deterministic_json = json.dumps(result.to_dict(), ensure_ascii=False, indent=2)
112
  narrative_excerpt = narrative_text[:12000]
@@ -132,21 +174,8 @@ Redacted narrative excerpt:
132
  """
133
 
134
 
135
- def extract_chat_content(response: Any) -> str:
136
- try:
137
- content = response.choices[0].message.content
138
- except (AttributeError, IndexError, TypeError) as exc:
139
- raise ValueError("Model response did not contain chat completion content.") from exc
140
- if not isinstance(content, str) or not content.strip():
141
- raise ValueError("Model response content was empty.")
142
- return content
143
-
144
-
145
  def parse_model_json(content: str) -> dict[str, Any]:
146
- try:
147
- parsed = json.loads(content)
148
- except json.JSONDecodeError as exc:
149
- raise ValueError("Model response was not valid JSON.") from exc
150
 
151
  required = {
152
  "executive_memo": str,
@@ -159,3 +188,30 @@ def parse_model_json(content: str) -> dict[str, Any]:
159
  raise ValueError(f"Model response missing {key!r} as {expected_type.__name__}.")
160
  parsed["caveats"] = [str(item) for item in parsed["caveats"][:6]]
161
  return parsed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local small-model assistance for Trace Field Notes on Hugging Face ZeroGPU.
2
+
3
+ The analysis models run on the Space GPU through ``transformers``. Heavy imports
4
+ (``torch``, ``transformers``) are loaded lazily inside the generator so that the
5
+ deterministic analyzer, the test suite, and local development keep working
6
+ without GPU dependencies installed. If a model cannot be loaded or its output is
7
+ not valid JSON, :func:`analyzer.analyze_trace_file` falls back to the
8
+ deterministic codebook and records the reason in the model notes.
9
+ """
10
 
11
  from __future__ import annotations
12
 
13
  import json
14
+ import re
15
  from dataclasses import dataclass
16
+ from typing import Any, Callable
17
 
18
  from schemas import AnalysisResult
19
 
 
22
  QUICK_MODEL_ID = "Qwen/Qwen3.5-9B"
23
 
24
  MODEL_CHOICES = {
25
+ "qwen": {
26
+ "label": "Qwen3.5 9B — quick analysis",
27
+ "model_id": QUICK_MODEL_ID,
28
  },
29
  "nemotron": {
30
+ "label": "NVIDIA Nemotron 3 Nano 30B-A3B — deeper analysis",
31
  "model_id": PRIMARY_MODEL_ID,
32
  },
33
+ "deterministic": {
34
+ "label": "Rule-based instant, no model",
35
+ "model_id": None,
36
  },
37
  }
38
 
39
+ # (messages, *, model_id, max_new_tokens) -> raw model text.
40
+ GenerateFn = Callable[..., str]
41
 
42
+ _MODEL_CACHE: dict[str, Any] = {}
 
 
43
 
44
 
45
  @dataclass(slots=True)
 
62
  engine: str,
63
  result: AnalysisResult,
64
  narrative_text: str,
65
+ generate: GenerateFn | None = None,
 
66
  ) -> ModelAssistResult:
67
+ """Run the selected model on the GPU and return a concise grounded memo."""
68
 
69
  model_id = model_id_for_engine(engine)
70
  if not model_id:
71
  raise ValueError(f"No model is configured for analysis engine {engine!r}.")
72
 
73
  prompt = build_model_prompt(result, narrative_text)
74
+ messages = [
75
+ {
76
+ "role": "system",
77
+ "content": (
78
+ "You analyze visible coding-agent narrative messages. "
79
+ "Do not infer hidden reasoning. Return JSON only."
80
+ ),
81
+ },
82
+ {"role": "user", "content": prompt},
83
+ ]
84
+
85
+ generator = generate or _local_generator
86
+ content = generator(messages, model_id=model_id, max_new_tokens=900)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  memo = parse_model_json(content)
88
  return ModelAssistResult(
89
  model_id=model_id,
90
  memo=memo,
91
+ note=f"Model assist completed on the Space GPU with {model_id}.",
92
  )
93
 
94
 
95
+ def _local_generator(
96
+ messages: list[dict[str, str]],
97
+ *,
98
+ model_id: str,
99
+ max_new_tokens: int,
100
+ ) -> str:
101
+ """Generate text with a locally loaded model on the ZeroGPU device.
102
+
103
+ Imported lazily: ``torch`` only needs to exist on the GPU Space, never for
104
+ the deterministic path, tests, or local development.
105
+ """
106
+
107
+ import torch
108
+
109
+ tokenizer, model = _load_model(model_id)
110
+ inputs = tokenizer.apply_chat_template(
111
+ messages,
112
+ add_generation_prompt=True,
113
+ return_tensors="pt",
114
+ ).to(model.device)
115
+ with torch.no_grad():
116
+ generated = model.generate(
117
+ inputs,
118
+ max_new_tokens=max_new_tokens,
119
+ do_sample=False,
120
+ )
121
+ completion = generated[0][inputs.shape[-1]:]
122
+ return tokenizer.decode(completion, skip_special_tokens=True)
123
+
124
+
125
+ def _load_model(model_id: str) -> Any:
126
+ """Lazily load and cache a (tokenizer, model) pair on the GPU.
127
+
128
+ The cache keeps weights resident across requests so only the first call per
129
+ model pays the load cost. ZeroGPU exposes CUDA inside the ``@spaces.GPU``
130
+ context, which is where this runs.
131
+ """
132
+
133
+ cached = _MODEL_CACHE.get(model_id)
134
+ if cached is not None:
135
+ return cached
136
+
137
+ import torch
138
+ from transformers import AutoModelForCausalLM, AutoTokenizer
139
+
140
+ tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
141
+ model = AutoModelForCausalLM.from_pretrained(
142
+ model_id,
143
+ torch_dtype=torch.bfloat16,
144
+ device_map="cuda",
145
+ trust_remote_code=True,
146
+ )
147
+ model.eval()
148
+ _MODEL_CACHE[model_id] = (tokenizer, model)
149
+ return tokenizer, model
150
+
151
+
152
  def build_model_prompt(result: AnalysisResult, narrative_text: str) -> str:
153
  deterministic_json = json.dumps(result.to_dict(), ensure_ascii=False, indent=2)
154
  narrative_excerpt = narrative_text[:12000]
 
174
  """
175
 
176
 
 
 
 
 
 
 
 
 
 
 
177
  def parse_model_json(content: str) -> dict[str, Any]:
178
+ parsed = _loads_lenient(content)
 
 
 
179
 
180
  required = {
181
  "executive_memo": str,
 
188
  raise ValueError(f"Model response missing {key!r} as {expected_type.__name__}.")
189
  parsed["caveats"] = [str(item) for item in parsed["caveats"][:6]]
190
  return parsed
191
+
192
+
193
+ def _loads_lenient(content: str) -> dict[str, Any]:
194
+ """Parse JSON from a model that may wrap it in prose or code fences."""
195
+
196
+ if not isinstance(content, str) or not content.strip():
197
+ raise ValueError("Model response content was empty.")
198
+
199
+ text = content.strip()
200
+ fence = re.match(r"^```[a-zA-Z0-9]*\s*(.*?)\s*```$", text, re.DOTALL)
201
+ if fence:
202
+ text = fence.group(1).strip()
203
+
204
+ try:
205
+ parsed: Any = json.loads(text)
206
+ except json.JSONDecodeError:
207
+ start, end = text.find("{"), text.rfind("}")
208
+ if start == -1 or end == -1 or end <= start:
209
+ raise ValueError("Model response was not valid JSON.")
210
+ try:
211
+ parsed = json.loads(text[start : end + 1])
212
+ except json.JSONDecodeError as exc:
213
+ raise ValueError("Model response was not valid JSON.") from exc
214
+
215
+ if not isinstance(parsed, dict):
216
+ raise ValueError("Model response was not a JSON object.")
217
+ return parsed
requirements.txt CHANGED
@@ -1,3 +1,7 @@
1
- gradio[oauth]>=5.50,<6.0
2
  huggingface_hub>=0.30
3
  spaces>=0.50
 
 
 
 
 
1
+ gradio>=6.16,<7
2
  huggingface_hub>=0.30
3
  spaces>=0.50
4
+ torch>=2.4
5
+ transformers>=4.57
6
+ accelerate>=1.0
7
+ einops>=0.8
tests/test_model_runtime.py CHANGED
@@ -10,24 +10,25 @@ from analyzer import analyze_trace_file
10
  from model_runtime import MODEL_CHOICES, PRIMARY_MODEL_ID, parse_model_json, run_model_assist
11
 
12
 
13
- class FakeChatClient:
14
- def chat_completion(self, *args, **kwargs):
15
- self.kwargs = kwargs
16
- content = json.dumps(
17
- {
18
- "executive_memo": "The trace shows a visible upload-boundary correction.",
19
- "detour_memo": "E01 narrows scope instead of changing the parser.",
20
- "outcome_audit_memo": "The agent keeps a deployment caveat visible.",
21
- "caveats": ["Model memo is based only on redacted narrative."],
22
- }
23
- )
24
- return types.SimpleNamespace(
25
- choices=[
26
- types.SimpleNamespace(
27
- message=types.SimpleNamespace(content=content),
28
- )
29
- ]
30
  )
 
31
 
32
 
33
  class ModelRuntimeTests(unittest.TestCase):
@@ -38,34 +39,36 @@ class ModelRuntimeTests(unittest.TestCase):
38
  self.assertNotIn("small", label.lower())
39
 
40
  def test_parse_model_json_validates_required_shape(self) -> None:
41
- memo = parse_model_json(
42
- json.dumps(
43
- {
44
- "executive_memo": "summary",
45
- "detour_memo": "detour",
46
- "outcome_audit_memo": "audit",
47
- "caveats": ["one"],
48
- }
49
- )
50
- )
 
 
 
51
 
52
- self.assertEqual(memo["executive_memo"], "summary")
53
- self.assertEqual(memo["caveats"], ["one"])
54
 
55
  def test_run_model_assist_uses_selected_model(self) -> None:
56
  result, narrative = analyze_trace_file(Path("examples/sample_trace_redacted.jsonl"))
57
- client = FakeChatClient()
58
 
59
  assist = run_model_assist(
60
  engine="nemotron",
61
  result=result,
62
  narrative_text=narrative,
63
- client=client,
64
  )
65
 
66
  self.assertEqual(assist.model_id, PRIMARY_MODEL_ID)
67
  self.assertIn("upload-boundary", assist.memo["executive_memo"])
68
- self.assertEqual(client.kwargs["model"], PRIMARY_MODEL_ID)
69
 
70
  def test_analyzer_records_unknown_engine_note(self) -> None:
71
  result, _ = analyze_trace_file(
@@ -77,7 +80,7 @@ class ModelRuntimeTests(unittest.TestCase):
77
  self.assertIn("Unknown analysis engine", result.model_notes[0])
78
 
79
  def test_analyzer_model_error_note_avoids_double_period(self) -> None:
80
- with patch("analyzer.run_model_assist", side_effect=ValueError("needs login.")):
81
  result, _ = analyze_trace_file(
82
  Path("examples/sample_trace_redacted.jsonl"),
83
  analysis_engine="qwen",
@@ -85,28 +88,22 @@ class ModelRuntimeTests(unittest.TestCase):
85
 
86
  self.assertTrue(result.model_notes)
87
  self.assertNotIn("..", result.model_notes[0])
88
- self.assertIn("ValueError: needs login.", result.model_notes[0])
89
 
90
- def test_analyzer_passes_hf_token_to_model_assist(self) -> None:
91
  with patch("analyzer.run_model_assist") as run_model_assist:
92
  run_model_assist.return_value = types.SimpleNamespace(
93
  model_id=PRIMARY_MODEL_ID,
94
- memo={
95
- "executive_memo": "memo",
96
- "detour_memo": "detour",
97
- "outcome_audit_memo": "audit",
98
- "caveats": [],
99
- },
100
  note="ok",
101
  )
102
  result, _ = analyze_trace_file(
103
  Path("examples/sample_trace_redacted.jsonl"),
104
  analysis_engine="nemotron",
105
- hf_token="hf_test_token",
106
  )
107
 
108
  self.assertIn(PRIMARY_MODEL_ID, result.engine)
109
- self.assertEqual(run_model_assist.call_args.kwargs["token"], "hf_test_token")
110
 
111
 
112
  if __name__ == "__main__":
 
10
  from model_runtime import MODEL_CHOICES, PRIMARY_MODEL_ID, parse_model_json, run_model_assist
11
 
12
 
13
+ MEMO_JSON = {
14
+ "executive_memo": "The trace shows a visible upload-boundary correction.",
15
+ "detour_memo": "E01 narrows scope instead of changing the parser.",
16
+ "outcome_audit_memo": "The agent keeps a deployment caveat visible.",
17
+ "caveats": ["Model memo is based only on redacted narrative."],
18
+ }
19
+
20
+
21
+ class RecordingGenerator:
22
+ """Stand-in for the local GPU generator that records its call arguments."""
23
+
24
+ def __init__(self) -> None:
25
+ self.calls: list[dict] = []
26
+
27
+ def __call__(self, messages, *, model_id, max_new_tokens) -> str:
28
+ self.calls.append(
29
+ {"messages": messages, "model_id": model_id, "max_new_tokens": max_new_tokens}
30
  )
31
+ return json.dumps(MEMO_JSON)
32
 
33
 
34
  class ModelRuntimeTests(unittest.TestCase):
 
39
  self.assertNotIn("small", label.lower())
40
 
41
  def test_parse_model_json_validates_required_shape(self) -> None:
42
+ memo = parse_model_json(json.dumps(MEMO_JSON))
43
+
44
+ self.assertEqual(memo["executive_memo"], MEMO_JSON["executive_memo"])
45
+ self.assertEqual(memo["caveats"], MEMO_JSON["caveats"])
46
+
47
+ def test_parse_model_json_recovers_from_code_fence(self) -> None:
48
+ memo = parse_model_json("```json\n" + json.dumps(MEMO_JSON) + "\n```")
49
+
50
+ self.assertEqual(memo["detour_memo"], MEMO_JSON["detour_memo"])
51
+
52
+ def test_parse_model_json_extracts_object_from_prose(self) -> None:
53
+ raw = "Here is the analysis:\n" + json.dumps(MEMO_JSON) + "\nHope this helps."
54
+ memo = parse_model_json(raw)
55
 
56
+ self.assertEqual(memo["outcome_audit_memo"], MEMO_JSON["outcome_audit_memo"])
 
57
 
58
  def test_run_model_assist_uses_selected_model(self) -> None:
59
  result, narrative = analyze_trace_file(Path("examples/sample_trace_redacted.jsonl"))
60
+ generate = RecordingGenerator()
61
 
62
  assist = run_model_assist(
63
  engine="nemotron",
64
  result=result,
65
  narrative_text=narrative,
66
+ generate=generate,
67
  )
68
 
69
  self.assertEqual(assist.model_id, PRIMARY_MODEL_ID)
70
  self.assertIn("upload-boundary", assist.memo["executive_memo"])
71
+ self.assertEqual(generate.calls[0]["model_id"], PRIMARY_MODEL_ID)
72
 
73
  def test_analyzer_records_unknown_engine_note(self) -> None:
74
  result, _ = analyze_trace_file(
 
80
  self.assertIn("Unknown analysis engine", result.model_notes[0])
81
 
82
  def test_analyzer_model_error_note_avoids_double_period(self) -> None:
83
+ with patch("analyzer.run_model_assist", side_effect=ValueError("model unavailable.")):
84
  result, _ = analyze_trace_file(
85
  Path("examples/sample_trace_redacted.jsonl"),
86
  analysis_engine="qwen",
 
88
 
89
  self.assertTrue(result.model_notes)
90
  self.assertNotIn("..", result.model_notes[0])
91
+ self.assertIn("ValueError: model unavailable.", result.model_notes[0])
92
 
93
+ def test_analyzer_records_model_engine_on_success(self) -> None:
94
  with patch("analyzer.run_model_assist") as run_model_assist:
95
  run_model_assist.return_value = types.SimpleNamespace(
96
  model_id=PRIMARY_MODEL_ID,
97
+ memo=dict(MEMO_JSON),
 
 
 
 
 
98
  note="ok",
99
  )
100
  result, _ = analyze_trace_file(
101
  Path("examples/sample_trace_redacted.jsonl"),
102
  analysis_engine="nemotron",
 
103
  )
104
 
105
  self.assertIn(PRIMARY_MODEL_ID, result.engine)
106
+ self.assertNotIn("token", run_model_assist.call_args.kwargs)
107
 
108
 
109
  if __name__ == "__main__":
view_model.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adapt an :class:`AnalysisResult` into the JSON shape the React frontend expects.
2
+
3
+ The designer's prototype renders from a richer object than the analyzer produces:
4
+ it also wants a top-level ``verdict`` (a whole-session read), a ``captured``
5
+ window, and a ``duration_total``. Those are synthesized here from the
6
+ deterministic episodes (and the model memo, when present) so the frontend stays
7
+ a pure view layer.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import Any
14
+
15
+ from analyzer import duration_label, parse_timestamp
16
+ from report_renderer import render_report
17
+ from schemas import AnalysisResult
18
+
19
+
20
+ # recovery_pattern -> tone bucket (mirrors the frontend's TONE_OF in data.js)
21
+ TONE_OF = {
22
+ "smooth_recovery": "stable",
23
+ "reflective_recovery": "stable",
24
+ "iterative_recovery": "iterative",
25
+ "detour_recovery": "detour",
26
+ "partial_recovery": "partial",
27
+ "failed_recovery": "risk",
28
+ "avoidant_recovery": "risk",
29
+ "overconfident_recovery": "risk",
30
+ "unknown": "unknown",
31
+ }
32
+
33
+ _SEVERITY = {"risk": 5, "partial": 4, "iterative": 3, "detour": 2, "stable": 1, "unknown": 0}
34
+
35
+ _CANDID_CLAIMS = {
36
+ "resolved_with_caveat",
37
+ "not_resolved",
38
+ "needs_verification",
39
+ "partially_resolved",
40
+ "uncertain_but_proceeding",
41
+ }
42
+
43
+ _HEADLINE_BY_TONE = {
44
+ "stable": "A clean run with an honest close-out.",
45
+ "detour": "Left the planned path and found a better line.",
46
+ "iterative": "Closed in on it through repeated attempts.",
47
+ "partial": "Part of the way there, with caveats left standing.",
48
+ "risk": "Hit hazard terrain and didn't clearly recover.",
49
+ "unknown": "A short session with little difficulty signal.",
50
+ }
51
+
52
+
53
+ def build_view_model(
54
+ result: AnalysisResult,
55
+ narrative_text: str,
56
+ *,
57
+ include_exports: bool = True,
58
+ ) -> dict[str, Any]:
59
+ """Return the frontend-ready dict for one analysis."""
60
+
61
+ base = result.to_dict()
62
+ episodes = [_clean_episode(ep) for ep in base["episodes"]]
63
+
64
+ view: dict[str, Any] = {
65
+ "trace_title": base["trace_title"],
66
+ "agent_type_guess": base["agent_type_guess"],
67
+ "analysis_scope": base["analysis_scope"],
68
+ "engine": base["engine"],
69
+ "captured": _captured(episodes),
70
+ "narrative_message_count": base["narrative_message_count"],
71
+ "redaction_count": base["redaction_count"],
72
+ "duration_total": _duration_total(episodes),
73
+ "verdict": _verdict(episodes, base["overall_patterns"], result.model_memo),
74
+ "overall_patterns": base["overall_patterns"],
75
+ "privacy_notes": list(base["privacy_notes"]) + list(base.get("model_notes") or []),
76
+ "episodes": episodes,
77
+ }
78
+ if result.model_memo:
79
+ view["model_memo"] = result.model_memo
80
+ if include_exports:
81
+ view["exports"] = {
82
+ "narrative_md": narrative_text,
83
+ "report_md": render_report(result),
84
+ "episodes_json": json.dumps(base, indent=2, ensure_ascii=False) + "\n",
85
+ }
86
+ return view
87
+
88
+
89
+ def _clean_episode(ep: dict[str, Any]) -> dict[str, Any]:
90
+ ep = dict(ep)
91
+ span = dict(ep.get("message_span") or {})
92
+ span["start_time"] = span.get("start_time") or ""
93
+ span["end_time"] = span.get("end_time") or ""
94
+ span["duration_label"] = span.get("duration_label") or "unknown"
95
+ ep["message_span"] = span
96
+ ep["evidence_quotes"] = list(ep.get("evidence_quotes") or [])
97
+ return ep
98
+
99
+
100
+ def _session_tone(episodes: list[dict[str, Any]]) -> str:
101
+ tones = [TONE_OF.get(ep["recovery_pattern"], "unknown") for ep in episodes]
102
+ if not tones:
103
+ return "unknown"
104
+ return max(tones, key=lambda t: _SEVERITY[t])
105
+
106
+
107
+ def _honesty(episodes: list[dict[str, Any]]) -> str:
108
+ claims = [ep["outcome_claim"] for ep in episodes]
109
+ if any(c == "premature_success_claim" for c in claims):
110
+ return "overclaimed"
111
+ if any(c in _CANDID_CLAIMS for c in claims):
112
+ return "candid"
113
+ return "mixed"
114
+
115
+
116
+ def _verdict(
117
+ episodes: list[dict[str, Any]],
118
+ patterns: dict[str, str],
119
+ model_memo: dict[str, Any] | None,
120
+ ) -> dict[str, str]:
121
+ n = len(episodes)
122
+ if not n:
123
+ return {
124
+ "tone": "unknown",
125
+ "headline": "No explicit difficulty episode surfaced.",
126
+ "detail": "The visible narrative did not carry clear blockage, detour, or recovery language.",
127
+ "honesty": "mixed",
128
+ }
129
+ tone = _session_tone(episodes)
130
+ honesty = _honesty(episodes)
131
+ headline = (
132
+ "Real progress, but the final claim outruns the evidence."
133
+ if honesty == "overclaimed"
134
+ else _HEADLINE_BY_TONE.get(tone, "A session across mixed terrain.")
135
+ )
136
+ memo_detail = (model_memo or {}).get("executive_memo") if model_memo else None
137
+ if memo_detail:
138
+ detail = str(memo_detail)
139
+ else:
140
+ plural = "s" if n != 1 else ""
141
+ parts = [f"{n} difficulty episode{plural}."]
142
+ if patterns.get("recovery_style"):
143
+ parts.append(patterns["recovery_style"])
144
+ if patterns.get("risk_or_caveat"):
145
+ parts.append(patterns["risk_or_caveat"])
146
+ detail = " ".join(parts)
147
+ return {"tone": tone, "headline": headline, "detail": detail, "honesty": honesty}
148
+
149
+
150
+ def _captured(episodes: list[dict[str, Any]]) -> str:
151
+ if not episodes:
152
+ return "—"
153
+ start = episodes[0]["message_span"].get("start_time") or ""
154
+ end = episodes[-1]["message_span"].get("end_time") or ""
155
+ if start and end:
156
+ return f"{start} – {end}"
157
+ return start or end or "—"
158
+
159
+
160
+ def _duration_total(episodes: list[dict[str, Any]]) -> str:
161
+ if not episodes:
162
+ return "—"
163
+ start = episodes[0]["message_span"].get("start_time")
164
+ end = episodes[-1]["message_span"].get("end_time")
165
+ if start and end:
166
+ label = duration_label(start, end)
167
+ if label != "unknown":
168
+ return label
169
+ # fall back to summing per-episode labels is lossy; show the span count instead
170
+ return episodes[-1]["message_span"].get("duration_label") or "—"