JacobLinCool Codex commited on
Commit
3b181a1
·
verified ·
1 Parent(s): b1236db

feat: add advisor cockpit controls

Browse files

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

app.py CHANGED
@@ -11,6 +11,7 @@ from gradio import Server
11
  from hackathon_advisor.agent import AdvisorEngine
12
  from hackathon_advisor.data import ProjectIndex
13
  from hackathon_advisor.tool_contracts import resolve_tool_call, tool_schemas
 
14
  from hackathon_advisor.trace_export import build_trace_jsonl, trace_metadata
15
 
16
 
@@ -18,6 +19,7 @@ ROOT = Path(__file__).parent
18
  STATIC_DIR = ROOT / "static"
19
  DATA_PATH = ROOT / "data" / "projects.json"
20
  INDEX_PATH = ROOT / "data" / "project_index.json"
 
21
 
22
  index = ProjectIndex.from_files(DATA_PATH, INDEX_PATH)
23
  engine = AdvisorEngine(index)
@@ -59,6 +61,9 @@ def bootstrap() -> dict:
59
  **trace_metadata(index),
60
  "top_projects": [project.to_public_dict() for project in index.top_projects(limit=8)],
61
  "whitespace": [item.to_dict() for item in index.find_whitespace(limit=5)],
 
 
 
62
  }
63
 
64
 
 
11
  from hackathon_advisor.agent import AdvisorEngine
12
  from hackathon_advisor.data import ProjectIndex
13
  from hackathon_advisor.tool_contracts import resolve_tool_call, tool_schemas
14
+ from hackathon_advisor.tools import TARGETS
15
  from hackathon_advisor.trace_export import build_trace_jsonl, trace_metadata
16
 
17
 
 
19
  STATIC_DIR = ROOT / "static"
20
  DATA_PATH = ROOT / "data" / "projects.json"
21
  INDEX_PATH = ROOT / "data" / "project_index.json"
22
+ PROFILE_FIELDS = ["skills", "time", "preferences", "constraints"]
23
 
24
  index = ProjectIndex.from_files(DATA_PATH, INDEX_PATH)
25
  engine = AdvisorEngine(index)
 
61
  **trace_metadata(index),
62
  "top_projects": [project.to_public_dict() for project in index.top_projects(limit=8)],
63
  "whitespace": [item.to_dict() for item in index.find_whitespace(limit=5)],
64
+ "target_options": TARGETS,
65
+ "default_targets": TARGETS[:3],
66
+ "profile_fields": PROFILE_FIELDS,
67
  }
68
 
69
 
hackathon_advisor/agent.py CHANGED
@@ -9,7 +9,7 @@ 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 AdvisorTools, Idea, ToolEvent, idea_from_text
13
 
14
 
15
  @dataclass
@@ -51,6 +51,8 @@ class AdvisorEngine:
51
  def turn(self, message: str, state: dict[str, Any] | None = None) -> TurnResult:
52
  state = dict(state or {})
53
  state.setdefault("ideas", [])
 
 
54
  normalized, corrections = normalize_text(message)
55
  resolution = self.planner.plan(normalized, state)
56
  state["last_tool_resolution"] = resolution.to_dict()
@@ -149,19 +151,31 @@ class AdvisorEngine:
149
  )
150
 
151
  def _store_idea(self, state: dict[str, Any], idea: Idea) -> None:
152
- state["ideas"] = [
153
- idea.to_dict() if item.get("id") == idea.id else item for item in state.get("ideas", [])
154
- ]
 
 
 
 
 
 
 
 
155
 
156
  def _current_idea(self, state: dict[str, Any]) -> Idea | None:
157
  current_id = state.get("current_idea_id")
158
  for item in state.get("ideas", []):
159
  if item.get("id") == current_id:
160
- return Idea(**item)
161
  if state.get("ideas"):
162
- return Idea(**state["ideas"][-1])
163
  return None
164
 
 
 
 
 
165
  def _idea_research_turn(
166
  self,
167
  call: ToolCall,
@@ -297,7 +311,7 @@ class AdvisorEngine:
297
  state: dict[str, Any],
298
  tool_events: list[ToolEvent],
299
  ) -> TurnResult:
300
- targets = [str(target) for target in call.arguments.get("side_quests", [])]
301
  state["targets"] = targets
302
  idea = self._current_idea(state)
303
  if idea is not None:
@@ -312,7 +326,7 @@ class AdvisorEngine:
312
  if idea_id:
