JacobLinCool Codex commited on
Commit
36ed450
·
verified ·
1 Parent(s): d659d2d

feat: add wood map artifact

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
  ## Tool-Call Contract
73
 
74
  `/api/tool-contracts` exposes the JSON schemas intended for MiniCPM-style tool calling. `tool_contract_check` accepts a
 
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
75
+ the closest cited echoes, and a green/red "you" dot for the current idea. The live UI and PNG export render the same
76
+ map, so the share artifact visually proves whether the page sits in an empty margin or near existing work.
77
+
78
  ## Tool-Call Contract
79
 
80
  `/api/tool-contracts` exposes the JSON schemas intended for MiniCPM-style tool calling. `tool_contract_check` accepts a
hackathon_advisor/agent.py CHANGED
@@ -9,7 +9,16 @@ from hackathon_advisor.data import Project, ProjectIndex, WhitespaceItem
9
  from hackathon_advisor.model_runtime import ToolPlanner, create_tool_planner, runtime_status
10
  from hackathon_advisor.scoring import ScoreCard
11
  from hackathon_advisor.tool_contracts import ToolCall
12
- from hackathon_advisor.tools import TARGETS, AdvisorTools, Idea, ToolEvent, idea_from_text, normalize_targets, targets_from_state
 
 
 
 
 
 
 
 
 
13
 
14
 
15
  @dataclass
@@ -440,4 +449,5 @@ class AdvisorEngine:
440
  "overall": score.overall,
441
  "caption": f"Mothback inked my Build Small fate page: {idea.title} - {score.verdict}.",
442
  "seal": score.to_dict(),
 
443
  }
 
9
  from hackathon_advisor.model_runtime import ToolPlanner, create_tool_planner, runtime_status
10
  from hackathon_advisor.scoring import ScoreCard
11
  from hackathon_advisor.tool_contracts import ToolCall
12
+ from hackathon_advisor.tools import (
13
+ TARGETS,
14
+ AdvisorTools,
15
+ Idea,
16
+ ToolEvent,
17
+ idea_from_text,
18
+ normalize_targets,
19
+ targets_from_state,
20
+ )
21
+ from hackathon_advisor.wood_map import build_wood_map
22
 
23
 
24
  @dataclass
 
449
  "overall": score.overall,
450
  "caption": f"Mothback inked my Build Small fate page: {idea.title} - {score.verdict}.",
451
  "seal": score.to_dict(),
452
+ "wood_map": build_wood_map(self.index, idea, score),
453
  }
