JacobLinCool Codex commited on
Commit
8fb1ae9
·
verified ·
1 Parent(s): 9219266

feat: export shareable agent traces

Browse files

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

README.md CHANGED
@@ -52,11 +52,17 @@ Then open <http://127.0.0.1:7860>.
52
  ```bash
53
  python scripts/crawl_hf_spaces.py --org build-small-hackathon --out data/projects.json
54
  python scripts/build_project_index.py --projects data/projects.json --out data/project_index.json
 
55
  ```
56
 
57
  The app uses `data/projects.json` and `data/project_index.json` at runtime. The index validates the snapshot timestamp,
58
  source, project order, and digest before the app starts.
59
 
 
 
 
 
 
60
  ## Test
61
 
62
  ```bash
 
52
  ```bash
53
  python scripts/crawl_hf_spaces.py --org build-small-hackathon --out data/projects.json
54
  python scripts/build_project_index.py --projects data/projects.json --out data/project_index.json
55
+ python scripts/generate_sample_trace.py --projects data/projects.json --index data/project_index.json --out data/sample_trace.jsonl
56
  ```
57
 
58
  The app uses `data/projects.json` and `data/project_index.json` at runtime. The index validates the snapshot timestamp,
59
  source, project order, and digest before the app starts.
60
 
61
+ ## Trace Artifact
62
+
63
+ The app exposes a `trace_artifact` Gradio API endpoint and a `JSONL` button in the UI. Both emit the same JSONL schema:
64
+ a manifest row followed by one row per agent turn. `data/sample_trace.jsonl` is a checked-in, Hub-published sample trace.
65
+
66
  ## Test
67
 
68
  ```bash
app.py CHANGED
@@ -10,6 +10,7 @@ from gradio import Server
10
 
11
  from hackathon_advisor.agent import AdvisorEngine
12
  from hackathon_advisor.data import ProjectIndex
 
13
 
14
 
15
  ROOT = Path(__file__).parent
@@ -44,10 +45,7 @@ def health() -> dict:
44
  return {
45
  "ok": True,
46
  "projects": len(index.projects),
47
- "snapshot_generated_at": index.generated_at,
48
- "index_generated_at": index.index_generated_at,
49
- "index_algorithm": index.index_algorithm,
50
- "snapshot_digest": index.snapshot_digest,
51
  }
52
 
53
 
@@ -55,15 +53,21 @@ def health() -> dict:
55
  def bootstrap() -> dict:
56
  return {
57
  "project_count": len(index.projects),
58
- "snapshot_generated_at": index.generated_at,
59
- "index_generated_at": index.index_generated_at,
60
- "index_algorithm": index.index_algorithm,
61
- "snapshot_digest": index.snapshot_digest,
62
  "top_projects": [project.to_public_dict() for project in index.top_projects(limit=8)],
63
  "whitespace": [item.to_dict() for item in index.find_whitespace(limit=5)],
64
  }
65
 
66
 
 
 
 
 
 
 
 
 
 
67
  @app.api(name="agent_turn", concurrency_limit=4, stream_every=0.04)
68
  def agent_turn(message: str, session_json: str = "{}") -> Iterator[str]:
69
  try:
 
10
 
11
  from hackathon_advisor.agent import AdvisorEngine
12
  from hackathon_advisor.data import ProjectIndex
13
+ from hackathon_advisor.trace_export import build_trace_jsonl, trace_metadata
14
 
15
 
16
  ROOT = Path(__file__).parent
 
45
  return {
46
  "ok": True,
47
  "projects": len(index.projects),
48
+ **trace_metadata(index),
 
 
 
49
  }
50
 
51
 
 
53
  def bootstrap() -> dict:
54
  return {
55
  "project_count": len(index.projects),
56
+ **trace_metadata(index),
 
 
 
57
  "top_projects": [project.to_public_dict() for project in index.top_projects(limit=8)],
58
  "whitespace": [item.to_dict() for item in index.find_whitespace(limit=5)],
59
  }
60
 
61
 
62
+ @app.api(name="trace_artifact", concurrency_limit=8)
63
+ def trace_artifact(session_json: str = "{}") -> str:
64
+ try:
65
+ session = json.loads(session_json or "{}")
66
+ except json.JSONDecodeError:
67
+ session = {}
68
+ return build_trace_jsonl(session, trace_metadata(index))
69
+
70
+
71
  @app.api(name="agent_turn", concurrency_limit=4, stream_every=0.04)
72
  def agent_turn(message: str, session_json: str = "{}") -> Iterator[str]:
73
  try:
data/sample_trace.jsonl ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {"app": "hackathon-advisor", "generated_at": "2026-06-06T19:52:06+00:00", "idea_count": 1, "index": {"algorithm": "tfidf-sparse-v1", "index_generated_at": "2026-06-06T19:44:42+00:00", "snapshot_digest": "1d59c168b446bc1268ef648897fac523e85b258fd6048a37409b9cfbc5c28329", "snapshot_generated_at": "2026-06-06T19:20:47+00:00"}, "schema_version": 1, "turn_count": 3, "type": "trace_manifest"}
2
+ {"artifact_title": "A local-first archive cartographer for family photos", "input": "A local-first archive cartographer for family photos", "overall": 4.9, "plan_steps": 0, "response": "The ink bleeds around A local-first archive cartographer for family photos. Closest echoes: page 1: Family Bill Assistant; page 2: Kasualdad LFED; page 3: Mycelium. The seal reads ECHO x4 at 4.9/10. Keep the audience, but change the mechanism or artifact so the demo proves a gap instead of joining a cluster.", "schema_version": 1, "tools": [{"name": "save_idea", "summary": "Wrote idea page 'A local-first archive cartographer for family photos'."}, {"name": "search_projects", "summary": "Checked 5 closest project echoes."}, {"name": "score_idea", "summary": "Pressed a five-quadrant seal: 4.9/10."}], "turn_index": 1, "type": "agent_turn", "verdict": "ECHO x4"}
3
+ {"artifact_title": "Hands-on science coach", "input": "write bolder and find whitespace", "overall": 6.0, "plan_steps": 0, "response": "Gold gathers on Hands-on science coach. A lab-notebook companion that designs safe experiments from household materials. No close project echoes in the current snapshot. The seal reads UNWRITTEN at 6.0/10. The next move is to make one concrete before/after scene and cite the two weakest nearby echoes in the margin.", "schema_version": 1, "tools": [{"name": "find_whitespace", "summary": "Ranked 4 under-explored regions."}, {"name": "save_idea", "summary": "Wrote idea page 'Hands-on science coach'."}, {"name": "score_idea", "summary": "Pressed a five-quadrant seal: 4.8/10."}], "turn_index": 2, "type": "agent_turn", "verdict": "UNWRITTEN"}
4
+ {"artifact_title": "Hands-on science coach", "input": "make a build plan", "overall": 6.0, "plan_steps": 6, "response": "Mothback presses the wax for Hands-on science coach: 6.0/10, UNWRITTEN. The build path is: 1. Lock a one-sentence promise and one demo input that proves originality. 2. Refresh the Space snapshot, then tune the bleed threshold against the closest echoes. 3. Build the smallest happy path: input, citations, score seal, and shareable artifact. 4. Add one prize ", "schema_version": 1, "tools": [{"name": "score_idea", "summary": "Pressed a five-quadrant seal: 4.8/10."}, {"name": "make_plan", "summary": "Drafted 6 build steps."}], "turn_index": 3, "type": "agent_turn", "verdict": "UNWRITTEN"}
hackathon_advisor/agent.py CHANGED
@@ -74,6 +74,8 @@ class AdvisorEngine:
74
  idea = self._current_idea(state)
75
  if idea is not None:
76
  score, event = self.tools.score_idea(idea)
 
 
77
  self._store_idea(state, idea)
78
  tool_events.append(event)
79
  plan, event = self.tools.make_plan(idea)
@@ -93,29 +95,17 @@ class AdvisorEngine:
93
  artifact,
94
  )
95
 
96
- title, pitch = idea_from_text(normalized)
97
- idea, event = self.tools.save_idea(state, title, pitch)
98
- tool_events.append(event)
99
-
100
- if PLAN_RE.search(normalized):
101
- score, event = self.tools.score_idea(idea)
102
- self._store_idea(state, idea)
103
- tool_events.append(event)
104
- plan, event = self.tools.make_plan(idea)
105
- tool_events.append(event)
106
- response = self._plan_response(idea, score, plan)
107
- artifact = self._artifact(idea, score)
108
- return self._result(normalized, corrections, response, state, tool_events, [], [], score, plan, artifact)
109
-
110
  if WHITESPACE_RE.search(normalized):
111
  whitespace, event = self.tools.find_whitespace(limit=4)
112
  tool_events.append(event)
113
  if whitespace:
114
- idea.title = whitespace[0].label
115
- idea.pitch = whitespace[0].pitch
116
- state["ideas"] = [
117
- idea.to_dict() if item.get("id") == idea.id else item for item in state.get("ideas", [])
118
- ]
 
 
119
  score, event = self.tools.score_idea(idea)
120
  if whitespace:
121
  score = self._align_score_with_whitespace(score, whitespace[0])
@@ -137,6 +127,20 @@ class AdvisorEngine:
137
  artifact,
138
  )
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  hits = self.index.search(normalized, limit=5)
141
  projects = [hit.project for hit in hits]
142
  tool_events.append(ToolEvent("search_projects", f"Checked {len(projects)} closest project echoes."))
@@ -241,6 +245,12 @@ class AdvisorEngine:
241
  verdict="UNWRITTEN",
242
  )
243
 
 
 
 
 
 
 
244
  def _opening_response(self, projects: list[Project]) -> str:
245
  names = ", ".join(project.title for project in projects[:4])
246
  return (
 
74
  idea = self._current_idea(state)
75
  if idea is not None:
76
  score, event = self.tools.score_idea(idea)
77
+ score = self._align_score_from_state(score, idea, state)
78
+ idea.score = score.to_dict()
79
  self._store_idea(state, idea)
80
  tool_events.append(event)
81
  plan, event = self.tools.make_plan(idea)
 
95
  artifact,
96
  )
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  if WHITESPACE_RE.search(normalized):
99
  whitespace, event = self.tools.find_whitespace(limit=4)
100
  tool_events.append(event)
101
  if whitespace:
102
+ idea, event = self.tools.save_idea(state, whitespace[0].label, whitespace[0].pitch)
103
+ tool_events.append(event)
104
+ state["current_whitespace"] = whitespace[0].to_dict()
105
+ else:
106
+ title, pitch = idea_from_text(normalized)
107
+ idea, event = self.tools.save_idea(state, title, pitch)
108
+ tool_events.append(event)
109
  score, event = self.tools.score_idea(idea)
110
  if whitespace:
111
  score = self._align_score_with_whitespace(score, whitespace[0])
 
127
  artifact,
128
  )
129
 
130
+ title, pitch = idea_from_text(normalized)
131
+ idea, event = self.tools.save_idea(state, title, pitch)
132
+ tool_events.append(event)
133
+
134
+ if PLAN_RE.search(normalized):
135
+ score, event = self.tools.score_idea(idea)
136
+ self._store_idea(state, idea)
137
+ tool_events.append(event)
138
+ plan, event = self.tools.make_plan(idea)
139
+ tool_events.append(event)
140
+ response = self._plan_response(idea, score, plan)
141
+ artifact = self._artifact(idea, score)
142
+ return self._result(normalized, corrections, response, state, tool_events, [], [], score, plan, artifact)
143
+
144
  hits = self.index.search(normalized, limit=5)
145
  projects = [hit.project for hit in hits]
146
  tool_events.append(ToolEvent("search_projects", f"Checked {len(projects)} closest project echoes."))
 
245
  verdict="UNWRITTEN",
246
  )
247
 
248
+ def _align_score_from_state(self, score: ScoreCard, idea: Idea, state: dict[str, Any]) -> ScoreCard:
249
+ artifact = state.get("last_artifact") or {}
250
+ if artifact.get("title") == idea.title and artifact.get("verdict") == "UNWRITTEN":
251
+ return replace(score, originality=max(score.originality, 8), verdict="UNWRITTEN")
252
+ return score
253
+
254
  def _opening_response(self, projects: list[Project]) -> str:
255
  names = ", ".join(project.title for project in projects[:4])
256
  return (
hackathon_advisor/trace_export.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ import json
5
+ from typing import Any
6
+
7
+
8
+ TRACE_SCHEMA_VERSION = 1
9
+
10
+
11
+ def build_trace_jsonl(session: dict[str, Any], metadata: dict[str, Any]) -> str:
12
+ trace = session.get("trace") or []
13
+ ideas = session.get("ideas") or []
14
+ records = [
15
+ {
16
+ "type": "trace_manifest",
17
+ "schema_version": TRACE_SCHEMA_VERSION,
18
+ "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
19
+ "app": "hackathon-advisor",
20
+ "index": {
21
+ "algorithm": metadata["index_algorithm"],
22
+ "snapshot_generated_at": metadata["snapshot_generated_at"],
23
+ "index_generated_at": metadata["index_generated_at"],
24
+ "snapshot_digest": metadata["snapshot_digest"],
25
+ },
26
+ "idea_count": len(ideas),
27
+ "turn_count": len(trace),
28
+ }
29
+ ]
30
+ for index, event in enumerate(trace, start=1):
31
+ records.append(
32
+ {
33
+ "type": "agent_turn",
34
+ "schema_version": TRACE_SCHEMA_VERSION,
35
+ "turn_index": index,
36
+ "input": str(event.get("input") or ""),
37
+ "tools": _tools(event),
38
+ "verdict": str(event.get("verdict") or ""),
39
+ "overall": event.get("overall"),
40
+ "plan_steps": int(event.get("plan_steps") or 0),
41
+ "artifact_title": str(event.get("artifact_title") or ""),
42
+ "response": str(event.get("response") or ""),
43
+ }
44
+ )
45
+ return "\n".join(json.dumps(record, ensure_ascii=False, sort_keys=True) for record in records) + "\n"
46
+
47
+
48
+ def trace_metadata(index: Any) -> dict[str, str]:
49
+ return {
50
+ "snapshot_generated_at": index.generated_at,
51
+ "index_generated_at": index.index_generated_at,
52
+ "index_algorithm": index.index_algorithm,
53
+ "snapshot_digest": index.snapshot_digest,
54
+ }
55
+
56
+
57
+ def _tools(event: dict[str, Any]) -> list[dict[str, str]]:
58
+ tools = event.get("tools") or []
59
+ return [
60
+ {
61
+ "name": str(tool.get("name") or ""),
62
+ "summary": str(tool.get("summary") or ""),
63
+ }
64
+ for tool in tools
65
+ if isinstance(tool, dict)
66
+ ]
scripts/generate_sample_trace.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ sys.path.insert(0, str(ROOT))
10
+
11
+ from hackathon_advisor.agent import AdvisorEngine
12
+ from hackathon_advisor.data import ProjectIndex
13
+ from hackathon_advisor.trace_export import build_trace_jsonl, trace_metadata
14
+
15
+
16
+ SAMPLE_TURNS = (
17
+ "A local-first archive cartographer for family photos",
18
+ "write bolder and find whitespace",
19
+ "make a build plan",
20
+ )
21
+
22
+
23
+ def main() -> None:
24
+ parser = argparse.ArgumentParser(description="Generate a publishable sample agent trace JSONL.")
25
+ parser.add_argument("--projects", default="data/projects.json")
26
+ parser.add_argument("--index", default="data/project_index.json")
27
+ parser.add_argument("--out", default="data/sample_trace.jsonl")
28
+ args = parser.parse_args()
29
+
30
+ index = ProjectIndex.from_files(Path(args.projects), Path(args.index))
31
+ engine = AdvisorEngine(index)
32
+ state = {}
33
+ for turn in SAMPLE_TURNS:
34
+ state = engine.turn(turn, state).state
35
+
36
+ output = Path(args.out)
37
+ output.parent.mkdir(parents=True, exist_ok=True)
38
+ output.write_text(build_trace_jsonl(state, trace_metadata(index)), encoding="utf-8")
39
+ print(f"wrote {len(state.get('trace', []))} turns to {output}")
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
static/app.js CHANGED
@@ -15,6 +15,7 @@ const provenanceEl = document.querySelector("#provenance");
15
  const verdictEl = document.querySelector("#verdict");
16
  const overallEl = document.querySelector("#overall");
17
  const exportButton = document.querySelector("#export-artifact");
 
18
 
19
  let session = {};
20
  let clientPromise = Client.connect(window.location.origin);
@@ -40,6 +41,10 @@ exportButton.addEventListener("click", () => {
40
  exportArtifact(currentArtifact);
41
  });
42
 
 
 
 
 
43
  async function runTurn(message) {
44
  input.value = "";
45
  submit.disabled = true;
@@ -125,6 +130,7 @@ function handleEvent(event) {
125
  currentArtifact = event.artifact;
126
  exportButton.disabled = false;
127
  }
 
128
  }
129
  }
130
 