313
  for item in state.get("ideas", []):
314
  if item.get("id") == idea_id:
315
- return Idea(**item)
316
  return self._current_idea(state)
317
 
318
  def _record_trace(
 
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
 
51
  def turn(self, message: str, state: dict[str, Any] | None = None) -> TurnResult:
52
  state = dict(state or {})
53
  state.setdefault("ideas", [])
54
+ state.setdefault("profile", {})
55
+ state.setdefault("targets", TARGETS[:3])
56
  normalized, corrections = normalize_text(message)
57
  resolution = self.planner.plan(normalized, state)
58
  state["last_tool_resolution"] = resolution.to_dict()
 
151
  )
152
 
153
  def _store_idea(self, state: dict[str, Any], idea: Idea) -> None:
154
+ stored = []
155
+ replaced = False
156
+ for item in state.get("ideas", []):
157
+ if item.get("id") == idea.id:
158
+ stored.append(idea.to_dict())
159
+ replaced = True
160
+ else:
161
+ stored.append(item)
162
+ if not replaced:
163
+ stored.append(idea.to_dict())
164
+ state["ideas"] = stored
165
 
166
  def _current_idea(self, state: dict[str, Any]) -> Idea | None:
167
  current_id = state.get("current_idea_id")
168
  for item in state.get("ideas", []):
169
  if item.get("id") == current_id:
170
+ return self._with_session_targets(Idea(**item), state)
171
  if state.get("ideas"):
172
+ return self._with_session_targets(Idea(**state["ideas"][-1]), state)
173
  return None
174
 
175
+ def _with_session_targets(self, idea: Idea, state: dict[str, Any]) -> Idea:
176
+ idea.targets = targets_from_state(state)
177
+ return idea
178
+
179
  def _idea_research_turn(
180
  self,
181
  call: ToolCall,
 
311
  state: dict[str, Any],
312
  tool_events: list[ToolEvent],
313
  ) -> TurnResult:
314
+ targets = normalize_targets(call.arguments.get("side_quests"), default=[])
315
  state["targets"] = targets
316
  idea = self._current_idea(state)
317
  if idea is not None:
 
326
  if idea_id:
327
  for item in state.get("ideas", []):
328
  if item.get("id") == idea_id:
329
+ return self._with_session_targets(Idea(**item), state)
330
  return self._current_idea(state)
331
 
332
  def _record_trace(
hackathon_advisor/tools.py CHANGED
@@ -18,6 +18,28 @@ TARGETS = [
18
  ]
19
 
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  @dataclass
22
  class Idea:
23
  id: str
@@ -65,13 +87,15 @@ class AdvisorTools:
65
  def save_idea(self, state: dict[str, Any], title: str, pitch: str) -> tuple[Idea, ToolEvent]:
66
  ideas = [Idea(**item) for item in state.get("ideas", [])]
67
  current_id = state.get("current_idea_id")
 
68
  idea = next((item for item in ideas if item.id == current_id), None)
69
  if idea is None:
70
- idea = Idea(id=uuid.uuid4().hex[:8], title=title, pitch=pitch)
71
  ideas.append(idea)
72
  else:
73
  idea.title = title
74
  idea.pitch = pitch
 
75
  state["ideas"] = [item.to_dict() for item in ideas]
76
  state["current_idea_id"] = idea.id
77
  return idea, ToolEvent("save_idea", f"Wrote idea page '{idea.title}'.")
 
18
  ]
19
 
20
 
21
+ def normalize_targets(raw_targets: Any, default: list[str] | None = None) -> list[str]:
22
+ if raw_targets is None:
23
+ return list(default or [])
24
+ if not isinstance(raw_targets, list):
25
+ return list(default or [])
26
+
27
+ targets: list[str] = []
28
+ seen: set[str] = set()
29
+ for raw_target in raw_targets:
30
+ target = str(raw_target)
31
+ if target in TARGETS and target not in seen:
32
+ targets.append(target)
33
+ seen.add(target)
34
+ return targets
35
+
36
+
37
+ def targets_from_state(state: dict[str, Any]) -> list[str]:
38
+ if "targets" not in state:
39
+ return TARGETS[:3]
40
+ return normalize_targets(state.get("targets"), default=[])
41
+
42
+
43
  @dataclass
44
  class Idea:
45
  id: str
 
87
  def save_idea(self, state: dict[str, Any], title: str, pitch: str) -> tuple[Idea, ToolEvent]:
