Spaces:
Running on Zero
Running on Zero
feat: serve designer React frontend via gradio.Server on ZeroGPU
Browse files- README.md +34 -20
- analyzer.py +0 -2
- app.py +94 -317
- frontend/index.html +31 -0
- frontend/static/app.jsx +358 -0
- frontend/static/components.jsx +615 -0
- frontend/static/data.js +320 -0
- frontend/static/field_report.css +619 -0
- model_runtime.py +122 -66
- requirements.txt +5 -1
- tests/test_model_runtime.py +40 -43
- view_model.py +170 -0
README.md
CHANGED
|
@@ -3,14 +3,10 @@ title: Trace Field Notes
|
|
| 3 |
colorFrom: green
|
| 4 |
colorTo: gray
|
| 5 |
sdk: gradio
|
| 6 |
-
sdk_version:
|
| 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
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
`
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
## Run Locally
|
| 32 |
|
|
@@ -45,18 +57,20 @@ python3.11 -m unittest discover -s tests
|
|
| 45 |
|
| 46 |
## Analysis Engines
|
| 47 |
|
| 48 |
-
- `
|
| 49 |
-
- `NVIDIA Nemotron 3 Nano 30B-A3B
|
| 50 |
-
|
| 51 |
-
- `
|
| 52 |
|
| 53 |
-
If a
|
| 54 |
-
|
| 55 |
-
|
| 56 |
|
| 57 |
-
The
|
| 58 |
-
Hugging Face ZeroGPU hardware.
|
| 59 |
-
|
|
|
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
import
|
| 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
|
| 17 |
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
SAMPLE_TRACE_PATH = "examples/sample_trace_redacted.jsonl"
|
| 22 |
|
| 23 |
-
|
| 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 |
-
|
| 30 |
-
**ZeroGPU field report**
|
| 31 |
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
|
| 35 |
-
"""
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|---|---|
|
| 42 |
-
| Codex | `~/.codex/sessions` |
|
| 43 |
-
| Claude Code | `~/.claude/projects` |
|
| 44 |
-
| Pi Agent | `~/.pi/agent/sessions` |
|
| 45 |
-
"""
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 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 |
-
|
| 60 |
-
|
| 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 |
-
|
| 143 |
-
|
| 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 |
-
|
| 157 |
-
|
| 158 |
-
|
| 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 |
-
@
|
| 185 |
-
def
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
| 198 |
include_user_context=include_user_context,
|
| 199 |
redact_secrets=redact_secrets,
|
| 200 |
-
ignore_tool_calls=
|
| 201 |
-
report_style=report_style,
|
| 202 |
analysis_engine=analysis_engine,
|
| 203 |
-
oauth_token=oauth_token,
|
| 204 |
)
|
| 205 |
|
| 206 |
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 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 |
-
|
| 286 |
-
|
| 287 |
-
|
| 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 |
-
|
| 296 |
-
|
| 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 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"> & </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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
| 6 |
-
import
|
| 7 |
from dataclasses import dataclass
|
| 8 |
-
from typing import Any,
|
| 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 |
-
"
|
| 18 |
-
"label": "
|
| 19 |
-
"model_id":
|
| 20 |
},
|
| 21 |
"nemotron": {
|
| 22 |
-
"label": "NVIDIA Nemotron 3 Nano 30B-A3B
|
| 23 |
"model_id": PRIMARY_MODEL_ID,
|
| 24 |
},
|
| 25 |
-
"
|
| 26 |
-
"label": "
|
| 27 |
-
"model_id":
|
| 28 |
},
|
| 29 |
}
|
| 30 |
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
|
| 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 |
-
|
| 58 |
-
client: ChatClient | None = None,
|
| 59 |
) -> ModelAssistResult:
|
| 60 |
-
"""
|
| 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 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
self.assertEqual(memo["
|
| 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 |
-
|
| 58 |
|
| 59 |
assist = run_model_assist(
|
| 60 |
engine="nemotron",
|
| 61 |
result=result,
|
| 62 |
narrative_text=narrative,
|
| 63 |
-
|
| 64 |
)
|
| 65 |
|
| 66 |
self.assertEqual(assist.model_id, PRIMARY_MODEL_ID)
|
| 67 |
self.assertIn("upload-boundary", assist.memo["executive_memo"])
|
| 68 |
-
self.assertEqual(
|
| 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("
|
| 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:
|
| 89 |
|
| 90 |
-
def
|
| 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.
|
| 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 "—"
|