JacobLinCool Codex commited on
Commit
d659d2d
·
verified ·
1 Parent(s): 3b181a1

feat: export field notes

Browse files

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

README.md CHANGED
@@ -63,6 +63,12 @@ source, project order, and digest before the app starts.
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
  ## Tool-Call Contract
67
 
68
  `/api/tool-contracts` exposes the JSON schemas intended for MiniCPM-style tool calling. `tool_contract_check` accepts a
 
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
+ ## Field Notes Artifact
67
+
68
+ The `field_notes` Gradio API endpoint and `Notes` button export a Markdown build note from the exact session state:
69
+ builder profile, target badges, idea board, cited Spaces, latest build plan, planner calls, and the share caption. This
70
+ keeps the Field Notes badge path tied to auditable app evidence instead of a separate hand-written summary.
71
+
72
  ## Tool-Call Contract
73
 
74
  `/api/tool-contracts` exposes the JSON schemas intended for MiniCPM-style tool calling. `tool_contract_check` accepts a
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
  from hackathon_advisor.tool_contracts import resolve_tool_call, tool_schemas
14
  from hackathon_advisor.tools import TARGETS
15
  from hackathon_advisor.trace_export import build_trace_jsonl, trace_metadata
@@ -94,6 +95,21 @@ def trace_artifact(session_json: str = "{}") -> str:
94
  return build_trace_jsonl(session, trace_metadata(index))
95
 
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  @app.api(name="agent_turn", concurrency_limit=4, stream_every=0.04)
98
  def agent_turn(message: str, session_json: str = "{}") -> Iterator[str]:
99
  try:
 
10
 
11
  from hackathon_advisor.agent import AdvisorEngine
12
  from hackathon_advisor.data import ProjectIndex
13
+ from hackathon_advisor.field_notes import build_field_notes_markdown
14
  from hackathon_advisor.tool_contracts import resolve_tool_call, tool_schemas
15
  from hackathon_advisor.tools import TARGETS
16
  from hackathon_advisor.trace_export import build_trace_jsonl, trace_metadata
 
95
  return build_trace_jsonl(session, trace_metadata(index))
96
 
97
 
98
+ @app.api(name="field_notes", concurrency_limit=8)
99
+ def field_notes_artifact(session_json: str = "{}") -> str:
100
+ try:
101
+ session = json.loads(session_json or "{}")
102
+ except json.JSONDecodeError:
103
+ session = {}
104
+ return build_field_notes_markdown(
105
+ session,
106
+ {
107
+ **trace_metadata(index),
108
+ "project_count": len(index.projects),
109
+ },
110
+ )
111
+
112
+
113
  @app.api(name="agent_turn", concurrency_limit=4, stream_every=0.04)
114
  def agent_turn(message: str, session_json: str = "{}") -> Iterator[str]:
115
  try:
hackathon_advisor/agent.py CHANGED
@@ -353,6 +353,8 @@ class AdvisorEngine:
353
  }
354
  )
355
  state["trace"] = trace[-12:]
 
 
356
  if artifact:
357
  state["last_artifact"] = artifact
358
 
 
353
  }
354
  )
355
  state["trace"] = trace[-12:]
356
+ if plan:
357
+ state["last_plan"] = list(plan)
358
  if artifact:
359
  state["last_artifact"] = artifact
360
 