88
  ideas = [Idea(**item) for item in state.get("ideas", [])]
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:
96
  idea.title = title
97
  idea.pitch = pitch
98
+ idea.targets = targets
99
  state["ideas"] = [item.to_dict() for item in ideas]
100
  state["current_idea_id"] = idea.id
101
  return idea, ToolEvent("save_idea", f"Wrote idea page '{idea.title}'.")
static/app.js CHANGED
@@ -8,6 +8,8 @@ const corrections = document.querySelector("#corrections");
8
  const projectsEl = document.querySelector("#projects");
9
  const whitespaceEl = document.querySelector("#whitespace");
10
  const ideasEl = document.querySelector("#ideas");
 
 
11
  const scoreEl = document.querySelector("#score");
12
  const planEl = document.querySelector("#plan");
13
  const traceEl = document.querySelector("#trace");
@@ -20,6 +22,8 @@ const exportTraceButton = document.querySelector("#export-trace");
20
  let session = {};
21
  let clientPromise = Client.connect(window.location.origin);
22
  let currentArtifact = null;
 
 
23
 
24
  bootstrap();
25
 
@@ -45,6 +49,30 @@ exportTraceButton.addEventListener("click", async () => {
45
  await exportTrace();
46
  });
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  async function runTurn(message) {
49
  input.value = "";
50
  submit.disabled = true;
@@ -81,7 +109,15 @@ async function runTurn(message) {
81
  async function bootstrap() {
82
  const response = await fetch("/api/bootstrap");
83
  const data = await response.json();
 
 
 
 
 
 
84
  renderProvenance(data);
 
 
85
  renderProjects(data.top_projects || []);
86
  renderWhitespace(data.whitespace || []);
87
  renderIdeas([]);
@@ -97,6 +133,45 @@ function renderProvenance(data) {
97
  provenanceEl.textContent = `${data.index_algorithm || "index"} · snapshot ${snapshot} · index ${index} · ${digest}`;
98
  }
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  function handleEvent(event) {
101
  if (event.type === "start") {
102
  if (event.corrections?.length) {
@@ -114,8 +189,12 @@ function handleEvent(event) {
114
 
115
  if (event.type === "done") {
116
  session = event.state || {};
 
 
117
  if (event.projects?.length) renderProjects(event.projects);
118
  if (event.whitespace?.length) renderWhitespace(event.whitespace);
 
 
119
  renderIdeas(session.ideas || []);
120
  renderTrace(session.trace || []);
121
  renderPlan(event.plan || []);
@@ -142,12 +221,14 @@ function renderIdeas(ideas) {
142
  }
143
  for (const idea of ideas.slice(-4).reverse()) {
144
  const score = idea.score?.overall ? Number(idea.score.overall).toFixed(1) : "0.0";
 
145
  const item = document.createElement("div");
146
  item.className = "idea";
147
  item.innerHTML = `
148
  <strong>${escapeHtml(idea.title)}</strong>
149
  <p>${escapeHtml((idea.pitch || "").slice(0, 120))}</p>
150
  <span>${escapeHtml(idea.score?.verdict || "DRAFT")} · ${score}</span>
 
151
  `;
152
  ideasEl.append(item);
153
  }
@@ -250,6 +331,13 @@ function setCommandDisabled(disabled) {
250
  });
251
  }
252
 
 
 
 
 
 
 
 
253
  async function exportTrace() {
254
  const client = await clientPromise;
255
  const result = await client.predict("/trace_artifact", {
@@ -377,6 +465,16 @@ function escapeHtml(value) {
377
  .replaceAll("'", "&#039;");
378
  }
379
 
 
 
 
 
 
 
 
 
 
 
380
  function shortDate(value) {
381
  if (!value) return "unknown";
382
  return String(value).replace("T", " ").replace(/\+00:00$/, "Z").slice(0, 16);
 
8
  const projectsEl = document.querySelector("#projects");
9
  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");
 
22
  let session = {};
23
  let clientPromise = Client.connect(window.location.origin);
24
  let currentArtifact = null;
25
+ let targetOptions = [];
26
+ let profileFields = [];
27
 
28
  bootstrap();
29
 
 
49
  await exportTrace();
50
  });
51
 
52
+ targetsEl.addEventListener("change", (event) => {
53
+ const target = event.target;
54
+ if (!(target instanceof HTMLInputElement) || !target.dataset.target) return;
55
+ const checked = new Set(
56
+ Array.from(targetsEl.querySelectorAll("input[data-target]:checked")).map((input) => input.dataset.target),
57
+ );
58
+ session.targets = targetOptions.filter((option) => checked.has(option));
59
+ syncCurrentIdeaTargets();
60
+ renderIdeas(session.ideas || []);
61
+ });
62
+
63
+ profileEl.addEventListener("input", (event) => {
64
+ const target = event.target;
65
+ if (!(target instanceof HTMLInputElement) || !target.dataset.profileField) return;
66
+ const profile = { ...(session.profile || {}) };
67
+ const value = target.value.trim();
68
+ if (value) {
69
+ profile[target.dataset.profileField] = value;
70
+ } else {
71
+ delete profile[target.dataset.profileField];
72
+ }
73
+ session.profile = profile;
74
+ });
75
+
76
  async function runTurn(message) {
77
  input.value = "";
78
  submit.disabled = true;
 
109
  async function bootstrap() {
110
  const response = await fetch("/api/bootstrap");
111
  const data = await response.json();
112
+ targetOptions = data.target_options || [];
113
+ profileFields = data.profile_fields || [];
114
+ session = {
115
+ profile: {},
116
+ targets: data.default_targets || targetOptions.slice(0, 3),
117
+ };
118
  renderProvenance(data);
119
+ renderTargets(session.targets);
120
+ renderProfile(session.profile);
121
  renderProjects(data.top_projects || []);
122
  renderWhitespace(data.whitespace || []);
123
  renderIdeas([]);
 
133
  provenanceEl.textContent = `${data.index_algorithm || "index"} · snapshot ${snapshot} · index ${index} · ${digest}`;
134
  }
135
 
136
+ function renderTargets(selectedTargets) {
137
+ const selected = new Set(selectedTargets || []);
138
+ targetsEl.innerHTML = "";
139
+ if (!targetOptions.length) {
140
+ targetsEl.innerHTML = `<div class="empty">No seals loaded.</div>`;
141
+ return;
142
+ }
143
+ for (const option of targetOptions) {
144
+ const label = document.createElement("label");
145
+ label.className = "target-toggle";
146
+ label.innerHTML = `
147
+ <input type="checkbox" data-target="${escapeAttribute(option)}" ${selected.has(option) ? "checked" : ""} />
148
+ <span>${escapeHtml(option)}</span>
149
+ `;
150
+ targetsEl.append(label);
151
+ }
152
+ }
153
+
154
+ function renderProfile(profile) {
155
+ profileEl.innerHTML = "";
156
+ if (!profileFields.length) {
157
+ profileEl.innerHTML = `<div class="empty">No profile fields.</div>`;
158
+ return;
159
+ }
160
+ for (const field of profileFields) {
161
+ const row = document.createElement("label");
162
+ row.className = "profile-field";
163
+ row.innerHTML = `
164
+ <span>${escapeHtml(fieldLabel(field))}</span>
165
+ <input
166
+ data-profile-field="${escapeAttribute(field)}"
167
+ value="${escapeAttribute(profile?.[field] || "")}"
168
+ autocomplete="off"
169
+ />
170
+ `;
171
+ profileEl.append(row);
172
+ }
173
+ }
174
+
175
  function handleEvent(event) {
176
  if (event.type === "start") {
177
  if (event.corrections?.length) {
 
189
 
190
  if (event.type === "done") {
191
  session = event.state || {};
192
+ session.profile = session.profile || {};
193
+ session.targets = Array.isArray(session.targets) ? session.targets : [];
194
  if (event.projects?.length) renderProjects(event.projects);
195
  if (event.whitespace?.length) renderWhitespace(event.whitespace);
196
+ renderTargets(session.targets);
197
+ renderProfile(session.profile);
198
  renderIdeas(session.ideas || []);
199
  renderTrace(session.trace || []);
200
  renderPlan(event.plan || []);
 
221
  }
222
  for (const idea of ideas.slice(-4).reverse()) {
223
  const score = idea.score?.overall ? Number(idea.score.overall).toFixed(1) : "0.0";
224
+ const targets = (idea.targets || []).slice(0, 3).join(" · ");
225
  const item = document.createElement("div");
226
  item.className = "idea";
227
  item.innerHTML = `
228
  <strong>${escapeHtml(idea.title)}</strong>
229
  <p>${escapeHtml((idea.pitch || "").slice(0, 120))}</p>
230
  <span>${escapeHtml(idea.score?.verdict || "DRAFT")} · ${score}</span>
231
+ ${targets ? `<small>${escapeHtml(targets)}</small>` : ""}
232
  `;
233
  ideasEl.append(item);
234
  }
 
331
  });
332
  }
333
 
334
+ function syncCurrentIdeaTargets() {
335
+ const currentId = session.current_idea_id;
336
+ if (!currentId || !Array.isArray(session.ideas)) return;
337
+ const idea = session.ideas.find((item) => item.id === currentId);
338
+ if (idea) idea.targets = [...(session.targets || [])];
339
+ }
340
+
341
  async function exportTrace() {
342
  const client = await clientPromise;
343
  const result = await client.predict("/trace_artifact", {
 
465
  .replaceAll("'", "&#039;");
466
  }
467
 
468
+ function escapeAttribute(value) {
469
+ return escapeHtml(value).replaceAll("`", "&#096;");
470
+ }
471
+
472
+ function fieldLabel(value) {
473
+ return String(value)
474
+ .replaceAll("_", " ")
475
+ .replace(/^\w/, (char) => char.toUpperCase());
476
+ }
477
+
478
  function shortDate(value) {
479
  if (!value) return "unknown";
480
  return String(value).replace("T", " ").replace(/\+00:00$/, "Z").slice(0, 16);
static/index.html CHANGED
@@ -46,6 +46,14 @@
46
  <div id="provenance" class="provenance"></div>
47
  <div id="score" class="score-grid" aria-label="Seal quadrants"></div>
48
  <div class="panels">
 
 
 
 
 
 
 
 
49
  <article>
50
  <h2>Idea Board</h2>
51
  <div id="ideas" class="idea-list"></div>
 
46
  <div id="provenance" class="provenance"></div>
47
  <div id="score" class="score-grid" aria-label="Seal quadrants"></div>
48
  <div class="panels">
49
+ <article>
50
+ <h2>Targets</h2>
51
+ <div id="targets" class="target-list"></div>
52
+ </article>
53
+ <article>
54
+ <h2>Profile</h2>
55
+ <div id="profile" class="profile-grid"></div>
56
+ </article>
57
  <article>
58
  <h2>Idea Board</h2>
59
  <div id="ideas" class="idea-list"></div>
static/styles.css CHANGED
@@ -278,7 +278,9 @@ button:disabled {
278
  .project-list,
279
  .whitespace-list,
280
  .idea-list,
281
- .trace-list {
 
 
282
  display: grid;
283
  gap: 9px;
284
  }
@@ -286,7 +288,9 @@ button:disabled {
286
  .project,
287
  .gap,
288
  .idea,
289
- .trace {
 
 
290
  border-left: 3px solid rgba(80, 47, 22, 0.48);
291
  padding: 8px 10px;
292
  background: rgba(255, 241, 196, 0.34);
@@ -322,6 +326,75 @@ button:disabled {
322
  letter-spacing: 0.04em;
323
  }
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  .plan-list {
326
  display: grid;
327
  gap: 7px;
@@ -370,6 +443,10 @@ button:disabled {
370
  grid-template-columns: 1fr;
371
  }
372
 
 
 
 
 
373
  .command-row {
374
  grid-template-columns: repeat(3, minmax(0, 1fr));
375
  }
 
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
  }
 
288
  .project,
289
  .gap,
290
  .idea,
291
+ .trace,
292
+ .target-toggle,
293
+ .profile-field {
294
  border-left: 3px solid rgba(80, 47, 22, 0.48);
295
  padding: 8px 10px;
296
  background: rgba(255, 241, 196, 0.34);
 
326
  letter-spacing: 0.04em;
327
  }
328
 
329
+ .idea small {
330
+ display: block;
331
+ margin-top: 4px;
332
+ color: #5f6d38;
333
+ font-size: 0.72rem;
334
+ line-height: 1.25;
335
+ font-weight: 900;
336
+ }
337
+
338
+ .target-list {
339
+ grid-template-columns: repeat(2, minmax(0, 1fr));
340
+ gap: 7px;
341
+ }
342
+
343
+ .target-toggle {
344
+ min-width: 0;
345
+ min-height: 36px;
346
+ display: flex;
347
+ align-items: center;
348
+ gap: 7px;
349
+ color: #2a170d;
350
+ font-size: 0.76rem;
351
+ line-height: 1.2;
352
+ font-weight: 900;
353
+ cursor: pointer;
354
+ }
355
+
356
+ .target-toggle input {
357
+ flex: 0 0 auto;
358
+ width: 16px;
359
+ height: 16px;
360
+ accent-color: var(--leaf);
361
+ }
362
+
363
+ .target-toggle span {
364
+ min-width: 0;
365
+ }
366
+
367
+ .profile-field {
368
+ min-width: 0;
369
+ display: grid;
370
+ grid-template-columns: 78px minmax(0, 1fr);
371
+ align-items: center;
372
+ gap: 8px;
373
+ }
374
+
375
+ .profile-field span {
376
+ color: var(--muted-ink);
377
+ font-size: 0.76rem;
378
+ line-height: 1.2;
379
+ font-weight: 900;
380
+ }
381
+
382
+ .profile-field input {
383
+ min-width: 0;
384
+ height: 32px;
385
+ border: 1px solid rgba(80, 47, 22, 0.32);
386
+ border-radius: 8px;
387
+ padding: 0 9px;
388
+ background: rgba(255, 243, 203, 0.48);
389
+ color: var(--ink);
390
+ outline: none;
391
+ }
392
+
393
+ .profile-field input:focus {
394
+ border-color: rgba(47, 122, 73, 0.72);
395
+ box-shadow: 0 0 0 3px rgba(47, 122, 73, 0.13);
396
+ }
397
+
398
  .plan-list {
399
  display: grid;
400
  gap: 7px;
 
443
  grid-template-columns: 1fr;
444
  }
445
 
446
+ .target-list {
447
+ grid-template-columns: 1fr;
448
+ }
449
+
450
  .command-row {
451
  grid-template-columns: repeat(3, minmax(0, 1fr));
452
  }
tests/test_agent.py CHANGED
@@ -106,6 +106,31 @@ def test_planner_profile_and_targets_update_state() -> None:
106
  assert targeted.state["targets"] == ["Off the Grid", "Field Notes"]
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  def test_planner_score_idea_scores_current_idea() -> None:
110
  index = ProjectIndex.from_files(Path("data/projects.json"), Path("data/project_index.json"))
111
  first = AdvisorEngine(index).turn("A local-first archive cartographer for family photos", {})
 
106
  assert targeted.state["targets"] == ["Off the Grid", "Field Notes"]
107
 
108
 
109
+ def test_session_targets_apply_to_new_and_current_ideas() -> None:
110
+ index = ProjectIndex.from_files(Path("data/projects.json"), Path("data/project_index.json"))
111
+ engine = AdvisorEngine(index)
112
+ state = {"targets": ["Field Notes"]}
113
+
114
+ first = engine.turn("A local-first archive cartographer for family photos", state)
115
+ first_idea = first.state["ideas"][0]
116
+ planned = engine.turn("make a build plan", first.state)
117
+
118
+ assert first_idea["targets"] == ["Field Notes"]
119
+ assert all("LoRA" not in step for step in planned.plan)
120
+
121
+
122
+ def test_well_tuned_target_adds_training_step_to_plan() -> None:
123
+ index = ProjectIndex.from_files(Path("data/projects.json"), Path("data/project_index.json"))
124
+ engine = AdvisorEngine(index)
125
+ state = {"targets": ["Well-Tuned"]}
126
+
127
+ first = engine.turn("A local-first archive cartographer for family photos", state)
128
+ planned = engine.turn("make a build plan", first.state)
129
+
130
+ assert first.state["ideas"][0]["targets"] == ["Well-Tuned"]
131
+ assert any("LoRA" in step for step in planned.plan)
132
+
133
+
134
  def test_planner_score_idea_scores_current_idea() -> None:
135
  index = ProjectIndex.from_files(Path("data/projects.json"), Path("data/project_index.json"))
136
  first = AdvisorEngine(index).turn("A local-first archive cartographer for family photos", {})
tests/test_app.py CHANGED
@@ -21,6 +21,8 @@ def test_bootstrap_exposes_index_metadata() -> None:
21
  assert payload["snapshot_digest"]
22
  assert payload["runtime"]["tool_count"] >= 8
23
  assert payload["top_projects"]
 
 
24
 
25
 
26
  def test_trace_artifact_endpoint_exports_jsonl() -> None:
 
21
  assert payload["snapshot_digest"]
22
  assert payload["runtime"]["tool_count"] >= 8
23
  assert payload["top_projects"]
24
+ assert payload["default_targets"] == payload["target_options"][:3]
25
+ assert "skills" in payload["profile_fields"]
26
 
27
 
28
  def test_trace_artifact_endpoint_exports_jsonl() -> None: