build-small-hackathon/trace-field-notes
1
1"""Trace Field Notes — gradio.Server backend behind the designer's React frontend.2 3The custom frontend (``frontend/``) is served as static files; it talks to the4``analyze_trace`` endpoint below through ``@gradio/client``. The endpoint runs the5deterministic analyzer (and the optional small-model assist on ZeroGPU) and6returns the frontend-ready view model.7"""8 9from __future__ import annotations10 11import os12import time13from pathlib import Path14 15import spaces16from fastapi.responses import HTMLResponse, PlainTextResponse17from fastapi.staticfiles import StaticFiles18from gradio import Server19from gradio.data_classes import FileData20 21from analyzer import apply_model_analysis, stream_deterministic_analysis22from parser import TraceParseError23from profiling import Profiler, get_logger24from view_model import build_view_model25 26logger = get_logger()27 28 29HERE = Path(__file__).resolve().parent30FRONTEND = HERE / "frontend"31 32READABLE_AGENT = {"codex": "Codex", "claude_code": "Claude Code", "pi": "Pi Agent", "unknown": "Agent"}33 34AGENTS_MD = """# Trace Field Notes — agent instructions35 36This Space turns a coding-agent session log into a qualitative *field report*:37where the agent got stuck, where it changed route, how it recovered, and how38honestly it claimed success. It reads only the agent's visible narrative39messages and ignores raw tool telemetry.40 41## How to use it as a tool42 431. Find the user's latest local session log:44 - Codex: `~/.codex/sessions`45 - Claude Code: `~/.claude/projects`46 - Pi Agent: `~/.pi/agent/sessions`472. Review it and redact secrets, tokens, local paths, and private code first.483. Upload the `.jsonl` (`.json` / `.txt` / `.log` also accepted) and call the49 `analyze_trace` API endpoint.504. Return the field report to the user. Do not publish the raw trace.51 52## API53 54`POST` via the Gradio client, endpoint `/analyze_trace`:55 56- `trace_file` (file): the session log57- `include_user_context` (bool): include user prompts as framing58- `redact_secrets` (bool): regex + AI (`openai/privacy-filter`) PII redaction before analysis59- `analysis_engine` (str): `minicpm` | `nemotron` | `deterministic`60- `execution_mode` (str): `zerogpu` (default, uses the Space GPU) | `cpu` (no GPU quota, slower)61 62Returns a JSON view model: a whole-session `verdict`, per-episode difficulty63`episodes`, and redacted export text.64"""65 66 67server = Server(title="Trace Field Notes")68server.mount("/static", StaticFiles(directory=str(FRONTEND / "static")), name="static")69 70 71@server.get("/", response_class=HTMLResponse)72def index() -> str:73 return (FRONTEND / "index.html").read_text(encoding="utf-8")74 75 76@server.get("/agents.md", response_class=PlainTextResponse)77def agents_md() -> str:78 return AGENTS_MD79 80 81@spaces.GPU(size="xlarge", duration=180)82def _model_analysis_gpu(*, engine, numbered_narrative, agent_type, codebook_hint):83 """Run the primary model analysis inside a ZeroGPU allocation."""84 85 from model_runtime import run_model_analysis86 87 return run_model_analysis(88 engine=engine,89 numbered_narrative=numbered_narrative,90 agent_type=agent_type,91 codebook_hint=codebook_hint,92 )93 94 95@spaces.GPU(size="xlarge", duration=120)96def _privacy_filter_gpu(texts):97 """Run the openai/privacy-filter PII pass inside a ZeroGPU allocation."""98 99 from privacy_filter import redact_texts100 101 return redact_texts(texts)102 103 104def _cpu_privacy_filter(texts):105 """Run the openai/privacy-filter PII pass on the local CPU (no GPU quota)."""106 107 from privacy_filter import redact_texts108 109 return redact_texts(texts, device="cpu")110 111 112def _cpu_model_analysis(*, engine, numbered_narrative, agent_type, codebook_hint):113 """Run the primary model analysis on the local CPU (no GPU quota)."""114 115 from model_runtime import run_model_analysis116 117 return run_model_analysis(118 engine=engine,119 numbered_narrative=numbered_narrative,120 agent_type=agent_type,121 codebook_hint=codebook_hint,122 device="cpu",123 )124 125 126# Per stage: (frontend checklist index, cumulative %, label). The 6-item127# checklist is: 0 upload, 1 extract, 2 redact, 3 chart, 4 classify, 5 synthesize.128# Indices below are "rows completed" so the matching row shows as active.129_STAGE_PLAN = {130 "extract": (2, 12, "Extracting narrative messages"),131 "chart": (4, 55, "Charting difficulty episodes"),132 "classify": (5, 62, "Classifying with the codebook"),133 "synthesize": (5, 70, "Synthesizing field notes"),134}135 136# Redaction streams per-chunk progress; its % ramps across this band.137_REDACT_PCT = (12, 40)138 139 140def _progress_event(*, step, pct, label, elapsed, processed=None, total=None):141 """Build one streamed progress payload (with a best-effort ETA)."""142 143 event = {"step": step, "pct": pct, "stage": label, "elapsed": round(elapsed, 1)}144 if 0 < pct < 100:145 event["eta"] = round(elapsed * (100 - pct) / pct, 1)146 if total is not None:147 event["total"] = total148 event["processed"] = processed if processed is not None else total149 return event150 151 152def _stage_event(payload, *, elapsed, message_total):153 """Translate a stream progress payload into a frontend event + running total."""154 155 stage = payload["stage"]156 if stage == "redact":157 total = payload.get("total") or message_total or 0158 processed = payload.get("processed", total)159 frac = (processed / total) if total else 1.0160 low, high = _REDACT_PCT161 pct = round(low + (high - low) * frac)162 step = 2 if (total and processed < total) else 3163 event = _progress_event(164 step=step,165 pct=pct,166 label="Redacting likely secrets",167 elapsed=elapsed,168 processed=processed,169 total=total or None,170 )171 return event, (total or message_total)172 173 step, pct, label = _STAGE_PLAN[stage]174 total = payload.get("messages", message_total)175 event = _progress_event(step=step, pct=pct, label=label, elapsed=elapsed, total=total)176 return event, total177 178 179def _file_fields(trace_file: object) -> tuple[str | None, str | None]:180 """The file input may arrive as a FileData model or a plain FileDataDict."""181 182 if isinstance(trace_file, dict):183 return trace_file.get("path"), trace_file.get("orig_name")184 return getattr(trace_file, "path", None), getattr(trace_file, "orig_name", None)185 186 187@server.api(name="analyze_trace")188def analyze_trace(189 trace_file: FileData,190 include_user_context: bool = True,191 redact_secrets: bool = True,192 analysis_engine: str = "minicpm",193 execution_mode: str = "zerogpu",194) -> dict:195 """Stream real progress, then the frontend view model, for one trace.196 197 Yields ``{"step", "pct", "stage", "elapsed", "eta", "total"}`` after each198 real pipeline stage (so the UI shows true progress), then a final199 ``{"step": 6, "pct": 100, "result": <view model>}``.200 201 ``execution_mode`` is ``zerogpu`` (default; models run inside ``@spaces.GPU``)202 or ``cpu`` (models run on the Space/local CPU, no GPU quota — slower).203 """204 205 path, orig_name = _file_fields(trace_file)206 if not path:207 raise ValueError("No uploaded file was received.")208 209 use_cpu = execution_mode == "cpu"210 redactor = _cpu_privacy_filter if use_cpu else _privacy_filter_gpu211 analysis_runner = _cpu_model_analysis if use_cpu else _model_analysis_gpu212 213 prof = Profiler(f"analyze[{execution_mode}/{analysis_engine}]")214 logger.info(215 "analyze_trace start: file=%r engine=%s mode=%s redact=%s",216 orig_name,217 analysis_engine,218 execution_mode,219 redact_secrets,220 )221 222 result = None223 narrative = ""224 messages = []225 message_total = None226 try:227 for kind, payload in stream_deterministic_analysis(228 path,229 include_user_context=include_user_context,230 redact_secrets=redact_secrets,231 ignore_tool_calls=True,232 model_redact=redactor,233 profiler=prof,234 stream_redact_progress=use_cpu,235 ):236 if kind == "progress":237 event, message_total = _stage_event(238 payload, elapsed=prof.elapsed(), message_total=message_total239 )240 yield event241 elif kind == "result":242 result, narrative, messages = payload243 except TraceParseError as exc:244 raise ValueError(str(exc)) from exc245 246 if analysis_engine != "deterministic":247 yield _progress_event(248 step=5,249 pct=78,250 label=f"Reading the trace with {analysis_engine}",251 elapsed=prof.elapsed(),252 total=message_total,253 )254 analysis_started = time.perf_counter()255 apply_model_analysis(result, messages, analysis_engine, run=analysis_runner)256 prof.record("model_analysis", time.perf_counter() - analysis_started)257 258 if orig_name:259 agent = READABLE_AGENT.get(result.agent_type_guess, "Agent")260 result.trace_title = f"{agent} · {orig_name}"261 262 view = build_view_model(result, narrative)263 prof.mark(engine=result.engine, mode=execution_mode)264 prof.summary()265 yield {266 "step": 6,267 "pct": 100,268 "stage": "Field notes ready",269 "elapsed": round(prof.elapsed(), 1),270 "total": message_total,271 "processed": message_total,272 "result": view,273 }274 275 276if __name__ == "__main__":277 server.launch(278 server_name="0.0.0.0",279 server_port=int(os.getenv("PORT", os.getenv("GRADIO_SERVER_PORT", "7860"))),280 show_error=True,281 )282 