hackathon_advisor/field_notes.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any
5
+
6
+
7
+ def build_field_notes_markdown(session: dict[str, Any], metadata: dict[str, Any]) -> str:
8
+ ideas = _list_of_dicts(session.get("ideas"))
9
+ trace = _list_of_dicts(session.get("trace"))
10
+ profile = session.get("profile") if isinstance(session.get("profile"), dict) else {}
11
+ targets = [str(target) for target in session.get("targets") or []]
12
+ last_plan = [str(step) for step in session.get("last_plan") or []]
13
+ last_artifact = session.get("last_artifact") if isinstance(session.get("last_artifact"), dict) else {}
14
+
15
+ lines = [
16
+ "# Hackathon Advisor Field Notes",
17
+ "",
18
+ f"Generated: {datetime.now(timezone.utc).isoformat(timespec='seconds')}",
19
+ "",
20
+ "## Snapshot",
21
+ "",
22
+ f"- Project snapshot: {_clean(metadata.get('snapshot_generated_at'))}",
23
+ f"- Project count: {_clean(metadata.get('project_count', 'unknown'))}",
24
+ f"- Index: {_clean(metadata.get('index_algorithm'))} at {_clean(metadata.get('index_generated_at'))}",
25
+ f"- Digest: `{_clean(metadata.get('snapshot_digest'))}`",
26
+ "",
27
+ "## Builder Context",
28
+ "",
29
+ ]
30
+
31
+ if profile:
32
+ for field in ("skills", "time", "preferences", "constraints"):
33
+ value = _clean(profile.get(field))
34
+ if value:
35
+ lines.append(f"- {field.title()}: {value}")
36
+ else:
37
+ lines.append("- No profile notes recorded.")
38
+ lines.append(f"- Targets: {', '.join(targets) if targets else 'No specific targets'}")
39
+
40
+ lines.extend(["", "## Idea Board", ""])
41
+ if ideas:
42
+ for index, idea in enumerate(ideas, start=1):
43
+ lines.extend(_idea_section(index, idea))
44
+ else:
45
+ lines.append("No ideas were written.")
46
+
47
+ lines.extend(["", "## Build Plan", ""])
48
+ if last_plan:
49
+ for index, step in enumerate(last_plan, start=1):
50
+ lines.append(f"{index}. {_clean(step)}")
51
+ else:
52
+ lines.append("No build plan was pressed yet.")
53
+
54
+ lines.extend(["", "## Turn Trace", ""])
55
+ if trace:
56
+ for index, event in enumerate(trace, start=1):
57
+ lines.extend(_trace_section(index, event))
58
+ else:
59
+ lines.append("No tool trace was recorded.")
60
+
61
+ if last_artifact:
62
+ lines.extend(
63
+ [
64
+ "",
65
+ "## Share Caption",
66
+ "",
67
+ _clean(last_artifact.get("caption") or ""),
68
+ ]
69
+ )
70
+
71
+ return "\n".join(lines).rstrip() + "\n"
72
+
73
+
74
+ def _idea_section(index: int, idea: dict[str, Any]) -> list[str]:
75
+ title = _clean(idea.get("title") or f"Idea {index}")
76
+ pitch = _clean(idea.get("pitch"))
77
+ targets = [str(target) for target in idea.get("targets") or []]
78
+ score = idea.get("score") if isinstance(idea.get("score"), dict) else {}
79
+ lines = [
80
+ f"### {index}. {title}",
81
+ "",
82
+ f"- Pitch: {pitch or 'No pitch recorded.'}",
83
+ f"- Targets: {', '.join(targets) if targets else 'No specific targets'}",
84
+ ]
85
+ if score:
86
+ lines.append(f"- Seal: {_clean(score.get('overall'))}/10 - {_clean(score.get('verdict'))}")
87
+ echoes = _list_of_dicts(score.get("echoes"))
88
+ if echoes:
89
+ lines.append("- Closest cited Spaces:")
90
+ for echo in echoes[:4]:
91
+ project = echo.get("project") if isinstance(echo.get("project"), dict) else {}
92
+ title = _clean(project.get("title") or project.get("id") or "Untitled Space")
93
+ url = _clean(project.get("url") or project.get("host") or "")
94
+ matched = ", ".join(str(term) for term in echo.get("matched_terms") or [])
95
+ score_text = _clean(echo.get("score"))
96
+ if url:
97
+ lines.append(f" - [{title}]({url}) - score {score_text}; matched {matched or 'no shared terms'}")
98
+ else:
99
+ lines.append(f" - {title} - score {score_text}; matched {matched or 'no shared terms'}")
100
+ lines.append("")
101
+ return lines
102
+
103
+
104
+ def _trace_section(index: int, event: dict[str, Any]) -> list[str]:
105
+ tools = _list_of_dicts(event.get("tools"))
106
+ tool_names = " -> ".join(_clean(tool.get("name")) for tool in tools if tool.get("name")) or "reply"
107
+ lines = [
108
+ f"### Turn {index}",
109
+ "",
110
+ f"- Input: {_clean(event.get('input'))}",
111
+ f"- Tools: {tool_names}",
112
+ ]
113
+ verdict = _clean(event.get("verdict"))
114
+ overall = event.get("overall")
115
+ if verdict or overall is not None:
116
+ lines.append(f"- Verdict: {verdict or 'n/a'} {overall if overall is not None else ''}".rstrip())
117
+ response = _clean(event.get("response"))
118
+ if response:
119
+ lines.append(f"- Advisor note: {response}")
120
+ resolution = event.get("tool_resolution") if isinstance(event.get("tool_resolution"), dict) else {}
121
+ call = resolution.get("call") if isinstance(resolution.get("call"), dict) else {}
122
+ if call:
123
+ lines.append(f"- Planner call: `{_clean(call.get('name'))}`")
124
+ lines.append("")
125
+ return lines
126
+
127
+
128
+ def _list_of_dicts(value: Any) -> list[dict[str, Any]]:
129
+ if not isinstance(value, list):
130
+ return []
131
+ return [item for item in value if isinstance(item, dict)]
132
+
133
+
134
+ def _clean(value: Any) -> str:
135
+ if value is None:
136
+ return ""
137
+ return " ".join(str(value).split())
static/app.js CHANGED
@@ -18,6 +18,7 @@ const verdictEl = document.querySelector("#verdict");
18
  const overallEl = document.querySelector("#overall");
