JacobLinCool Codex commited on
Commit
c8055f7
·
verified ·
1 Parent(s): 2b6d074

feat: add small model assisted analysis

Browse files

Co-authored-by: Codex <noreply@openai.com>

Files changed (7) hide show
  1. README.md +16 -2
  2. analyzer.py +25 -0
  3. app.py +30 -2
  4. model_runtime.py +153 -0
  5. report_renderer.py +20 -0
  6. schemas.py +4 -0
  7. tests/test_model_runtime.py +74 -0
README.md CHANGED
@@ -18,9 +18,11 @@ telemetry by default and analyzes only the agent's visible narrative messages:
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 as a Gradio app. The stable MVP uses a
22
  verified deterministic codebook analyzer so the Space can always start and
23
- produce a report; the analysis schema is model-ready for small-model assistance.
 
 
24
 
25
  ## Run Locally
26
 
@@ -37,6 +39,18 @@ python app.py
37
  python3.11 -m unittest discover -s tests
38
  ```
39
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  ## Agent Session Locations
41
 
42
  ```bash
 
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 as a Gradio app. The default engine uses a
22
  verified deterministic codebook analyzer so the Space can always start and
23
+ produce a report. The app also exposes explicit small-model assist modes for
24
+ `nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16` and `Qwen/Qwen3.5-9B` through
25
+ Hugging Face Inference Providers when the runtime has provider access.
26
 
27
  ## Run Locally
28
 
 
39
  python3.11 -m unittest discover -s tests
40
  ```
41
 
42
+ ## Analysis Engines
43
+
44
+ - `Deterministic field notes`: default, local, no model dependency.
45
+ - `Small-model assist: NVIDIA Nemotron 3 Nano 30B-A3B`: uses the hackathon-sized
46
+ 30B total-parameter Nemotron model when Hugging Face Inference Providers can
47
+ serve it.
48
+ - `Quick small-model assist: Qwen3.5 9B`: optional lower-latency model-assisted
49
+ memo.
50
+
51
+ If a selected model is unavailable, the report records the error in model notes
52
+ and returns the deterministic analysis instead of failing the whole Space.
53
+
54
  ## Agent Session Locations
55
 
56
  ```bash
analyzer.py CHANGED
@@ -8,6 +8,7 @@ from datetime import datetime, timezone
8
  from pathlib import Path
9
  from typing import Iterable
10
 
 
11
  from parser import parse_trace
12
  from redaction import redact_text
13
  from schemas import AnalysisResult, DifficultyEpisode, MessageSpan, NarrativeMessage
@@ -117,6 +118,7 @@ def analyze_trace_file(
117
  redact_secrets: bool = True,
118
  ignore_tool_calls: bool = True,
119
  report_style: str = "field_notes",
 
120
  ) -> tuple[AnalysisResult, str]:
121
  """Parse, optionally redact, and analyze an uploaded trace file."""
122
 
@@ -178,6 +180,29 @@ def analyze_trace_file(
178
  engine="deterministic-codebook",
179
  )
180
  narrative_text = render_redacted_narrative(messages)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  return result, narrative_text
182
 
183
 
 
8
  from pathlib import Path
9
  from typing import Iterable
10
 
11
+ from model_runtime import MODEL_CHOICES, run_model_assist
12
  from parser import parse_trace
13
  from redaction import redact_text
14
  from schemas import AnalysisResult, DifficultyEpisode, MessageSpan, NarrativeMessage
 
118
  redact_secrets: bool = True,
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
 
 
180
  engine="deterministic-codebook",
181
  )
182
  narrative_text = render_redacted_narrative(messages)
