JacobLinCool Codex commited on
Commit
52c63cf
·
verified ·
1 Parent(s): 50124a7

feat: export almanac chapters

Browse files

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

README.md CHANGED
@@ -69,6 +69,12 @@ The `field_notes` Gradio API endpoint and `Notes` button export a Markdown build
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
  ## Wood Map
73
 
74
  Every scored fate page now carries a deterministic `wood_map` artifact: background dots for inked Spaces, red dots for
 
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
+ ## Chapter Artifact
73
+
74
+ The `chapter` Gradio API endpoint and `Chapter` button export the public-facing idea board as an Almanac chapter:
75
+ one fate page per idea, each with verdict, score, targets, and closest cited pages. It is the shareable companion to
76
+ the private Field Notes artifact.
77
+
78
  ## Wood Map
79
 
80
  Every scored fate page now carries a deterministic `wood_map` artifact: background dots for inked Spaces, red dots for
app.py CHANGED
@@ -9,6 +9,7 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
9
  from gradio import Server
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
@@ -110,6 +111,21 @@ def field_notes_artifact(session_json: str = "{}") -> str:
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:
 
9
  from gradio import Server
10
 
11
  from hackathon_advisor.agent import AdvisorEngine
12
+ from hackathon_advisor.chapter import build_chapter_markdown
13
  from hackathon_advisor.data import ProjectIndex
14
  from hackathon_advisor.field_notes import build_field_notes_markdown
15
  from hackathon_advisor.tool_contracts import resolve_tool_call, tool_schemas
 
111
  )
112
 
113
 
114
+ @app.api(name="chapter", concurrency_limit=8)
115
+ def chapter_artifact(session_json: str = "{}") -> str:
116
+ try:
117
+ session = json.loads(session_json or "{}")
118
+ except json.JSONDecodeError:
119
+ session = {}
120
+ return build_chapter_markdown(
121
+ session,
122
+ {
123
+ **trace_metadata(index),
124
+ "project_count": len(index.projects),
125
+ },
126
+ )
127
+
128
+
129
  @app.api(name="agent_turn", concurrency_limit=4, stream_every=0.04)
130
  def agent_turn(message: str, session_json: str = "{}") -> Iterator[str]:
131
  try:
hackathon_advisor/chapter.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any
5
+
6
+
7
+ def build_chapter_markdown(session: dict[str, Any], metadata: dict[str, Any]) -> str:
8
+ ideas = _list_of_dicts(session.get("ideas"))
9
+ targets = [str(target) for target in session.get("targets") or []]
10
+ artifact = session.get("last_artifact") if isinstance(session.get("last_artifact"), dict) else {}
11
+ lines = [
12
+ "# The Unwritten Almanac Chapter",
13
+ "",
14
+ f"Generated: {datetime.now(timezone.utc).isoformat(timespec='seconds')}",
15
+ f"Snapshot: {_clean(metadata.get('snapshot_generated_at'))} · {_clean(metadata.get('project_count'))} pages",
16
+ f"Targets: {', '.join(targets) if targets else 'No specific targets'}",
17
+ "",
18
+ ]
19
+
20
+ if not ideas:
21
+ lines.extend(["No fate pages have been written yet.", ""])
22
+ return "\n".join(lines)
23
+
24
+ for index, idea in enumerate(ideas, start=1):
25
+ lines.extend(_idea_page(index, idea))
26
+
27
+ caption = _clean(artifact.get("caption")) if artifact else ""
28
+ if caption:
29
+ lines.extend(["## Share Caption", "", caption, ""])
30
+ return "\n".join(lines).rstrip() + "\n"
31
+
32
+
33
+ def _idea_page(index: int, idea: dict[str, Any]) -> list[str]:
34
+ title = _clean(idea.get("title") or f"Page {index}")
35
+ pitch = _clean(idea.get("pitch"))
36
+ targets = [str(target) for target in idea.get("targets") or []]
37
+ score = idea.get("score") if isinstance(idea.get("score"), dict) else {}
38
+ verdict = _clean(score.get("verdict")) if score else "DRAFT"
39
+ overall = _clean(score.get("overall")) if score else "0.0"
40
+ lines = [
41
+ f"## Page {index}: {title}",
42
+ "",
43
+ f"Verdict: {verdict} · {overall}/10",
44
+ f"Targets: {', '.join(targets) if targets else 'No specific targets'}",
45
+ "",
46
+ pitch or "No prophecy text recorded.",
47
+ "",
48
+ ]
49
+ echoes = _list_of_dicts(score.get("echoes")) if score else []
50
+ if echoes:
51
+ lines.extend(["Closest inked pages:", ""])
52
+ for echo in echoes[:3]:
53
+ project = echo.get("project") if isinstance(echo.get("project"), dict) else {}
54
+ page = _clean(echo.get("page_number")) or "?"
55
+ project_title = _clean(project.get("title") or project.get("id") or "Untitled")
56
+ url = _clean(project.get("url") or project.get("host") or "")
57
+ score_text = _clean(echo.get("score"))
58
+ if url:
59
+ lines.append(f"- Page {page}: [{project_title}]({url}) · echo {score_text}")
60
+ else:
61
+ lines.append(f"- Page {page}: {project_title} · echo {score_text}")
62
+ lines.append("")
63
+ return lines
64
+
65
+
66
+ def _list_of_dicts(value: Any) -> list[dict[str, Any]]:
67
+ if not isinstance(value, list):
68
+ return []
69
+ return [item for item in value if isinstance(item, dict)]
70
+
71
+
72
+ def _clean(value: Any) -> str:
73
+ if value is None:
74
+ return ""
75
+ return " ".join(str(value).split())
hackathon_advisor/tools.py CHANGED
@@ -89,7 +89,7 @@ class AdvisorTools:
89
  current_id = state.get("current_idea_id")
