Spaces:
Running on Zero
Running on Zero
feat: export almanac chapters
Browse filesCo-authored-by: Codex <noreply@openai.com>
- README.md +6 -0
- app.py +16 -0
- hackathon_advisor/chapter.py +75 -0
- hackathon_advisor/tools.py +5 -1
- static/app.js +19 -1
- static/index.html +1 -0
- static/styles.css +2 -2
- tests/test_agent.py +12 -0
- tests/test_app.py +13 -0
- tests/test_chapter.py +40 -0
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(
|
| 188 |
gap: 8px;
|
| 189 |
margin-top: 10px;
|
| 190 |
}
|
|
@@ -527,7 +527,7 @@ button:disabled {
|
|
| 527 |
}
|
| 528 |
|
| 529 |
.command-row {
|
| 530 |
-
grid-template-columns: repeat(
|
| 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
|