183
+
184
+ if analysis_engine != "deterministic":
185
+ if analysis_engine not in MODEL_CHOICES:
186
+ result.model_notes.append(
187
+ f"Unknown analysis engine {analysis_engine!r}; deterministic analysis was returned."
188
+ )
189
+ else:
190
+ try:
191
+ assist = run_model_assist(
192
+ engine=analysis_engine,
193
+ result=result,
194
+ narrative_text=narrative_text,
195
+ )
196
+ except Exception as exc:
197
+ result.model_notes.append(
198
+ "Small-model assist was requested but unavailable: "
199
+ f"{type(exc).__name__}: {exc}. Deterministic analysis was returned."
200
+ )
201
+ else:
202
+ result.engine = f"deterministic-codebook + {assist.model_id}"
203
+ result.model_memo = assist.memo
204
+ result.model_notes.append(assist.note)
205
+
206
  return result, narrative_text
207
 
208
 
app.py CHANGED
@@ -10,6 +10,7 @@ from typing import Any
10
  import gradio as gr
11
 
12
  from analyzer import analyze_trace_file
 
13
  from parser import TraceParseError
14
  from report_renderer import render_report
15
 
@@ -99,6 +100,7 @@ def analyze_trace(
99
  redact_secrets: bool = True,
100
  ignore_tool_calls: bool = True,
101
  report_style: str = "field_notes",
 
102
  ) -> tuple[str, dict[str, Any], str, str, str]:
103
  """Gradio-callable analysis endpoint."""
104
 
@@ -113,6 +115,7 @@ def analyze_trace(
113
  redact_secrets=redact_secrets,
114
  ignore_tool_calls=ignore_tool_calls,
115
  report_style=report_style,
 
116
  )
117
  except TraceParseError as exc:
118
  raise gr.Error(str(exc)) from exc
@@ -194,6 +197,14 @@ with gr.Blocks(
194
  label="Report style",
195
  interactive=False,
196
  )
 
 
 
 
 
 
 
 
197
  analyze_button = gr.Button("Analyze My Trace", variant="primary")
198
  with gr.Column(scale=2):
199
  gr.Markdown(SESSION_PATHS_MD)
@@ -208,8 +219,24 @@ with gr.Blocks(
208
  )
209
 
210
  gr.Examples(
211
- examples=[["examples/sample_trace_redacted.jsonl", True, True, True, "field_notes"]],
212
- inputs=[trace_input, include_user_context, redact_secrets, ignore_tool_calls, report_style],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  label="Try a redacted sample trace",
214
  )
215
 
@@ -229,6 +256,7 @@ with gr.Blocks(
229
  redact_secrets,
230
  ignore_tool_calls,
231
  report_style,
 
232
  ],
233
  outputs=[
234
  report_output,
 
10
  import gradio as gr
11
 
12
  from analyzer import analyze_trace_file
13
+ from model_runtime import MODEL_CHOICES
14
  from parser import TraceParseError
15
  from report_renderer import render_report
16
 
 
100
  redact_secrets: bool = True,
101
  ignore_tool_calls: bool = True,
102
  report_style: str = "field_notes",
103
+ analysis_engine: str = "deterministic",
104
  ) -> tuple[str, dict[str, Any], str, str, str]:
105
  """Gradio-callable analysis endpoint."""
106
 
 
115
  redact_secrets=redact_secrets,
116
  ignore_tool_calls=ignore_tool_calls,
117
  report_style=report_style,
118
+ analysis_engine=analysis_engine,
119
  )
120
  except TraceParseError as exc:
121
  raise gr.Error(str(exc)) from exc
 
197
  label="Report style",
198
  interactive=False,
199
  )
200
+ analysis_engine = gr.Radio(
201
+ choices=[
202
+ (str(choice["label"]), key)
203
+ for key, choice in MODEL_CHOICES.items()
204
+ ],
205
+ value="deterministic",
206
+ label="Analysis engine",
207
+ )
208
  analyze_button = gr.Button("Analyze My Trace", variant="primary")
209
  with gr.Column(scale=2):
210
  gr.Markdown(SESSION_PATHS_MD)
 
219
  )
220
 