90
  targets = targets_from_state(state)
91
  idea = next((item for item in ideas if item.id == current_id), None)
92
- if idea is None:
93
  idea = Idea(id=uuid.uuid4().hex[:8], title=title, pitch=pitch, targets=targets)
94
  ideas.append(idea)
95
  else:
@@ -133,6 +133,10 @@ def idea_from_text(text: str) -> tuple[str, str]:
133
  return _display_title(title), cleaned
134
 
135
 
 
 
 
 
136
  def _display_title(title: str) -> str:
137
  if not title:
138
  return "Unwritten Page"
 
89
  current_id = state.get("current_idea_id")
90
  targets = targets_from_state(state)
91
  idea = next((item for item in ideas if item.id == current_id), None)
92
+ if idea is None or _is_new_idea(idea, title, pitch):
93
  idea = Idea(id=uuid.uuid4().hex[:8], title=title, pitch=pitch, targets=targets)
94
  ideas.append(idea)
95
  else:
 
133
  return _display_title(title), cleaned
134
 
135
 
136
+ def _is_new_idea(current: Idea, title: str, pitch: str) -> bool:
137
+ return current.title.strip().casefold() != title.strip().casefold() or current.pitch.strip() != pitch.strip()
138
+
139
+
140
  def _display_title(title: str) -> str:
141
  if not title:
142
  return "Unwritten Page"
static/app.js CHANGED
@@ -20,6 +20,7 @@ const overallEl = document.querySelector("#overall");
20
  const exportButton = document.querySelector("#export-artifact");
21
  const exportTraceButton = document.querySelector("#export-trace");
22
  const exportNotesButton = document.querySelector("#export-notes");
 
23
  const resetButton = document.querySelector("#reset-session");
24
 
25
  const SESSION_STORAGE_KEY = "hackathon-advisor-session-v1";
@@ -60,6 +61,10 @@ exportNotesButton.addEventListener("click", async () => {
60
  await exportNotes();
61
  });
62
 
 
 
 
 
63
  resetButton.addEventListener("click", () => {
64
  clearSavedSession();
65
  window.location.reload();
@@ -175,6 +180,7 @@ function renderRestoredSession(data) {
175
  renderTrace(session.trace || []);
176
  exportTraceButton.disabled = !(session.trace?.length);
177
  exportNotesButton.disabled = !(session.trace?.length);
 
178
  }
179
 
180
  function readSavedSession() {
@@ -310,6 +316,7 @@ function handleEvent(event) {
310
  }
311
  exportTraceButton.disabled = !(session.trace?.length);
312
  exportNotesButton.disabled = !(session.trace?.length);
 
313
  saveSession();
314
  }
315
  }
@@ -484,11 +491,13 @@ function setCommandDisabled(disabled) {
484
  const isArtifact = button.id === "export-artifact";
485
  const isTrace = button.id === "export-trace";
486
  const isNotes = button.id === "export-notes";
 
487
  button.disabled =
488
  disabled ||
489
  (isArtifact && !currentArtifact) ||
490
  (isTrace && !session.trace?.length) ||
491
- (isNotes && !session.trace?.length);
 
492
  });