@@ -238,8 +244,19 @@ function renderTrace(trace) {
238
 
239
  function setCommandDisabled(disabled) {
240
  document.querySelectorAll(".command-row button").forEach((button) => {
241
- button.disabled = disabled || (button.id === "export-artifact" && !currentArtifact);
 
 
 
 
 
 
 
 
 
242
  });
 
 
243
  }
244
 
245
  function exportArtifact(artifact) {
@@ -297,6 +314,15 @@ function exportArtifact(artifact) {
297
  link.click();
298
  }
299
 
 
 
 
 
 
 
 
 
 
300
  function drawParchment(ctx, width, height) {
301
  const gradient = ctx.createLinearGradient(0, 0, width, height);
302
  gradient.addColorStop(0, "#ead7a7");
 
15
  const verdictEl = document.querySelector("#verdict");
16
  const overallEl = document.querySelector("#overall");
17
  const exportButton = document.querySelector("#export-artifact");
18
+ const exportTraceButton = document.querySelector("#export-trace");
19
 
20
  let session = {};
21
  let clientPromise = Client.connect(window.location.origin);
 
41
  exportArtifact(currentArtifact);
42
  });
43
 
44
+ exportTraceButton.addEventListener("click", async () => {
45
+ await exportTrace();
46
+ });
47
+
48
  async function runTurn(message) {
49
  input.value = "";
50
  submit.disabled = true;
 
130
  currentArtifact = event.artifact;
131
  exportButton.disabled = false;
132
  }
133
+ exportTraceButton.disabled = !(session.trace?.length);
134
  }
135
  }
136
 
 
244
 
245
  function setCommandDisabled(disabled) {
246
  document.querySelectorAll(".command-row button").forEach((button) => {
247
+ const isArtifact = button.id === "export-artifact";
248
+ const isTrace = button.id === "export-trace";
249
+ button.disabled = disabled || (isArtifact && !currentArtifact) || (isTrace && !session.trace?.length);
250
+ });
251
+ }
252
+
253
+ async function exportTrace() {
254
+ const client = await clientPromise;
255
+ const result = await client.predict("/trace_artifact", {
256
+ session_json: JSON.stringify(session),
257
  });
258
+ const data = Array.isArray(result.data) ? result.data[0] : result.data;
259
+ downloadText("hackathon-advisor-trace.jsonl", String(data || ""));
260
  }
261
 
262
  function exportArtifact(artifact) {
 
314
  link.click();
315
  }
316
 
