Spaces:
Running on Zero
Running on Zero
feat: add wood map artifact
Browse filesCo-authored-by: Codex <noreply@openai.com>
- README.md +6 -0
- hackathon_advisor/agent.py +11 -1
- hackathon_advisor/field_notes.py +14 -0
- hackathon_advisor/wood_map.py +96 -0
- static/app.js +83 -0
- static/index.html +4 -0
- static/styles.css +54 -1
- tests/test_agent.py +2 -0
- tests/test_field_notes.py +2 -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 |
## 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|