493
  }
494
 
@@ -543,6 +552,15 @@ async function exportNotes() {
543
  downloadText("hackathon-advisor-field-notes.md", String(data || ""), "text/markdown;charset=utf-8");
544
  }
545
 
 
 
 
 
 
 
 
 
 
546
  function exportArtifact(artifact) {
547
  const canvas = document.createElement("canvas");
548
  canvas.width = 1200;
 
20
  const exportButton = document.querySelector("#export-artifact");
21
  const exportTraceButton = document.querySelector("#export-trace");
22
  const exportNotesButton = document.querySelector("#export-notes");
23
+ const exportChapterButton = document.querySelector("#export-chapter");
24
  const resetButton = document.querySelector("#reset-session");
25
 
26
  const SESSION_STORAGE_KEY = "hackathon-advisor-session-v1";
 
61
  await exportNotes();
62
  });
63
 
64
+ exportChapterButton.addEventListener("click", async () => {
65
+ await exportChapter();
66
+ });
67
+
68
  resetButton.addEventListener("click", () => {
69
  clearSavedSession();
70
  window.location.reload();
 
180
  renderTrace(session.trace || []);
181
  exportTraceButton.disabled = !(session.trace?.length);
182
  exportNotesButton.disabled = !(session.trace?.length);
183
+ exportChapterButton.disabled = !(session.ideas?.length);
184
  }
185
 
186
  function readSavedSession() {
 
316
  }
317
  exportTraceButton.disabled = !(session.trace?.length);
318
  exportNotesButton.disabled = !(session.trace?.length);
319
+ exportChapterButton.disabled = !(session.ideas?.length);
320
  saveSession();
321
  }
322
  }
 
491
  const isArtifact = button.id === "export-artifact";
492
  const isTrace = button.id === "export-trace";
493
  const isNotes = button.id === "export-notes";
494
+ const isChapter = button.id === "export-chapter";
495
  button.disabled =
496
  disabled ||
497
  (isArtifact && !currentArtifact) ||
498
  (isTrace && !session.trace?.length) ||
499
+ (isNotes && !session.trace?.length) ||
500
+ (isChapter && !session.ideas?.length);
501
  });
502
  }
503
 
 
552
  downloadText("hackathon-advisor-field-notes.md", String(data || ""), "text/markdown;charset=utf-8");
553
  }
554
 