hackathon_advisor/field_notes.py CHANGED
@@ -58,6 +58,20 @@ def build_field_notes_markdown(session: dict[str, Any], metadata: dict[str, Any]
58
  else:
59
  lines.append("No tool trace was recorded.")
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  if last_artifact:
62
  lines.extend(
63
  [
 
58
  else:
59
  lines.append("No tool trace was recorded.")
60
 
61
+ wood_map = last_artifact.get("wood_map") if isinstance(last_artifact.get("wood_map"), dict) else {}
62
+ if wood_map:
63
+ lines.extend(["", "## Wood Map", "", _clean(wood_map.get("caption"))])
64
+ for dot in _list_of_dicts(wood_map.get("dots")):
65
+ if dot.get("kind") != "echo":
66
+ continue
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(
77
  [
hackathon_advisor/wood_map.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from hashlib import sha256
4
+ from typing import Any
5
+
6
+ from hackathon_advisor.data import Project, ProjectIndex, SearchHit
7
+ from hackathon_advisor.scoring import ScoreCard
8
+ from hackathon_advisor.tools import Idea
9
+
10
+
11
+ def build_wood_map(index: ProjectIndex, idea: Idea, score: ScoreCard) -> dict[str, Any]:
12
+ echoes = list(score.echoes)
13
+ background = _background_projects(index, echoes)
14
+ dots = [_project_dot(project, "inked") for project in background]
15
+ dots.extend(_echo_dot(hit) for hit in echoes[:5])
16
+ dots.append(_idea_dot(idea, score, echoes))
17
+ return {
18
+ "caption": _caption(score, echoes),
19
+ "dots": _dedupe_dots(dots),
20
+ }
21
+
22
+
23
+ def _background_projects(index: ProjectIndex, echoes: list[SearchHit]) -> list[Project]:
24
+ echo_ids = {hit.project.id for hit in echoes}
25
+ projects = [project for project in index.top_projects(limit=22) if project.id not in echo_ids]
26
+ return projects[:16]
27
+
28
+
29
+ def _project_dot(project: Project, kind: str) -> dict[str, Any]:
30
+ x, y = _point(project.id)
31
+ return {
32
+ "id": project.id,
33
+ "kind": kind,
34
+ "title": project.title,
35
+ "url": project.url,
36
+ "x": x,
37
+ "y": y,
38
+ "radius": 3,
39
+ }
40
+
41
+
42
+ 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
+
49
+
50
+ def _idea_dot(idea: Idea, score: ScoreCard, echoes: list[SearchHit]) -> dict[str, Any]:
51
+ if echoes and not score.verdict.startswith("UNWRITTEN"):
52
+ lead_x, lead_y = _point(echoes[0].project.id)
53
+ x = _clamp(lead_x + 7, 8, 92)
54
+ y = _clamp(lead_y - 5, 8, 92)
55
+ else:
56
+ x, y = _point(f"idea:{idea.id}:{idea.title}")
57
+ return {
58
+ "id": idea.id,
59
+ "kind": "idea",
60
+ "title": idea.title,
61
+ "x": x,
62
+ "y": y,
63
+ "radius": 8,
64
+ "verdict": score.verdict,
65
+ "overall": score.overall,
66
+ }
67
+
68
+
69
+ def _caption(score: ScoreCard, echoes: list[SearchHit]) -> str:
70
+ if score.verdict.startswith("UNWRITTEN"):
71
+ return "Your page sits in a pale margin beyond the nearest inked clusters."
72
+ names = ", ".join(hit.project.title for hit in echoes[:2]) or "nearby pages"
73
+ return f"Your page is pressed close to {names}; the red dots are the strongest echoes."
74
+
75
+
76
+ def _point(key: str) -> tuple[int, int]:
77
+ digest = sha256(key.encode("utf-8")).hexdigest()
78
+ x = 8 + int(digest[:4], 16) % 84
79
+ y = 8 + int(digest[4:8], 16) % 84
80
+ return x, y
81
+
82
+
83
+ def _clamp(value: int, low: int, high: int) -> int:
84
+ return max(low, min(high, value))
85
+
86
+
87
+ def _dedupe_dots(dots: list[dict[str, Any]]) -> list[dict[str, Any]]:
88
+ seen: set[tuple[str, str]] = set()
89
+ deduped: list[dict[str, Any]] = []
90
+ for dot in dots:
91
+ key = (str(dot.get("kind")), str(dot.get("id")))
92
+ if key in seen:
93
+ continue
94
+ deduped.append(dot)
95
+ seen.add(key)
96
+ return deduped
static/app.js CHANGED
@@ -10,6 +10,7 @@ const whitespaceEl = document.querySelector("#whitespace");
10
  const ideasEl = document.querySelector("#ideas");
11
  const targetsEl = document.querySelector("#targets");
12
  const profileEl = document.querySelector("#profile");
 
13
  const scoreEl = document.querySelector("#score");
14
  const planEl = document.querySelector("#plan");
15
  const traceEl = document.querySelector("#trace");
@@ -126,6 +127,7 @@ async function bootstrap() {
126
  renderProjects(data.top_projects || []);
127
  renderWhitespace(data.whitespace || []);
128
  renderIdeas([]);
 
129
  renderScore(null);
130
  renderPlan([]);
131
  renderTrace([]);
@@ -212,6 +214,7 @@ function handleEvent(event) {
212
  }
213
  if (event.artifact?.title) {
214
  currentArtifact = event.artifact;
 
215
  exportButton.disabled = false;
216
  }
217
  exportTraceButton.disabled = !(session.trace?.length);
@@ -261,6 +264,37 @@ function renderScore(score) {
261
  .join("");
262
  }
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  function renderProjects(projects) {
265
  projectsEl.innerHTML = "";
266
  if (!projects.length) {
@@ -415,6 +449,7 @@ function exportArtifact(artifact) {
415
  ctx.fillStyle = "#25160e";
416
  ctx.fillText(String(value), 582, y);
417
  });
 
418
 
419
  const link = document.createElement("a");
420
  link.download = `${slugify(artifact.title || "unwritten-page")}.png`;
@@ -449,6 +484,50 @@ function drawParchment(ctx, width, height) {
449
  ctx.strokeRect(28, 28, width - 56, height - 56);
450
  }
451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  function wrapText(ctx, text, x, y, maxWidth, lineHeight, align = "left") {
453
  const words = String(text).split(/\s+/);
454
  let line = "";
@@ -495,6 +574,10 @@ function fieldLabel(value) {
495
  .replace(/^\w/, (char) => char.toUpperCase());
496
  }
497
 
 
 
 
 
498
  function shortDate(value) {
499
  if (!value) return "unknown";
500
  return String(value).replace("T", " ").replace(/\+00:00$/, "Z").slice(0, 16);
 
10
  const ideasEl = document.querySelector("#ideas");
11
  const targetsEl = document.querySelector("#targets");
12
  const profileEl = document.querySelector("#profile");
13
+ const woodMapEl = document.querySelector("#wood-map");
14
  const scoreEl = document.querySelector("#score");
15
  const planEl = document.querySelector("#plan");
16
  const traceEl = document.querySelector("#trace");
 
127
  renderProjects(data.top_projects || []);
128
  renderWhitespace(data.whitespace || []);
129
  renderIdeas([]);
130
+ renderWoodMap(null);
131
  renderScore(null);
132
  renderPlan([]);
133
  renderTrace([]);
 
214
  }
215
  if (event.artifact?.title) {
216
  currentArtifact = event.artifact;
217
+ renderWoodMap(event.artifact.wood_map || null);
218
  exportButton.disabled = false;
219
  }
220
  exportTraceButton.disabled = !(session.trace?.length);
 
264
  .join("");
265
  }
266
 
267
+ function renderWoodMap(map) {
268
+ woodMapEl.innerHTML = "";
269
+ if (!map?.dots?.length) {
270
+ woodMapEl.innerHTML = `<div class="empty">No page has been placed yet.</div>`;
271
+ return;
272
+ }
273
+ const field = document.createElement("div");
274
+ field.className = "wood-map-field";
275
+ for (const dot of map.dots) {
276
+ const marker = document.createElement(dot.url ? "a" : "span");
277
+ const verdictClass = dot.kind === "idea" && String(dot.verdict || "").startsWith("ECHO") ? "echo-idea" : "";
278
+ marker.className = `wood-dot ${dot.kind || "inked"} ${verdictClass}`.trim();
279
+ marker.style.left = `${boundedPercent(dot.x)}%`;
280
+ marker.style.top = `${boundedPercent(dot.y)}%`;
281
+ const radius = Math.max(3, Math.min(10, Number(dot.radius || 4)));
282
+ marker.style.width = `${radius * 2}px`;
283
+ marker.style.height = `${radius * 2}px`;
284
+ marker.title = dot.kind === "idea" ? `You: ${dot.title}` : `${dot.title}${dot.score ? ` (${dot.score})` : ""}`;
285
+ if (dot.url) {
286
+ marker.href = dot.url;
287
+ marker.target = "_blank";
288
+ marker.rel = "noreferrer";
289
+ }
290
+ field.append(marker);
291
+ }
292
+ const caption = document.createElement("p");
293
+ caption.className = "wood-map-caption";
294
+ caption.textContent = map.caption || "Your page is plotted against the current Wood.";
295
+ woodMapEl.append(field, caption);
296
+ }
297
+
298
  function renderProjects(projects) {
299
  projectsEl.innerHTML = "";
300
  if (!projects.length) {
 
449
  ctx.fillStyle = "#25160e";
450
  ctx.fillText(String(value), 582, y);
451
  });
452
+ drawWoodMap(ctx, artifact.wood_map, 742, 396, 330, 184, artifact.verdict);
453
 
454
  const link = document.createElement("a");
455
  link.download = `${slugify(artifact.title || "unwritten-page")}.png`;
 
484
  ctx.strokeRect(28, 28, width - 56, height - 56);
485
  }
486
 
487
+ function drawWoodMap(ctx, map, x, y, width, height, verdict) {
488
+ if (!map?.dots?.length) return;
489
+ ctx.save();
490
+ ctx.fillStyle = "rgba(255, 241, 196, 0.38)";
491
+ ctx.strokeStyle = "rgba(80, 47, 22, 0.34)";
492
+ ctx.lineWidth = 2;
493
+ ctx.beginPath();
494
+ ctx.roundRect(x, y, width, height, 8);
495
+ ctx.fill();
496
+ ctx.stroke();
497
+
498
+ ctx.fillStyle = "#6b4e35";
499
+ ctx.font = "800 18px Inter, sans-serif";
500
+ ctx.fillText("YOU VS THE WOOD", x, y - 14);
501
+
502
+ for (const dot of map.dots) {
503
+ const px = x + (width * boundedPercent(dot.x)) / 100;
504
+ const py = y + (height * boundedPercent(dot.y)) / 100;
505
+ const radius = Math.max(3, Math.min(10, Number(dot.radius || 4)));
506
+ if (dot.kind === "idea") {
507
+ ctx.fillStyle = verdict?.startsWith("UNWRITTEN") ? "#2f7a49" : "#8d2d26";
508
+ ctx.strokeStyle = "#fff0b5";
509
+ ctx.lineWidth = 3;
510
+ } else if (dot.kind === "echo") {
511
+ ctx.fillStyle = "#8d2d26";
512
+ ctx.strokeStyle = "rgba(255, 240, 181, 0.72)";
513
+ ctx.lineWidth = 1.5;
514
+ } else {
515
+ ctx.fillStyle = "rgba(80, 47, 22, 0.34)";
516
+ ctx.strokeStyle = "transparent";
517
+ ctx.lineWidth = 0;
518
+ }
519
+ ctx.beginPath();
520
+ ctx.arc(px, py, radius, 0, Math.PI * 2);
521
+ ctx.fill();
522
+ if (ctx.lineWidth) ctx.stroke();
523
+ }
524
+
525
+ ctx.fillStyle = "#6b4e35";
526
+ ctx.font = "700 15px Inter, sans-serif";
527
+ wrapText(ctx, map.caption || "", x, y + height + 24, width, 20);
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 = "";
 
574
  .replace(/^\w/, (char) => char.toUpperCase());
575
  }
576
 
577
+ function boundedPercent(value) {
578
+ return Math.max(4, Math.min(96, Number(value || 50)));
579
+ }
580
+
581
  function shortDate(value) {
582
  if (!value) return "unknown";
583
  return String(value).replace("T", " ").replace(/\+00:00$/, "Z").slice(0, 16);
static/index.html CHANGED
@@ -47,6 +47,10 @@
47
  <div id="provenance" class="provenance"></div>
48
  <div id="score" class="score-grid" aria-label="Seal quadrants"></div>
49
  <div class="panels">
 
 
 
 
50
  <article>
51
  <h2>Targets</h2>
52
  <div id="targets" class="target-list"></div>
 
47
  <div id="provenance" class="provenance"></div>
48
  <div id="score" class="score-grid" aria-label="Seal quadrants"></div>
49
  <div class="panels">
50
+ <article class="wide-panel">
51
+ <h2>You vs the Wood</h2>
52
+ <div id="wood-map" class="wood-map"></div>
53
+ </article>
54
  <article>
55
  <h2>Targets</h2>
56
  <div id="targets" class="target-list"></div>
static/styles.css CHANGED
@@ -275,12 +275,17 @@ button:disabled {
275
  gap: 16px;
276
  }
277
 
 
 
 
 
278
  .project-list,
279
  .whitespace-list,
280
  .idea-list,
281
  .trace-list,
282
  .target-list,
283
- .profile-grid {
 
284
  display: grid;
285
  gap: 9px;
286
  }
@@ -395,6 +400,54 @@ button:disabled {
395
  box-shadow: 0 0 0 3px rgba(47, 122, 73, 0.13);
396
  }
397
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  .plan-list {
399
  display: grid;
400
  gap: 7px;
 
275
  gap: 16px;
276
  }
277
 
278
+ .wide-panel {
279
+ grid-column: 1 / -1;
280
+ }
281
+
282
  .project-list,
283
  .whitespace-list,
284
  .idea-list,
285
  .trace-list,
286
  .target-list,
287
+ .profile-grid,
288
+ .wood-map {
289
  display: grid;
290
  gap: 9px;
291
  }
 
400
  box-shadow: 0 0 0 3px rgba(47, 122, 73, 0.13);
401
  }
402
 
403
+ .wood-map-field {
404
+ position: relative;
405
+ min-height: 138px;
406
+ border: 1px solid rgba(80, 47, 22, 0.3);
407
+ border-radius: 8px;
408
+ background:
409
+ linear-gradient(rgba(80, 47, 22, 0.12) 1px, transparent 1px),
410
+ linear-gradient(90deg, rgba(80, 47, 22, 0.12) 1px, transparent 1px),
411
+ rgba(255, 241, 196, 0.28);
412
+ background-size: 28px 28px;
413
+ overflow: hidden;
414
+ }
415
+
416
+ .wood-dot {
417
+ position: absolute;
418
+ display: block;
419
+ border-radius: 50%;
420
+ transform: translate(-50%, -50%);
421
+ }
422
+
423
+ .wood-dot.inked {
424
+ background: rgba(80, 47, 22, 0.38);
425
+ }
426
+
427
+ .wood-dot.echo {
428
+ background: var(--red);
429
+ box-shadow: 0 0 0 2px rgba(255, 240, 181, 0.48);
430
+ }
431
+
432
+ .wood-dot.idea {
433
+ background: var(--leaf);
434
+ box-shadow:
435
+ 0 0 0 3px #fff0b5,
436
+ 0 0 18px rgba(47, 122, 73, 0.42);
437
+ }
438
+
439
+ .wood-dot.idea.echo-idea {
440
+ background: var(--red);
441
+ }
442
+
443
+ .wood-map-caption {
444
+ margin: 0;
445
+ color: var(--muted-ink);
446
+ font-size: 0.84rem;
447
+ line-height: 1.35;
448
+ font-weight: 800;
449
+ }
450
+
451
  .plan-list {
452
  display: grid;
453
  gap: 7px;
tests/test_agent.py CHANGED
@@ -29,6 +29,8 @@ def test_agent_scores_and_persists_idea() -> None:
29
  assert result.state["last_tool_resolution"]["call"]["name"] == "save_idea"
30
  assert result.state["trace"][0]["tool_resolution"]["call"]["name"] == "save_idea"
31
  assert result.state["last_artifact"]["title"] == result.artifact["title"]
 
 
32
  assert result.response
33
 
34
 
 
29
  assert result.state["last_tool_resolution"]["call"]["name"] == "save_idea"
30
  assert result.state["trace"][0]["tool_resolution"]["call"]["name"] == "save_idea"
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
 
tests/test_field_notes.py CHANGED
@@ -31,4 +31,6 @@ 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 "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 "## Wood Map" in markdown
35
+ assert "echo score" in markdown
36
  assert "Planner call: `make_plan`" in markdown