Spaces:
Running on Zero
Running on Zero
feat: add page-number citations
Browse filesCo-authored-by: Codex <noreply@openai.com>
- hackathon_advisor/agent.py +3 -1
- hackathon_advisor/data.py +13 -2
- hackathon_advisor/field_notes.py +9 -4
- hackathon_advisor/scoring.py +1 -0
- hackathon_advisor/wood_map.py +1 -0
- static/app.js +45 -1
- static/styles.css +9 -0
- tests/test_agent.py +2 -0
- tests/test_data.py +1 -0
- tests/test_field_notes.py +1 -0
hackathon_advisor/agent.py
CHANGED
|
@@ -398,7 +398,9 @@ class AdvisorEngine:
|
|
| 398 |
f"{score.verdict} at {score.overall}/10. Push the AI necessity harder: make the model decide, rank, "
|
| 399 |
"or personalize something a static app cannot."
|
| 400 |
)
|
| 401 |
-
citations = "; ".join(
|
|
|
|
|
|
|
| 402 |
return (
|
| 403 |
f"The ink bleeds around {idea.title}. Closest echoes: {citations}. The seal reads "
|
| 404 |
f"{score.verdict} at {score.overall}/10. Keep the audience, but change the mechanism or artifact so the "
|
|
|
|
| 398 |
f"{score.verdict} at {score.overall}/10. Push the AI necessity harder: make the model decide, rank, "
|
| 399 |
"or personalize something a static app cannot."
|
| 400 |
)
|
| 401 |
+
citations = "; ".join(
|
| 402 |
+
f"page {hit.page_number}: {hit.project.title}" for hit in score.echoes[:3]
|
| 403 |
+
)
|
| 404 |
return (
|
| 405 |
f"The ink bleeds around {idea.title}. Closest echoes: {citations}. The seal reads "
|
| 406 |
f"{score.verdict} at {score.overall}/10. Keep the audience, but change the mechanism or artifact so the "
|
hackathon_advisor/data.py
CHANGED
|
@@ -87,6 +87,7 @@ class SearchHit:
|
|
| 87 |
project: Project
|
| 88 |
score: float
|
| 89 |
matched_terms: tuple[str, ...]
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
@dataclass(frozen=True)
|
|
@@ -218,7 +219,10 @@ class ProjectIndex:
|
|
| 218 |
query_doc = Counter(query_terms)
|
| 219 |
query_norm = self._norm(query_doc)
|
| 220 |
hits: list[SearchHit] = []
|
| 221 |
-
for project, doc, doc_norm in
|
|
|
|
|
|
|
|
|
|
| 222 |
if doc_norm == 0.0 or query_norm == 0.0:
|
| 223 |
continue
|
| 224 |
raw = 0.0
|
|
@@ -233,7 +237,14 @@ class ProjectIndex:
|
|
| 233 |
title_bonus = sum(0.08 for term in matched if term in tokenize(project.title))
|
| 234 |
tag_bonus = sum(0.05 for term in matched if term in tokenize(" ".join(project.tags)))
|
| 235 |
score = raw / (query_norm * doc_norm) + title_bonus + tag_bonus
|
| 236 |
-
hits.append(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
hits.sort(key=lambda hit: (hit.score, hit.project.likes), reverse=True)
|
| 238 |
return hits[:limit]
|
| 239 |
|
|
|
|
| 87 |
project: Project
|
| 88 |
score: float
|
| 89 |
matched_terms: tuple[str, ...]
|
| 90 |
+
page_number: int
|
| 91 |
|
| 92 |
|
| 93 |
@dataclass(frozen=True)
|
|
|
|
| 219 |
query_doc = Counter(query_terms)
|
| 220 |
query_norm = self._norm(query_doc)
|
| 221 |
hits: list[SearchHit] = []
|
| 222 |
+
for page_number, (project, doc, doc_norm) in enumerate(
|
| 223 |
+
zip(self.projects, self._documents, self._norms, strict=True),
|
| 224 |
+
start=1,
|
| 225 |
+
):
|
| 226 |
if doc_norm == 0.0 or query_norm == 0.0:
|
| 227 |
continue
|
| 228 |
raw = 0.0
|
|
|
|
| 237 |
title_bonus = sum(0.08 for term in matched if term in tokenize(project.title))
|
| 238 |
tag_bonus = sum(0.05 for term in matched if term in tokenize(" ".join(project.tags)))
|
| 239 |
score = raw / (query_norm * doc_norm) + title_bonus + tag_bonus
|
| 240 |
+
hits.append(
|
| 241 |
+
SearchHit(
|
| 242 |
+
project=project,
|
| 243 |
+
score=score,
|
| 244 |
+
matched_terms=tuple(sorted(matched)),
|
| 245 |
+
page_number=page_number,
|
| 246 |
+
)
|
| 247 |
+
)
|
| 248 |
hits.sort(key=lambda hit: (hit.score, hit.project.likes), reverse=True)
|
| 249 |
return hits[:limit]
|
| 250 |
|
hackathon_advisor/field_notes.py
CHANGED
|
@@ -67,10 +67,11 @@ def build_field_notes_markdown(session: dict[str, Any], metadata: dict[str, Any]
|
|
| 67 |
title = _clean(dot.get("title"))
|
| 68 |
score = _clean(dot.get("score"))
|
| 69 |
url = _clean(dot.get("url"))
|
|
|
|
| 70 |
if url:
|
| 71 |
-
lines.append(f"- [{title}]({url}) - echo score {score}")
|
| 72 |
else:
|
| 73 |
-
lines.append(f"- {title} - echo score {score}")
|
| 74 |
|
| 75 |
if last_artifact:
|
| 76 |
lines.extend(
|
|
@@ -107,10 +108,14 @@ def _idea_section(index: int, idea: dict[str, Any]) -> list[str]:
|
|
| 107 |
url = _clean(project.get("url") or project.get("host") or "")
|
| 108 |
matched = ", ".join(str(term) for term in echo.get("matched_terms") or [])
|
| 109 |
score_text = _clean(echo.get("score"))
|
|
|
|
| 110 |
if url:
|
| 111 |
-
lines.append(
|
|
|
|
|
|
|
|
|
|
| 112 |
else:
|
| 113 |
-
lines.append(f" - {title} - score {score_text}; matched {matched or 'no shared terms'}")
|
| 114 |
lines.append("")
|
| 115 |
return lines
|
| 116 |
|
|
|
|
| 67 |
title = _clean(dot.get("title"))
|
| 68 |
score = _clean(dot.get("score"))
|
| 69 |
url = _clean(dot.get("url"))
|
| 70 |
+
page = _clean(dot.get("page_number")) or "?"
|
| 71 |
if url:
|
| 72 |
+
lines.append(f"- Page {page}: [{title}]({url}) - echo score {score}")
|
| 73 |
else:
|
| 74 |
+
lines.append(f"- Page {page}: {title} - echo score {score}")
|
| 75 |
|
| 76 |
if last_artifact:
|
| 77 |
lines.extend(
|
|
|
|
| 108 |
url = _clean(project.get("url") or project.get("host") or "")
|
| 109 |
matched = ", ".join(str(term) for term in echo.get("matched_terms") or [])
|
| 110 |
score_text = _clean(echo.get("score"))
|
| 111 |
+
page = _clean(echo.get("page_number")) or "?"
|
| 112 |
if url:
|
| 113 |
+
lines.append(
|
| 114 |
+
f" - Page {page}: [{title}]({url}) - score {score_text}; "
|
| 115 |
+
f"matched {matched or 'no shared terms'}"
|
| 116 |
+
)
|
| 117 |
else:
|
| 118 |
+
lines.append(f" - Page {page}: {title} - score {score_text}; matched {matched or 'no shared terms'}")
|
| 119 |
lines.append("")
|
| 120 |
return lines
|
| 121 |
|
hackathon_advisor/scoring.py
CHANGED
|
@@ -40,6 +40,7 @@ class ScoreCard:
|
|
| 40 |
"echoes": [
|
| 41 |
{
|
| 42 |
"score": round(hit.score, 3),
|
|
|
|
| 43 |
"matched_terms": list(hit.matched_terms),
|
| 44 |
"project": hit.project.to_public_dict(),
|
| 45 |
}
|
|
|
|
| 40 |
"echoes": [
|
| 41 |
{
|
| 42 |
"score": round(hit.score, 3),
|
| 43 |
+
"page_number": hit.page_number,
|
| 44 |
"matched_terms": list(hit.matched_terms),
|
| 45 |
"project": hit.project.to_public_dict(),
|
| 46 |
}
|
hackathon_advisor/wood_map.py
CHANGED
|
@@ -43,6 +43,7 @@ def _echo_dot(hit: SearchHit) -> dict[str, Any]:
|
|
| 43 |
dot = _project_dot(hit.project, "echo")
|
| 44 |
dot["score"] = round(hit.score, 3)
|
| 45 |
dot["matched_terms"] = list(hit.matched_terms)
|
|
|
|
| 46 |
dot["radius"] = max(5, min(9, round(4 + hit.score * 14)))
|
| 47 |
return dot
|
| 48 |
|
|
|
|
| 43 |
dot = _project_dot(hit.project, "echo")
|
| 44 |
dot["score"] = round(hit.score, 3)
|
| 45 |
dot["matched_terms"] = list(hit.matched_terms)
|
| 46 |
+
dot["page_number"] = hit.page_number
|
| 47 |
dot["radius"] = max(5, min(9, round(4 + hit.score * 14)))
|
| 48 |
return dot
|
| 49 |
|
static/app.js
CHANGED
|
@@ -198,7 +198,11 @@ function handleEvent(event) {
|
|
| 198 |
session = event.state || {};
|
| 199 |
session.profile = session.profile || {};
|
| 200 |
session.targets = Array.isArray(session.targets) ? session.targets : [];
|
| 201 |
-
if (event.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
if (event.whitespace?.length) renderWhitespace(event.whitespace);
|
| 203 |
renderTargets(session.targets);
|
| 204 |
renderProfile(session.profile);
|
|
@@ -315,6 +319,30 @@ function renderProjects(projects) {
|
|
| 315 |
}
|
| 316 |
}
|
| 317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
function renderWhitespace(items) {
|
| 319 |
whitespaceEl.innerHTML = "";
|
| 320 |
if (!items.length) {
|
|
@@ -414,6 +442,7 @@ function exportArtifact(artifact) {
|
|
| 414 |
ctx.font = "28px Georgia, serif";
|
| 415 |
ctx.fillStyle = "#6b4e35";
|
| 416 |
wrapText(ctx, artifact.caption || "", 82, 252, 720, 36);
|
|
|
|
| 417 |
|
| 418 |
ctx.save();
|
| 419 |
ctx.translate(930, 226);
|
|
@@ -528,6 +557,21 @@ function drawWoodMap(ctx, map, x, y, width, height, verdict) {
|
|
| 528 |
ctx.restore();
|
| 529 |
}
|
| 530 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
function wrapText(ctx, text, x, y, maxWidth, lineHeight, align = "left") {
|
| 532 |
const words = String(text).split(/\s+/);
|
| 533 |
let line = "";
|
|
|
|
| 198 |
session = event.state || {};
|
| 199 |
session.profile = session.profile || {};
|
| 200 |
session.targets = Array.isArray(session.targets) ? session.targets : [];
|
| 201 |
+
if (event.score?.echoes?.length) {
|
| 202 |
+
renderCitations(event.score.echoes);
|
| 203 |
+
} else if (event.projects?.length) {
|
| 204 |
+
renderProjects(event.projects);
|
| 205 |
+
}
|
| 206 |
if (event.whitespace?.length) renderWhitespace(event.whitespace);
|
| 207 |
renderTargets(session.targets);
|
| 208 |
renderProfile(session.profile);
|
|
|
|
| 319 |
}
|
| 320 |
}
|
| 321 |
|
| 322 |
+
function renderCitations(echoes) {
|
| 323 |
+
projectsEl.innerHTML = "";
|
| 324 |
+
if (!echoes.length) {
|
| 325 |
+
projectsEl.innerHTML = `<div class="empty">No red ink yet.</div>`;
|
| 326 |
+
return;
|
| 327 |
+
}
|
| 328 |
+
for (const echo of echoes.slice(0, 5)) {
|
| 329 |
+
const project = echo.project || {};
|
| 330 |
+
const item = document.createElement("a");
|
| 331 |
+
item.className = "project citation";
|
| 332 |
+
item.href = project.url || project.host || "#";
|
| 333 |
+
item.target = "_blank";
|
| 334 |
+
item.rel = "noreferrer";
|
| 335 |
+
item.title = project.title || project.id || "Project citation";
|
| 336 |
+
const matched = (echo.matched_terms || []).slice(0, 5).join(", ") || "no shared terms";
|
| 337 |
+
item.innerHTML = `
|
| 338 |
+
<strong>Page ${escapeHtml(echo.page_number || "?")} · ${escapeHtml(project.title || project.id || "Untitled")}</strong>
|
| 339 |
+
<p>${escapeHtml(project.summary || project.id || "")}</p>
|
| 340 |
+
<span>${Number(echo.score || 0).toFixed(3)} · ${escapeHtml(matched)}</span>
|
| 341 |
+
`;
|
| 342 |
+
projectsEl.append(item);
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
function renderWhitespace(items) {
|
| 347 |
whitespaceEl.innerHTML = "";
|
| 348 |
if (!items.length) {
|
|
|
|
| 442 |
ctx.font = "28px Georgia, serif";
|
| 443 |
ctx.fillStyle = "#6b4e35";
|
| 444 |
wrapText(ctx, artifact.caption || "", 82, 252, 720, 36);
|
| 445 |
+
drawCitationList(ctx, seal.echoes || [], 742, 330, 330);
|
| 446 |
|
| 447 |
ctx.save();
|
| 448 |
ctx.translate(930, 226);
|
|
|
|
| 557 |
ctx.restore();
|
| 558 |
}
|
| 559 |
|
| 560 |
+
function drawCitationList(ctx, echoes, x, y, maxWidth) {
|
| 561 |
+
if (!echoes.length) return;
|
| 562 |
+
ctx.save();
|
| 563 |
+
ctx.fillStyle = "#6b4e35";
|
| 564 |
+
ctx.font = "800 18px Inter, sans-serif";
|
| 565 |
+
ctx.fillText("CLOSEST PAGES", x, y);
|
| 566 |
+
ctx.font = "700 15px Inter, sans-serif";
|
| 567 |
+
echoes.slice(0, 3).forEach((echo, index) => {
|
| 568 |
+
const project = echo.project || {};
|
| 569 |
+
const label = `Page ${echo.page_number || "?"}: ${project.title || project.id || "Untitled"}`;
|
| 570 |
+
wrapText(ctx, label, x, y + 24 + index * 26, maxWidth, 18);
|
| 571 |
+
});
|
| 572 |
+
ctx.restore();
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
function wrapText(ctx, text, x, y, maxWidth, lineHeight, align = "left") {
|
| 576 |
const words = String(text).split(/\s+/);
|
| 577 |
let line = "";
|
static/styles.css
CHANGED
|
@@ -322,6 +322,15 @@ button:disabled {
|
|
| 322 |
line-height: 1.35;
|
| 323 |
}
|
| 324 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
.idea span {
|
| 326 |
display: inline-block;
|
| 327 |
margin-top: 6px;
|
|
|
|
| 322 |
line-height: 1.35;
|
| 323 |
}
|
| 324 |
|
| 325 |
+
.project span {
|
| 326 |
+
display: inline-block;
|
| 327 |
+
margin-top: 6px;
|
| 328 |
+
color: #70401e;
|
| 329 |
+
font-size: 0.74rem;
|
| 330 |
+
line-height: 1.25;
|
| 331 |
+
font-weight: 900;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
.idea span {
|
| 335 |
display: inline-block;
|
| 336 |
margin-top: 6px;
|
tests/test_agent.py
CHANGED
|
@@ -31,6 +31,8 @@ def test_agent_scores_and_persists_idea() -> None:
|
|
| 31 |
assert result.state["last_artifact"]["title"] == result.artifact["title"]
|
| 32 |
assert result.artifact["wood_map"]["caption"]
|
| 33 |
assert {dot["kind"] for dot in result.artifact["wood_map"]["dots"]} >= {"idea", "echo", "inked"}
|
|
|
|
|
|
|
| 34 |
assert result.response
|
| 35 |
|
| 36 |
|
|
|
|
| 31 |
assert result.state["last_artifact"]["title"] == result.artifact["title"]
|
| 32 |
assert result.artifact["wood_map"]["caption"]
|
| 33 |
assert {dot["kind"] for dot in result.artifact["wood_map"]["dots"]} >= {"idea", "echo", "inked"}
|
| 34 |
+
assert result.score.to_dict()["echoes"][0]["page_number"] >= 1
|
| 35 |
+
assert "page " in result.response
|
| 36 |
assert result.response
|
| 37 |
|
| 38 |
|
tests/test_data.py
CHANGED
|
@@ -11,6 +11,7 @@ def test_project_index_searches_snapshot() -> None:
|
|
| 11 |
|
| 12 |
assert hits
|
| 13 |
assert hits[0].project.id.startswith("build-small-hackathon/")
|
|
|
|
| 14 |
assert index.index_algorithm == "tfidf-sparse-v1"
|
| 15 |
|
| 16 |
|
|
|
|
| 11 |
|
| 12 |
assert hits
|
| 13 |
assert hits[0].project.id.startswith("build-small-hackathon/")
|
| 14 |
+
assert hits[0].page_number >= 1
|
| 15 |
assert index.index_algorithm == "tfidf-sparse-v1"
|
| 16 |
|
| 17 |
|
tests/test_field_notes.py
CHANGED
|
@@ -31,6 +31,7 @@ def test_field_notes_markdown_contains_session_decisions() -> None:
|
|
| 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 "## Wood Map" in markdown
|
| 35 |
assert "echo score" in markdown
|
| 36 |
assert "Planner call: `make_plan`" 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 "Page " in markdown
|
| 35 |
assert "## Wood Map" in markdown
|
| 36 |
assert "echo score" in markdown
|
| 37 |
assert "Planner call: `make_plan`" in markdown
|