317
+ function downloadText(filename, text) {
318
+ const blob = new Blob([text], { type: "application/jsonl;charset=utf-8" });
319
+ const link = document.createElement("a");
320
+ link.download = filename;
321
+ link.href = URL.createObjectURL(blob);
322
+ link.click();
323
+ setTimeout(() => URL.revokeObjectURL(link.href), 0);
324
+ }
325
+
326
  function drawParchment(ctx, width, height) {
327
  const gradient = ctx.createLinearGradient(0, 0, width, height);
328
  gradient.addColorStop(0, "#ead7a7");
static/index.html CHANGED
@@ -32,6 +32,7 @@
32
  </button>
33
  <button type="button" data-command="make a build plan" title="Draft a build plan">Plan</button>
34
  <button type="button" data-command="compare ideas" title="Compare the idea board">Rank</button>
 
35
  <button type="button" id="export-artifact" title="Export the current fate page" disabled>PNG</button>
36
  </div>
37
  <div id="corrections" class="corrections" aria-live="polite"></div>
 
32
  </button>
33
  <button type="button" data-command="make a build plan" title="Draft a build plan">Plan</button>
34
  <button type="button" data-command="compare ideas" title="Compare the idea board">Rank</button>
35
+ <button type="button" id="export-trace" title="Export the tool trace" disabled>JSONL</button>
36
  <button type="button" id="export-artifact" title="Export the current fate page" disabled>PNG</button>
37
  </div>
38
  <div id="corrections" class="corrections" aria-live="polite"></div>
static/styles.css CHANGED
@@ -167,7 +167,7 @@ button:disabled {
167
 
168
  .command-row {
169
  display: grid;
170
- grid-template-columns: repeat(4, minmax(0, 1fr));
171
  gap: 8px;
172
  margin-top: 10px;
173
  }
@@ -371,7 +371,7 @@ button:disabled {
371
  }
372
 
373
  .command-row {
374
- grid-template-columns: repeat(2, minmax(0, 1fr));
375
  }
376
 
377
  .score-row {
 
167
 
168
  .command-row {
169
  display: grid;
170
+ grid-template-columns: repeat(5, minmax(0, 1fr));
171
  gap: 8px;
172
  margin-top: 10px;
173
  }
 
371
  }
372
 
373
  .command-row {
374
+ grid-template-columns: repeat(3, minmax(0, 1fr));
375
  }
376
 
377
  .score-row {
tests/test_agent.py CHANGED
@@ -27,6 +27,7 @@ def test_agent_finds_whitespace() -> None:
27
  assert result.whitespace
28
  assert result.score is not None
29
  assert result.artifact["verdict"] == "UNWRITTEN"
 
30
 
31
 
32
  def test_agent_preserves_canonical_jargon_case() -> None:
@@ -49,3 +50,15 @@ def test_plan_command_uses_current_idea() -> None:
49
  assert planned.plan
50
  assert planned.artifact["title"] == first.artifact["title"]
51
  assert planned.state["ideas"][0]["title"] == first.artifact["title"]
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  assert result.whitespace
28
  assert result.score is not None
29
  assert result.artifact["verdict"] == "UNWRITTEN"
30
+ assert result.state["ideas"][0]["title"] == result.whitespace[0].label
31
 
32
 
33
  def test_agent_preserves_canonical_jargon_case() -> None:
 
50
  assert planned.plan
51
  assert planned.artifact["title"] == first.artifact["title"]
52
  assert planned.state["ideas"][0]["title"] == first.artifact["title"]
53
+
54
+
55
+ def test_plan_preserves_unwritten_whitespace_verdict() -> None:
56
+ index = ProjectIndex.from_files(Path("data/projects.json"), Path("data/project_index.json"))
57
+ engine = AdvisorEngine(index)
58
+
59
+ whitespace = engine.turn("write bolder and find whitespace", {})
60
+ planned = engine.turn("make a build plan", whitespace.state)
61
+
62
+ assert whitespace.artifact["verdict"] == "UNWRITTEN"
63
+ assert planned.artifact["title"] == whitespace.artifact["title"]
64
+ assert planned.artifact["verdict"] == "UNWRITTEN"
tests/test_app.py CHANGED
@@ -1,4 +1,6 @@
1
- from app import bootstrap, health, index
 
 
2
 
3
 
4
  def test_health_exposes_index_metadata() -> None:
@@ -17,3 +19,13 @@ def test_bootstrap_exposes_index_metadata() -> None:
17
  assert payload["index_generated_at"]
18
  assert payload["snapshot_digest"]
19
  assert payload["top_projects"]
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ from app import bootstrap, engine, health, index, trace_artifact
4
 
5
 
6
  def test_health_exposes_index_metadata() -> None:
 
19
  assert payload["index_generated_at"]
20
  assert payload["snapshot_digest"]
21
  assert payload["top_projects"]
22
+
23
+
24
+ def test_trace_artifact_endpoint_exports_jsonl() -> None:
25
+ state = engine.turn("A local-first archive cartographer for family photos", {}).state
26
+ payload = trace_artifact(json.dumps(state))
27
+ lines = [json.loads(line) for line in payload.splitlines()]
28
+
29
+ assert lines[0]["type"] == "trace_manifest"
30
+ assert lines[0]["turn_count"] == 1
31
+ assert lines[1]["type"] == "agent_turn"
tests/test_trace_export.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from hackathon_advisor.agent import AdvisorEngine
5
+ from hackathon_advisor.data import ProjectIndex
6
+ from hackathon_advisor.trace_export import build_trace_jsonl, trace_metadata
7
+
8
+
9
+ def test_trace_jsonl_contains_manifest_and_turns() -> None:
10
+ index = ProjectIndex.from_files(Path("data/projects.json"), Path("data/project_index.json"))
11
+ engine = AdvisorEngine(index)
12
+ state = engine.turn("A local-first archive cartographer for family photos", {}).state
13
+ state = engine.turn("make a build plan", state).state
14
+
15
+ lines = [json.loads(line) for line in build_trace_jsonl(state, trace_metadata(index)).splitlines()]
16
+
17
+ assert lines[0]["type"] == "trace_manifest"
18
+ assert lines[0]["turn_count"] == 2
19
+ assert lines[0]["index"]["algorithm"] == "tfidf-sparse-v1"
20
+ assert lines[1]["type"] == "agent_turn"
21
+ assert lines[1]["tools"]
22
+ assert lines[2]["plan_steps"] > 0
23
+
24
+
25
+ def test_checked_in_sample_trace_matches_schema() -> None:
26
+ lines = [
27
+ json.loads(line)
28
+ for line in Path("data/sample_trace.jsonl").read_text(encoding="utf-8").splitlines()
29
+ ]
30
+
31
+ assert lines[0]["type"] == "trace_manifest"
32
+ assert lines[0]["turn_count"] >= 3
33
+ assert all(line["schema_version"] == 1 for line in lines)