221
  gr.Examples(
222
+ examples=[
223
+ [
224
+ "examples/sample_trace_redacted.jsonl",
225
+ True,
226
+ True,
227
+ True,
228
+ "field_notes",
229
+ "deterministic",
230
+ ]
231
+ ],
232
+ inputs=[
233
+ trace_input,
234
+ include_user_context,
235
+ redact_secrets,
236
+ ignore_tool_calls,
237
+ report_style,
238
+ analysis_engine,
239
+ ],
240
  label="Try a redacted sample trace",
241
  )
242
 
 
256
  redact_secrets,
257
  ignore_tool_calls,
258
  report_style,
259
+ analysis_engine,
260
  ],
261
  outputs=[
262
  report_output,
model_runtime.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional small-model assistance through Hugging Face Inference Providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass
8
+ from typing import Any, Protocol
9
+
10
+ from schemas import AnalysisResult
11
+
12
+
13
+ PRIMARY_MODEL_ID = "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16"
14
+ QUICK_MODEL_ID = "Qwen/Qwen3.5-9B"
15
+
16
+ MODEL_CHOICES = {
17
+ "deterministic": {
18
+ "label": "Deterministic field notes",
19
+ "model_id": None,
20
+ },
21
+ "nemotron": {
22
+ "label": "Small-model assist: NVIDIA Nemotron 3 Nano 30B-A3B",
23
+ "model_id": PRIMARY_MODEL_ID,
24
+ },
25
+ "qwen": {
26
+ "label": "Quick small-model assist: Qwen3.5 9B",
27
+ "model_id": QUICK_MODEL_ID,
28
+ },
29
+ }
30
+
31
+
32
+ class ChatClient(Protocol):
33
+ def chat_completion(self, *args: Any, **kwargs: Any) -> Any:
34
+ ...
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class ModelAssistResult:
39
+ model_id: str
40
+ memo: dict[str, Any]
41
+ note: str
42
+
43
+
44
+ def model_id_for_engine(engine: str) -> str | None:
45
+ choice = MODEL_CHOICES.get(engine)
46
+ if not choice:
47
+ return None
48
+ model_id = choice["model_id"]
49
+ return str(model_id) if model_id else None
50
+
51
+
52
+ def run_model_assist(
53
+ *,
54
+ engine: str,
55
+ result: AnalysisResult,
56
+ narrative_text: str,
57
+ client: ChatClient | None = None,
58
+ ) -> ModelAssistResult:
59
+ """Ask the selected small model for a concise memo grounded in visible text."""
60
+
61
+ model_id = model_id_for_engine(engine)
62
+ if not model_id:
63
+ raise ValueError(f"No model is configured for analysis engine {engine!r}.")
64
+
65
+ prompt = build_model_prompt(result, narrative_text)
66
+ if client is None:
67
+ from huggingface_hub import InferenceClient
68
+
69
+ inference_client = InferenceClient(
70
+ model=model_id,
71
+ provider=os.getenv("TRACE_FIELD_NOTES_INFERENCE_PROVIDER") or None,
72
+ token=os.getenv("HF_TOKEN") or None,
73
+ timeout=float(os.getenv("TRACE_FIELD_NOTES_MODEL_TIMEOUT", "45")),
74
+ )
75
+ else:
76
+ inference_client = client
77
+ response = inference_client.chat_completion(
78
+ messages=[
79
+ {
80
+ "role": "system",
81
+ "content": (
82
+ "You analyze visible coding-agent narrative messages. "
83
+ "Do not infer hidden reasoning. Return JSON only."
84
+ ),
85
+ },
86
+ {"role": "user", "content": prompt},
87
+ ],
88
+ model=model_id,
89
+ max_tokens=900,
90
+ temperature=0.2,
91
+ response_format={"type": "json_object"},
92
+ )
93
+ content = extract_chat_content(response)
94
+ memo = parse_model_json(content)
95
+ return ModelAssistResult(
96
+ model_id=model_id,
97
+ memo=memo,
98
+ note=f"Small-model assist completed with {model_id}.",
99
+ )
100
+
101
+
102
+ def build_model_prompt(result: AnalysisResult, narrative_text: str) -> str:
103
+ deterministic_json = json.dumps(result.to_dict(), ensure_ascii=False, indent=2)
104
+ narrative_excerpt = narrative_text[:12000]
105
+ return f"""Use the deterministic codebook analysis and redacted visible narrative below.
106
+
107
+ Return JSON with exactly these keys:
108
+ - executive_memo: 4-6 sentences for a developer
109
+ - detour_memo: 2-4 sentences about productive detours vs wandering
110
+ - outcome_audit_memo: 2-4 sentences about completion claims and caveats
111
+ - caveats: array of short strings
112
+
113
+ Rules:
114
+ - Analyze only visible narrative messages.
115
+ - Do not claim to know hidden reasoning.
116
+ - Cite episode IDs where useful.
117
+ - Do not include raw secrets, tool outputs, or long quotes.
118
+
119
+ Deterministic analysis:
120
+ {deterministic_json}
121
+
122
+ Redacted narrative excerpt:
123
+ {narrative_excerpt}
124
+ """
125
+
126
+
127
+ def extract_chat_content(response: Any) -> str:
128
+ try:
129
+ content = response.choices[0].message.content
130
+ except (AttributeError, IndexError, TypeError) as exc:
131
+ raise ValueError("Model response did not contain chat completion content.") from exc
132
+ if not isinstance(content, str) or not content.strip():
133
+ raise ValueError("Model response content was empty.")
134
+ return content
135
+
136
+
137
+ def parse_model_json(content: str) -> dict[str, Any]:
138
+ try:
139
+ parsed = json.loads(content)
140
+ except json.JSONDecodeError as exc:
141
+ raise ValueError("Model response was not valid JSON.") from exc
142
+
143
+ required = {
144
+ "executive_memo": str,
145
+ "detour_memo": str,
146
+ "outcome_audit_memo": str,
147
+ "caveats": list,
148
+ }
149
+ for key, expected_type in required.items():
150
+ if key not in parsed or not isinstance(parsed[key], expected_type):
151
+ raise ValueError(f"Model response missing {key!r} as {expected_type.__name__}.")
152
+ parsed["caveats"] = [str(item) for item in parsed["caveats"][:6]]
153
+ return parsed
report_renderer.py CHANGED
@@ -26,6 +26,7 @@ def render_report(result: AnalysisResult) -> str:
26
  sections = [
27
  render_header(result),
28
  render_executive_summary(result),
 
29
  render_timeline(result.episodes),
30
  render_difficulty_map(result.episodes),
31
  render_detour_analysis(result.episodes),
@@ -71,6 +72,25 @@ def render_executive_summary(result: AnalysisResult) -> str:
71
  )
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def render_timeline(episodes: list[DifficultyEpisode]) -> str:
75
  if not episodes:
76
  return "## Journey Timeline\n\nNo difficulty timeline was detected."
 
26
  sections = [
27
  render_header(result),
28
  render_executive_summary(result),
29
+ render_model_memo(result),
30
  render_timeline(result.episodes),
31
  render_difficulty_map(result.episodes),
32
  render_detour_analysis(result.episodes),
 
72
  )
73
 
74
 
75
+ def render_model_memo(result: AnalysisResult) -> str:
76
+ if not result.model_memo and not result.model_notes:
77
+ return ""
78
+
79
+ lines = ["## Small-Model Memo"]
80
+ if result.model_memo:
81
+ lines.append(result.model_memo.get("executive_memo", ""))
82
+ lines.append(f"**Detours:** {result.model_memo.get('detour_memo', '')}")
83
+ lines.append(f"**Outcome audit:** {result.model_memo.get('outcome_audit_memo', '')}")
84
+ caveats = result.model_memo.get("caveats") or []
85
+ if caveats:
86
+ lines.append("**Model caveats:**")
87
+ lines.extend(f"- {caveat}" for caveat in caveats)
88
+ if result.model_notes:
89
+ lines.append("**Model notes:**")
90
+ lines.extend(f"- {note}" for note in result.model_notes)
91
+ return "\n\n".join(line for line in lines if line)
92
+
93
+
94
  def render_timeline(episodes: list[DifficultyEpisode]) -> str:
95
  if not episodes:
96
  return "## Journey Timeline\n\nNo difficulty timeline was detected."
schemas.py CHANGED
@@ -147,6 +147,8 @@ class AnalysisResult:
147
  narrative_message_count: int
148
  redaction_count: int = 0
149
  engine: str = "deterministic-codebook"
 
 
150
 
151
  def to_dict(self) -> dict[str, Any]:
152
  return {
@@ -159,4 +161,6 @@ class AnalysisResult:
159
  "narrative_message_count": self.narrative_message_count,
160
  "redaction_count": self.redaction_count,
161
  "engine": self.engine,
 
 
162
  }
 
147
  narrative_message_count: int
148
  redaction_count: int = 0
149
  engine: str = "deterministic-codebook"
150
+ model_notes: list[str] = field(default_factory=list)
151
+ model_memo: dict[str, Any] = field(default_factory=dict)
152
 
153
  def to_dict(self) -> dict[str, Any]:
154
  return {
 
161
  "narrative_message_count": self.narrative_message_count,
162
  "redaction_count": self.redaction_count,
163
  "engine": self.engine,
164
+ "model_notes": self.model_notes,
165
+ "model_memo": self.model_memo,
166
  }
tests/test_model_runtime.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import types
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+ from analyzer import analyze_trace_file
9
+ from model_runtime import PRIMARY_MODEL_ID, parse_model_json, run_model_assist
10
+
11
+
12
+ class FakeChatClient:
13
+ def chat_completion(self, *args, **kwargs):
14
+ self.kwargs = kwargs
15
+ content = json.dumps(
16
+ {
17
+ "executive_memo": "The trace shows a visible upload-boundary correction.",
18
+ "detour_memo": "E01 narrows scope instead of changing the parser.",
19
+ "outcome_audit_memo": "The agent keeps a deployment caveat visible.",
20
+ "caveats": ["Model memo is based only on redacted narrative."],
21
+ }
22
+ )
23
+ return types.SimpleNamespace(
24
+ choices=[
25
+ types.SimpleNamespace(
26
+ message=types.SimpleNamespace(content=content),
27
+ )
28
+ ]
29
+ )
30
+
31
+
32
+ class ModelRuntimeTests(unittest.TestCase):
33
+ def test_parse_model_json_validates_required_shape(self) -> None:
34
+ memo = parse_model_json(
35
+ json.dumps(
36
+ {
37
+ "executive_memo": "summary",
38
+ "detour_memo": "detour",
39
+ "outcome_audit_memo": "audit",
40
+ "caveats": ["one"],
41
+ }
42
+ )
43
+ )
44
+
45
+ self.assertEqual(memo["executive_memo"], "summary")
46
+ self.assertEqual(memo["caveats"], ["one"])
47
+
48
+ def test_run_model_assist_uses_selected_model(self) -> None:
49
+ result, narrative = analyze_trace_file(Path("examples/sample_trace_redacted.jsonl"))
50
+ client = FakeChatClient()
51
+
52
+ assist = run_model_assist(
53
+ engine="nemotron",
54
+ result=result,
55
+ narrative_text=narrative,
56
+ client=client,
57
+ )
58
+
59
+ self.assertEqual(assist.model_id, PRIMARY_MODEL_ID)
60
+ self.assertIn("upload-boundary", assist.memo["executive_memo"])
61
+ self.assertEqual(client.kwargs["model"], PRIMARY_MODEL_ID)
62
+
63
+ def test_analyzer_records_unknown_engine_note(self) -> None:
64
+ result, _ = analyze_trace_file(
65
+ Path("examples/sample_trace_redacted.jsonl"),
66
+ analysis_engine="missing-engine",
67
+ )
68
+
69
+ self.assertTrue(result.model_notes)
70
+ self.assertIn("Unknown analysis engine", result.model_notes[0])
71
+
72
+
73
+ if __name__ == "__main__":
74
+ unittest.main()