19
  const exportButton = document.querySelector("#export-artifact");
20
  const exportTraceButton = document.querySelector("#export-trace");
 
21
 
22
  let session = {};
23
  let clientPromise = Client.connect(window.location.origin);
@@ -49,6 +50,10 @@ exportTraceButton.addEventListener("click", async () => {
49
  await exportTrace();
50
  });
51
 
 
 
 
 
52
  targetsEl.addEventListener("change", (event) => {
53
  const target = event.target;
54
  if (!(target instanceof HTMLInputElement) || !target.dataset.target) return;
@@ -210,6 +215,7 @@ function handleEvent(event) {
210
  exportButton.disabled = false;
211
  }
212
  exportTraceButton.disabled = !(session.trace?.length);
 
213
  }
214
  }
215
 
@@ -327,7 +333,12 @@ function setCommandDisabled(disabled) {
327
  document.querySelectorAll(".command-row button").forEach((button) => {
328
  const isArtifact = button.id === "export-artifact";
329
  const isTrace = button.id === "export-trace";
330
- button.disabled = disabled || (isArtifact && !currentArtifact) || (isTrace && !session.trace?.length);
 
 
 
 
 
331
  });
332
  }
333
 
@@ -347,6 +358,15 @@ async function exportTrace() {
347
  downloadText("hackathon-advisor-trace.jsonl", String(data || ""));
348
  }
349
 
 
 
 
 
 
 
 
 
 
350
  function exportArtifact(artifact) {
351
  const canvas = document.createElement("canvas");
352
  canvas.width = 1200;
@@ -402,8 +422,8 @@ function exportArtifact(artifact) {
402
  link.click();
403
  }
404
 
405
- function downloadText(filename, text) {
406
- const blob = new Blob([text], { type: "application/jsonl;charset=utf-8" });
407
  const link = document.createElement("a");
408
  link.download = filename;
409
  link.href = URL.createObjectURL(blob);
 
18
  const overallEl = document.querySelector("#overall");
19
  const exportButton = document.querySelector("#export-artifact");
20
  const exportTraceButton = document.querySelector("#export-trace");
21
+ const exportNotesButton = document.querySelector("#export-notes");
22
 
23
  let session = {};
24
  let clientPromise = Client.connect(window.location.origin);
 
50
  await exportTrace();
51
  });
52
 
53
+ exportNotesButton.addEventListener("click", async () => {
54
+ await exportNotes();
55
+ });
56
+
57
  targetsEl.addEventListener("change", (event) => {
58
  const target = event.target;
59
  if (!(target instanceof HTMLInputElement) || !target.dataset.target) return;
 
215
  exportButton.disabled = false;
216
  }
217
  exportTraceButton.disabled = !(session.trace?.length);
218
+ exportNotesButton.disabled = !(session.trace?.length);
219
  }
220
  }
221
 
 
333
  document.querySelectorAll(".command-row button").forEach((button) => {
334
  const isArtifact = button.id === "export-artifact";
335
  const isTrace = button.id === "export-trace";
336
+ const isNotes = button.id === "export-notes";
337
+ button.disabled =
338
+ disabled ||
339
+ (isArtifact && !currentArtifact) ||
340
+ (isTrace && !session.trace?.length) ||
341
+ (isNotes && !session.trace?.length);
342
  });
343
  }
344
 
 
358
  downloadText("hackathon-advisor-trace.jsonl", String(data || ""));
359
  }
360
 
361
+ async function exportNotes() {
362
+ const client = await clientPromise;
363
+ const result = await client.predict("/field_notes", {
364
+ session_json: JSON.stringify(session),
365
+ });
366
+ const data = Array.isArray(result.data) ? result.data[0] : result.data;
367
+ downloadText("hackathon-advisor-field-notes.md", String(data || ""), "text/markdown;charset=utf-8");
368
+ }
369
+
370
  function exportArtifact(artifact) {
371
  const canvas = document.createElement("canvas");
372
  canvas.width = 1200;
 
422
  link.click();
423
  }