555
+ async function exportChapter() {
556
+ const client = await clientPromise;
557
+ const result = await client.predict("/chapter", {
558
+ session_json: JSON.stringify(session),
559
+ });
560
+ const data = Array.isArray(result.data) ? result.data[0] : result.data;
561
+ downloadText("hackathon-advisor-chapter.md", String(data || ""), "text/markdown;charset=utf-8");
562
+ }
563
+
564
  function exportArtifact(artifact) {
565
  const canvas = document.createElement("canvas");
566
  canvas.width = 1200;
static/index.html CHANGED
@@ -34,6 +34,7 @@
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
  <button type="button" id="reset-session" title="Clear the saved session">Reset</button>
39
  </div>
 
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-chapter" title="Export the Almanac chapter" disabled>Chapter</button>
38
  <button type="button" id="export-artifact" title="Export the current fate page" disabled>PNG</button>
39
  <button type="button" id="reset-session" title="Clear the saved session">Reset</button>
40
  </div>
static/styles.css CHANGED
@@ -184,7 +184,7 @@ button:disabled {
184
 
185
  .command-row {
186
  display: grid;
187
- grid-template-columns: repeat(7, minmax(0, 1fr));
188
  gap: 8px;
189
  margin-top: 10px;
190
  }
@@ -527,7 +527,7 @@ button:disabled {
527
  }
528
 
529
  .command-row {
530
- grid-template-columns: repeat(4, minmax(0, 1fr));
531
  }
532
 
533
  .score-row {
 
184
 
185
  .command-row {
186
  display: grid;
187
+ grid-template-columns: repeat(4, minmax(0, 1fr));
188
  gap: 8px;
189
  margin-top: 10px;
190
  }
 
527
  }
528
 
529
  .command-row {
530
+ grid-template-columns: repeat(3, minmax(0, 1fr));
531
  }
532
 
533
  .score-row {
tests/test_agent.py CHANGED
@@ -70,6 +70,18 @@ def test_plan_command_uses_current_idea() -> None:
70
  assert planned.state["ideas"][0]["title"] == first.artifact["title"]
71
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  def test_plan_preserves_unwritten_whitespace_verdict() -> None:
74
  index = ProjectIndex.from_files(Path("data/projects.json"), Path("data/project_index.json"))
75
  engine = AdvisorEngine(index)
 
70
  assert planned.state["ideas"][0]["title"] == first.artifact["title"]
71
 
72
 
73
+ def test_distinct_idea_turns_append_to_board() -> None:
74
+ index = ProjectIndex.from_files(Path("data/projects.json"), Path("data/project_index.json"))
75
+ engine = AdvisorEngine(index)
76
+
77
+ first = engine.turn("A local-first archive cartographer for family photos", {})
78
+ second = engine.turn("write bolder and find whitespace", first.state)
79
+
80
+ assert len(second.state["ideas"]) == 2
81
+ assert second.state["ideas"][0]["title"] == first.artifact["title"]
82
+ assert second.state["ideas"][1]["title"] == second.artifact["title"]
83
+
84
+
85
  def test_plan_preserves_unwritten_whitespace_verdict() -> None:
86
  index = ProjectIndex.from_files(Path("data/projects.json"), Path("data/project_index.json"))
87
  engine = AdvisorEngine(index)
tests/test_app.py CHANGED
@@ -2,6 +2,7 @@ import json
2
 
3
  from app import (
4
  bootstrap,
 
5
  engine,
6
  field_notes_artifact,
7
  health,
@@ -61,6 +62,18 @@ def test_field_notes_endpoint_exports_markdown() -> None:
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
 
 
2
 
3
  from app import (
4
  bootstrap,
5
+ chapter_artifact,
6
  engine,
7
  field_notes_artifact,
8
  health,
 
62
  assert "Record the trace and write Field Notes" in payload
63
 
64
 
65
+ def test_chapter_endpoint_exports_markdown() -> None:
66
+ state = engine.turn("A local-first archive cartographer for family photos", {}).state
67
+ state = engine.turn("write bolder and find whitespace", state).state
68
+
69
+ payload = chapter_artifact(json.dumps(state))
70
+
71
+ assert payload.startswith("# The Unwritten Almanac Chapter")
72
+ assert "## Page 1:" in payload
73
+ assert "## Page 2:" in payload
74
+ assert "Closest inked pages:" in payload
75
+
76
+
77
  def test_tool_contracts_endpoint_exposes_schemas() -> None:
78
  payload = tool_contracts()
79
 
tests/test_chapter.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from hackathon_advisor.agent import AdvisorEngine
4
+ from hackathon_advisor.chapter import build_chapter_markdown
5
+ from hackathon_advisor.data import ProjectIndex
6
+ from hackathon_advisor.trace_export import trace_metadata
7
+
8
+
9
+ def test_chapter_markdown_contains_idea_pages_and_citations() -> 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("write bolder and find whitespace", state).state
14
+
15
+ markdown = build_chapter_markdown(
16
+ state,
17
+ {
18
+ **trace_metadata(index),
19
+ "project_count": len(index.projects),
20
+ },
21
+ )
22
+
23
+ assert markdown.startswith("# The Unwritten Almanac Chapter")
24
+ assert "## Page 1:" in markdown
25
+ assert "## Page 2:" in markdown
26
+ assert "Targets:" in markdown
27
+ assert "Closest inked pages:" in markdown
28
+ assert "Page 30:" in markdown
29
+
30
+
31
+ def test_empty_chapter_markdown_is_explicit() -> None:
32
+ markdown = build_chapter_markdown(
33
+ {},
34
+ {
35
+ "snapshot_generated_at": "2026-06-06T00:00:00+00:00",
36
+ "project_count": 100,
37
+ },
38
+ )
39
+
40
+ assert "No fate pages have been written yet." in markdown