424
 
425
+ function downloadText(filename, text, type = "application/jsonl;charset=utf-8") {
426
+ const blob = new Blob([text], { type });
427
  const link = document.createElement("a");
428
  link.download = filename;
429
  link.href = URL.createObjectURL(blob);
static/index.html CHANGED
@@ -33,6 +33,7 @@
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>
 
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-notes" title="Export Field Notes" disabled>Notes</button>
37
  <button type="button" id="export-artifact" title="Export the current fate page" disabled>PNG</button>
38
  </div>
39
  <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(5, minmax(0, 1fr));
171
  gap: 8px;
172
  margin-top: 10px;
173
  }
 
167
 
168
  .command-row {
169
  display: grid;
170
+ grid-template-columns: repeat(6, minmax(0, 1fr));
171
  gap: 8px;
172
  margin-top: 10px;
173
  }
tests/test_app.py CHANGED
@@ -1,6 +1,16 @@
1
  import json
2
 
3
- from app import bootstrap, engine, health, index, runtime, tool_contract_check, tool_contracts, trace_artifact
 
 
 
 
 
 
 
 
 
 
4
 
5
 
6
  def test_health_exposes_index_metadata() -> None:
@@ -35,6 +45,22 @@ def test_trace_artifact_endpoint_exports_jsonl() -> None:
35
  assert lines[1]["type"] == "agent_turn"
36
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  def test_tool_contracts_endpoint_exposes_schemas() -> None:
39
  payload = tool_contracts()
40
 
 
1
  import json
2
 
3
+ from app import (
4
+ bootstrap,
5
+ engine,
6
+ field_notes_artifact,
7
+ health,
8
+ index,
9
+ runtime,
10
+ tool_contract_check,
11
+ tool_contracts,
12
+ trace_artifact,
13
+ )
14
 
15
 
16
  def test_health_exposes_index_metadata() -> None:
 
45
  assert lines[1]["type"] == "agent_turn"
46
 
47
 
48
+ def test_field_notes_endpoint_exports_markdown() -> None:
49
+ state = engine.turn(
50
+ "A local-first archive cartographer for family photos",
51
+ {"profile": {"skills": "frontend"}, "targets": ["Field Notes"]},
52
+ ).state
53
+ state = engine.turn("make a build plan", state).state
54
+
55
+ payload = field_notes_artifact(json.dumps(state))
56
+
57
+ assert payload.startswith("# Hackathon Advisor Field Notes")
58
+ assert "Skills: frontend" in payload
59
+ assert "Targets: Field Notes" in payload
60
+ assert "## Turn Trace" in payload
61
+ assert "Record the trace and write Field Notes" in payload
62
+
63
+
64
  def test_tool_contracts_endpoint_exposes_schemas() -> None:
65
  payload = tool_contracts()
66
 
tests/test_field_notes.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from hackathon_advisor.agent import AdvisorEngine
4
+ from hackathon_advisor.data import ProjectIndex
5
+ from hackathon_advisor.field_notes import build_field_notes_markdown
6
+ from hackathon_advisor.trace_export import trace_metadata
7
+
8
+
9
+ def test_field_notes_markdown_contains_session_decisions() -> None:
10
+ index = ProjectIndex.from_files(Path("data/projects.json"), Path("data/project_index.json"))
11
+ engine = AdvisorEngine(index)
12
+ state = {
13
+ "profile": {"skills": "frontend prototyping"},
14
+ "targets": ["Field Notes"],
15
+ }
16
+ first = engine.turn("A local-first archive cartographer for family photos", state)
17
+ planned = engine.turn("make a build plan", first.state)
18
+
19
+ markdown = build_field_notes_markdown(
20
+ planned.state,
21
+ {
22
+ **trace_metadata(index),
23
+ "project_count": len(index.projects),
24
+ },
25
+ )
26
+
27
+ assert "# Hackathon Advisor Field Notes" in markdown
28
+ assert "frontend prototyping" in markdown
29
+ assert "Targets: Field Notes" in markdown
30
+ assert "A local-first archive cartographer for family photos" in markdown
31
+ assert "## Build Plan" in markdown
32
+ assert "Record the trace and write Field Notes" in markdown
33
+ assert "Closest cited Spaces" in markdown
34
+ assert "Planner call: `make_plan`" in markdown