JacobLinCool commited on
Commit
ffcf6c4
·
verified ·
1 Parent(s): 2bc6ea9

feat: add atlas project search

Browse files
app.py CHANGED
@@ -30,6 +30,12 @@ from hackathon_advisor.dashboard_storage import (
30
  persist_refresh_artifacts,
31
  require_writable_cache_dir,
32
  )
 
 
 
 
 
 
33
  from hackathon_advisor.data import (
34
  DEFAULT_EMBEDDING_MODEL_FILE,
35
  DEFAULT_EMBEDDING_MODEL_REPO,
@@ -120,6 +126,7 @@ def _load_initial_runtime() -> tuple[ProjectIndex, dict[str, Any]]:
120
 
121
 
122
  index, dashboard_payload = _load_initial_runtime()
 
123
 
124
  # Acceleration is automatic: on a ZeroGPU Space the GPU path uses accelerate device_map inside
125
  # the @spaces.GPU fork; locally the device resolves CUDA -> Apple MPS -> CPU. CPU is only used
@@ -775,14 +782,16 @@ def _format_refresh_error(error: BaseException) -> str:
775
 
776
 
777
  def _replace_runtime_from_files(projects_path: Path, index_path: Path, refreshed_dashboard: dict[str, Any]) -> None:
778
- global index, engine, _cpu_engine, dashboard_payload
779
  new_index = ProjectIndex.from_files(projects_path, index_path)
 
780
  with _runtime_lock:
781
  index = new_index
782
  engine = AdvisorEngine(new_index, engine.planner)
783
  if _cpu_engine is not None:
784
  _cpu_engine = AdvisorEngine(new_index, _cpu_engine.planner)
785
  dashboard_payload = refreshed_dashboard
 
786
 
787
 
788
  def _public_dashboard_payload(payload: dict[str, Any]) -> dict[str, Any]:
@@ -900,6 +909,34 @@ def dashboard() -> dict:
900
  return payload
901
 
902
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
903
  @app.post("/api/dashboard/refresh")
904
  def dashboard_refresh_start(payload: dict[str, Any] | None = None) -> JSONResponse:
905
  try:
 
30
  persist_refresh_artifacts,
31
  require_writable_cache_dir,
32
  )
33
+ from hackathon_advisor.dashboard_search import (
34
+ DEFAULT_SEARCH_LIMIT,
35
+ DashboardSearchIndex,
36
+ normalize_query,
37
+ normalize_search_limit,
38
+ )
39
  from hackathon_advisor.data import (
40
  DEFAULT_EMBEDDING_MODEL_FILE,
41
  DEFAULT_EMBEDDING_MODEL_REPO,
 
126
 
127
 
128
  index, dashboard_payload = _load_initial_runtime()
129
+ dashboard_search_index = DashboardSearchIndex(index.projects, dashboard_payload)
130
 
131
  # Acceleration is automatic: on a ZeroGPU Space the GPU path uses accelerate device_map inside
132
  # the @spaces.GPU fork; locally the device resolves CUDA -> Apple MPS -> CPU. CPU is only used
 
782
 
783
 
784
  def _replace_runtime_from_files(projects_path: Path, index_path: Path, refreshed_dashboard: dict[str, Any]) -> None:
785
+ global index, engine, _cpu_engine, dashboard_payload, dashboard_search_index
786
  new_index = ProjectIndex.from_files(projects_path, index_path)
787
+ new_search_index = DashboardSearchIndex(new_index.projects, refreshed_dashboard)
788
  with _runtime_lock:
789
  index = new_index
790
  engine = AdvisorEngine(new_index, engine.planner)
791
  if _cpu_engine is not None:
792
  _cpu_engine = AdvisorEngine(new_index, _cpu_engine.planner)
793
  dashboard_payload = refreshed_dashboard
794
+ dashboard_search_index = new_search_index
795
 
796
 
797
  def _public_dashboard_payload(payload: dict[str, Any]) -> dict[str, Any]:
 
909
  return payload
910
 
911
 
912
+ @app.get("/api/dashboard/search")
913
+ def dashboard_search(q: str = "", limit: int = DEFAULT_SEARCH_LIMIT) -> dict:
914
+ query = normalize_query(q)
915
+ if not query:
916
+ raise HTTPException(status_code=400, detail="Search query is required.")
917
+ try:
918
+ normalized_limit = normalize_search_limit(limit)
919
+ except ValueError as error:
920
+ raise HTTPException(status_code=400, detail=str(error)) from error
921
+ with _runtime_lock:
922
+ search_index = dashboard_search_index
923
+ current_dashboard = dashboard_payload
924
+ payload = search_index.search(query, limit=normalized_limit)
925
+ public_points = {
926
+ str(point.get("id") or ""): _public_dashboard_point(point)
927
+ for point in current_dashboard.get("points") or []
928
+ if isinstance(point, dict)
929
+ }
930
+ for result in payload["results"]:
931
+ result["point"] = public_points.get(str(result.get("project_id") or ""), {})
932
+ provenance = current_dashboard.get("provenance", {})
933
+ payload["provenance"] = {
934
+ "snapshot_digest": str(provenance.get("snapshot_digest") or ""),
935
+ "snapshot_generated_at": str(provenance.get("snapshot_generated_at") or ""),
936
+ }
937
+ return payload
938
+
939
+
940
  @app.post("/api/dashboard/refresh")
941
  def dashboard_refresh_start(payload: dict[str, Any] | None = None) -> JSONResponse:
942
  try:
hackathon_advisor/dashboard_search.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from collections.abc import Mapping, Sequence
5
+ from dataclasses import dataclass
6
+ import math
7
+ import re
8
+ import unicodedata
9
+ from typing import Any
10
+
11
+ from hackathon_advisor.data import Project, public_project_summary, public_project_title
12
+
13
+
14
+ SEARCH_SCHEMA_VERSION = 1
15
+ SEARCH_ALGORITHM = "bm25-text-v1"
16
+ DEFAULT_SEARCH_LIMIT = 12
17
+ MAX_SEARCH_LIMIT = 30
18
+ BM25_K1 = 1.35
19
+ BM25_B = 0.72
20
+ MAX_SNIPPET_CHARS = 170
21
+
22
+ SEARCH_TOKEN_RE = re.compile(r"[\w][\w.+-]*", re.UNICODE)
23
+ TOKEN_SPLIT_RE = re.compile(r"[._+\-/]+")
24
+ HIGHLIGHT_BOUNDARY_RE = re.compile(r"\s+")
25
+
26
+ STOPWORDS = {
27
+ "a",
28
+ "an",
29
+ "and",
30
+ "are",
31
+ "as",
32
+ "at",
33
+ "be",
34
+ "by",
35
+ "for",
36
+ "from",
37
+ "in",
38
+ "into",
39
+ "is",
40
+ "it",
41
+ "its",
42
+ "of",
43
+ "on",
44
+ "or",
45
+ "that",
46
+ "the",
47
+ "their",
48
+ "this",
49
+ "to",
50
+ "with",
51
+ "you",
52
+ "your",
53
+ }
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class SearchField:
58
+ source: str
59
+ text: str
60
+ weight: float
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class SearchDocument:
65
+ project: Project
66
+ fields: tuple[SearchField, ...]
67
+ term_counts: Counter[str]
68
+ length: float
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class DashboardSearchHit:
73
+ project: Project
74
+ score: float
75
+ matched_terms: tuple[str, ...]
76
+ snippets: tuple[dict[str, str], ...]
77
+
78
+ def to_dict(self) -> dict[str, Any]:
79
+ return {
80
+ "project": self.project.to_public_dict(),
81
+ "project_id": self.project.id,
82
+ "title": public_project_title(self.project.title),
83
+ "summary": public_project_summary(self.project.summary),
84
+ "url": self.project.url,
85
+ "score": round(self.score, 4),
86
+ "matched_terms": list(self.matched_terms),
87
+ "snippets": [dict(snippet) for snippet in self.snippets],
88
+ }
89
+
90
+
91
+ class DashboardSearchIndex:
92
+ def __init__(self, projects: Sequence[Project], dashboard_payload: Mapping[str, Any]) -> None:
93
+ point_by_id = _point_by_project_id(dashboard_payload)
94
+ cluster_by_id = _cluster_by_id(dashboard_payload)
95
+ quest_label_by_id = _quest_label_by_id(dashboard_payload)
96
+ self.documents = tuple(
97
+ _build_document(
98
+ project,
99
+ point_by_id,
100
+ cluster_by_id,
101
+ quest_label_by_id,
102
+ )
103
+ for project in projects
104
+ )
105
+ if not self.documents:
106
+ raise ValueError("dashboard search index requires at least one project")
107
+ self.document_count = len(self.documents)
108
+ self.average_length = (
109
+ sum(document.length for document in self.documents) / self.document_count
110
+ )
111
+ self.document_frequency = _document_frequency(self.documents)
112
+ self.index_metadata = {
113
+ "schema_version": SEARCH_SCHEMA_VERSION,
114
+ "algorithm": SEARCH_ALGORITHM,
115
+ "document_count": self.document_count,
116
+ }
117
+
118
+ def search(self, query: str, limit: int = DEFAULT_SEARCH_LIMIT) -> dict[str, Any]:
119
+ normalized_query = normalize_query(query)
120
+ terms = tuple(dict.fromkeys(search_tokens(normalized_query)))
121
+ if not terms:
122
+ return {
123
+ "schema_version": SEARCH_SCHEMA_VERSION,
124
+ "algorithm": SEARCH_ALGORITHM,
125
+ "query": normalized_query,
126
+ "total": 0,
127
+ "results": [],
128
+ }
129
+
130
+ scored: list[tuple[float, SearchDocument]] = []
131
+ for document in self.documents:
132
+ score = self._score_document(document, terms, normalized_query)
133
+ if score > 0:
134
+ scored.append((score, document))
135
+ scored.sort(
136
+ key=lambda item: (
137
+ item[0],
138
+ item[1].project.likes,
139
+ item[1].project.title.casefold(),
140
+ ),
141
+ reverse=True,
142
+ )
143
+ raw_top_score = scored[0][0] if scored else 0.0
144
+ results = [
145
+ DashboardSearchHit(
146
+ project=document.project,
147
+ score=raw_score / raw_top_score if raw_top_score else 0.0,
148
+ matched_terms=tuple(
149
+ term for term in terms if document.term_counts.get(term, 0) > 0
150
+ )[:8],
151
+ snippets=tuple(_snippets(document, terms)),
152
+ ).to_dict()
153
+ for raw_score, document in scored[:limit]
154
+ ]
155
+ return {
156
+ "schema_version": SEARCH_SCHEMA_VERSION,
157
+ "algorithm": SEARCH_ALGORITHM,
158
+ "query": normalized_query,
159
+ "total": len(scored),
160
+ "results": results,
161
+ }
162
+
163
+ def _score_document(
164
+ self,
165
+ document: SearchDocument,
166
+ terms: Sequence[str],
167
+ normalized_query: str,
168
+ ) -> float:
169
+ score = 0.0
170
+ length = max(document.length, 1.0)
171
+ average_length = max(self.average_length, 1.0)
172
+ for term in terms:
173
+ frequency = float(document.term_counts.get(term, 0.0))
174
+ if frequency <= 0:
175
+ continue
176
+ idf = self._idf(term)
177
+ denominator = frequency + BM25_K1 * (1.0 - BM25_B + BM25_B * length / average_length)
178
+ score += idf * ((frequency * (BM25_K1 + 1.0)) / denominator)
179
+
180
+ query_for_exact = normalized_query.casefold()
181
+ if query_for_exact:
182
+ title = public_project_title(document.project.title).casefold()
183
+ slug = document.project.slug.replace("-", " ").replace("_", " ").casefold()
184
+ if query_for_exact in title:
185
+ score += 2.0
186
+ if query_for_exact in slug:
187
+ score += 1.4
188
+ return score
189
+
190
+ def _idf(self, term: str) -> float:
191
+ frequency = self.document_frequency.get(term, 0)
192
+ return math.log(1.0 + (self.document_count - frequency + 0.5) / (frequency + 0.5))
193
+
194
+
195
+ def normalize_query(query: str) -> str:
196
+ return " ".join(str(query or "").split())
197
+
198
+
199
+ def normalize_search_limit(value: Any) -> int:
200
+ try:
201
+ limit = int(value)
202
+ except (TypeError, ValueError) as error:
203
+ raise ValueError("search limit must be an integer") from error
204
+ if not 1 <= limit <= MAX_SEARCH_LIMIT:
205
+ raise ValueError(f"search limit must be between 1 and {MAX_SEARCH_LIMIT}")
206
+ return limit
207
+
208
+
209
+ def search_tokens(text: str) -> list[str]:
210
+ tokens: list[str] = []
211
+ normalized = unicodedata.normalize("NFKC", str(text or "")).casefold()
212
+ for raw_token in SEARCH_TOKEN_RE.findall(normalized):
213
+ for token in _token_variants(raw_token):
214
+ if (len(token) <= 1 and not token.isdigit()) or token in STOPWORDS:
215
+ continue
216
+ tokens.append(token)
217
+ return tokens
218
+
219
+
220
+ def _token_variants(raw_token: str) -> tuple[str, ...]:
221
+ cleaned = raw_token.strip("._+-/")
222
+ if not cleaned:
223
+ return ()
224
+ parts = tuple(part for part in TOKEN_SPLIT_RE.split(cleaned) if len(part) > 1)
225
+ if parts and parts != (cleaned,):
226
+ return (cleaned, *parts)
227
+ return (cleaned,)
228
+
229
+
230
+ def _document_frequency(documents: Sequence[SearchDocument]) -> dict[str, int]:
231
+ frequency: Counter[str] = Counter()
232
+ for document in documents:
233
+ frequency.update(document.term_counts.keys())
234
+ return dict(frequency)
235
+
236
+
237
+ def _build_document(
238
+ project: Project,
239
+ point_by_id: Mapping[str, Mapping[str, Any]],
240
+ cluster_by_id: Mapping[str, Mapping[str, Any]],
241
+ quest_label_by_id: Mapping[str, str],
242
+ ) -> SearchDocument:
243
+ point = point_by_id.get(project.id, {})
244
+ fields = _project_fields(project, point, cluster_by_id, quest_label_by_id)
245
+ term_counts: Counter[str] = Counter()
246
+ for field in fields:
247
+ for token in search_tokens(field.text):
248
+ term_counts[token] += field.weight
249
+ return SearchDocument(
250
+ project=project,
251
+ fields=fields,
252
+ term_counts=term_counts,
253
+ length=sum(term_counts.values()),
254
+ )
255
+
256
+
257
+ def _point_by_project_id(dashboard_payload: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]:
258
+ return {
259
+ str(point.get("id") or ""): point
260
+ for point in dashboard_payload.get("points") or []
261
+ if isinstance(point, Mapping)
262
+ }
263
+
264
+
265
+ def _project_fields(
266
+ project: Project,
267
+ point: Mapping[str, Any],
268
+ cluster_by_id: Mapping[str, Mapping[str, Any]],
269
+ quest_labels: Mapping[str, str],
270
+ ) -> tuple[SearchField, ...]:
271
+ cluster = cluster_by_id.get(str(point.get("cluster_id") or ""), {})
272
+ quest_texts = []
273
+ for match in point.get("quest_matches") or []:
274
+ if not isinstance(match, Mapping):
275
+ continue
276
+ quest = str(match.get("quest") or "")
277
+ quest_texts.append(
278
+ " ".join(
279
+ [
280
+ quest_labels.get(quest, quest),
281
+ str(match.get("evidence") or ""),
282
+ ]
283
+ ).strip()
284
+ )
285
+
286
+ return tuple(
287
+ field
288
+ for field in [
289
+ SearchField("Title", public_project_title(project.title), 4.0),
290
+ SearchField(
291
+ "Space",
292
+ " ".join(
293
+ [
294
+ project.id,
295
+ project.slug,
296
+ project.slug.replace("-", " ").replace("_", " "),
297
+ ]
298
+ ),
299
+ 3.2,
300
+ ),
301
+ SearchField("Summary", public_project_summary(project.summary), 2.4),
302
+ SearchField(
303
+ "Tags",
304
+ " ".join([*project.tags, *project.models, *project.datasets, project.sdk]),
305
+ 2.0,
306
+ ),
307
+ SearchField(
308
+ "Cluster",
309
+ " ".join(
310
+ [
311
+ str(cluster.get("label") or ""),
312
+ " ".join(str(keyword) for keyword in cluster.get("keywords") or []),
313
+ ]
314
+ ),
315
+ 1.4,
316
+ ),
317
+ SearchField("Quest evidence", " ".join(quest_texts), 1.6),
318
+ SearchField(
319
+ "App",
320
+ " ".join(
321
+ [
322
+ project.app_file,
323
+ project.app_file_embedding_text,
324
+ project.app_file_source,
325
+ ]
326
+ ),
327
+ 1.0,
328
+ ),
329
+ SearchField("README", project.readme_body, 0.9),
330
+ ]
331
+ if field.text.strip()
332
+ )
333
+
334
+
335
+ def _cluster_by_id(dashboard_payload: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]:
336
+ return {
337
+ str(cluster.get("id") or ""): cluster
338
+ for cluster in dashboard_payload.get("clusters") or []
339
+ if isinstance(cluster, Mapping)
340
+ }
341
+
342
+
343
+ def _quest_label_by_id(dashboard_payload: Mapping[str, Any]) -> dict[str, str]:
344
+ quest_report = dashboard_payload.get("quest_report")
345
+ if not isinstance(quest_report, Mapping):
346
+ return {}
347
+ return {
348
+ str(quest.get("id") or ""): str(quest.get("label") or quest.get("id") or "")
349
+ for quest in quest_report.get("quests") or []
350
+ if isinstance(quest, Mapping)
351
+ }
352
+
353
+
354
+ def _snippets(document: SearchDocument, terms: Sequence[str]) -> list[dict[str, str]]:
355
+ snippets: list[dict[str, str]] = []
356
+ seen_sources: set[str] = set()
357
+ for field in document.fields:
358
+ field_terms = set(search_tokens(field.text))
359
+ if not field_terms.intersection(terms):
360
+ continue
361
+ if field.source in seen_sources:
362
+ continue
363
+ snippet = _field_snippet(field.text, terms)
364
+ if not snippet:
365
+ continue
366
+ snippets.append({"source": field.source, "text": snippet})
367
+ seen_sources.add(field.source)
368
+ if len(snippets) >= 2:
369
+ break
370
+ return snippets
371
+
372
+
373
+ def _field_snippet(text: str, terms: Sequence[str]) -> str:
374
+ cleaned = HIGHLIGHT_BOUNDARY_RE.sub(" ", str(text or "")).strip()
375
+ if not cleaned:
376
+ return ""
377
+ folded = unicodedata.normalize("NFKC", cleaned).casefold()
378
+ indexes = [folded.find(term) for term in terms if folded.find(term) >= 0]
379
+ center = min(indexes) if indexes else 0
380
+ start = max(0, center - MAX_SNIPPET_CHARS // 2)
381
+ end = min(len(cleaned), start + MAX_SNIPPET_CHARS)
382
+ start = max(0, end - MAX_SNIPPET_CHARS)
383
+ snippet = cleaned[start:end].strip()
384
+ if start > 0:
385
+ snippet = f"... {snippet}"
386
+ if end < len(cleaned):
387
+ snippet = f"{snippet} ..."
388
+ return snippet
static/app.js CHANGED
@@ -5,6 +5,12 @@ const openAdvisorButton = document.querySelector("#open-advisor");
5
  const openAtlasButton = document.querySelector("#open-atlas");
6
  const refreshDashboardButton = document.querySelector("#refresh-dashboard");
7
  const atlasStatusEl = document.querySelector("#atlas-status");
 
 
 
 
 
 
8
  const atlasStatsEl = document.querySelector("#atlas-stats");
9
  const atlasClustersEl = document.querySelector("#atlas-clusters");
10
  const atlasQuestsEl = document.querySelector("#atlas-quests");
@@ -79,6 +85,13 @@ let selectedClusterId = "";
79
  let selectedQuestId = "";
80
  let selectedProjectId = "";
81
  let dashboardRefreshTimer = null;
 
 
 
 
 
 
 
82
 
83
  setVoiceRecordingState("idle");
84
  setupViewRouting();
@@ -140,6 +153,19 @@ refreshDashboardButton?.addEventListener("click", async () => {
140
  await startDashboardRefresh();
141
  });
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  recordVoiceButton.addEventListener("click", async () => {
144
  await toggleVoiceRecording();
145
  });
@@ -226,8 +252,9 @@ async function loadDashboard() {
226
  }
227
 
228
  function handleDashboardError(error) {
 
229
  dashboardData = null;
230
- if (atlasStatusEl) atlasStatusEl.textContent = `Atlas could not load: ${error.message}`;
231
  if (atlasSvgEl) atlasSvgEl.innerHTML = "";
232
  if (atlasStatsEl) atlasStatsEl.innerHTML = "";
233
  if (atlasDetailEl) atlasDetailEl.innerHTML = `<p>Reload the page to try again.</p>`;
@@ -244,11 +271,9 @@ async function startDashboardRefresh() {
244
  renderRefreshState(data);
245
  scheduleRefreshPoll();
246
  } catch (error) {
247
- if (atlasStatusEl) atlasStatusEl.textContent = `Refresh could not start: ${error.message}`;
248
- if (atlasRefreshProgressEl) {
249
- atlasRefreshProgressEl.hidden = false;
250
- atlasRefreshProgressEl.textContent = error.message;
251
- }
252
  refreshDashboardButton.disabled = false;
253
  }
254
  }
@@ -272,7 +297,8 @@ async function pollDashboardRefresh() {
272
  await loadDashboard();
273
  }
274
  } catch (error) {
275
- if (atlasStatusEl) atlasStatusEl.textContent = `Refresh status unavailable: ${error.message}`;
 
276
  } finally {
277
  if (_refreshIsSettled()) refreshDashboardButton.disabled = false;
278
  }
@@ -293,19 +319,20 @@ function renderRefreshState(state) {
293
  } else if (status === "succeeded") {
294
  atlasStatusEl.textContent = `Atlas refreshed: ${state.result?.project_count || "current"} projects mapped.`;
295
  } else if (status === "failed") {
296
- atlasStatusEl.textContent = `Refresh failed: ${state.error || "unknown error"}`;
 
297
  } else if (dashboardData) {
298
- atlasStatusEl.textContent = atlasProvenanceCopy(dashboardData);
299
  }
300
  }
301
  if (atlasRefreshProgressEl) {
302
- const show = status === "running" || status === "failed";
303
  const cacheCopy = refreshQuestCacheCopy(state?.quest_cache || {});
304
  atlasRefreshProgressEl.hidden = !show;
305
  atlasRefreshProgressEl.textContent =
306
  status === "running"
307
  ? `${stage || "Working"}${cacheCopy ? ` · ${cacheCopy}` : ""} · run ${state.run_id || ""}`
308
- : state.error || "";
309
  }
310
  if (refreshDashboardButton) refreshDashboardButton.disabled = status === "running";
311
  }
@@ -322,6 +349,117 @@ function refreshQuestCacheCopy(cache) {
322
  return `${hits} cached, ${analyzed} analyzed`;
323
  }
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  function renderDashboard(data) {
326
  if (!data?.points?.length) {
327
  handleDashboardError(new Error("empty dashboard payload"));
@@ -334,7 +472,8 @@ function renderDashboard(data) {
334
  renderAtlasSvg(data);
335
  renderAtlasDetail(currentAtlasPoint(data));
336
  renderAtlasReport(data);
337
- if (atlasStatusEl) atlasStatusEl.textContent = atlasProvenanceCopy(data);
 
338
  }
339
 
340
  function atlasProvenanceCopy(data) {
@@ -445,6 +584,16 @@ function renderAtlasSvg(data) {
445
  atlasSvgEl.append(line);
446
  }
447
 
 
 
 
 
 
 
 
 
 
 
448
  for (const point of data.points || []) {
449
  const circle = svgEl("circle");
450
  circle.setAttribute("cx", point.x);
@@ -453,7 +602,9 @@ function renderAtlasSvg(data) {
453
  circle.setAttribute("fill", atlasColor(clusterIndex.get(point.cluster_id) || 0));
454
  circle.setAttribute(
455
  "class",
456
- `atlas-dot ${visible.has(point.id) ? "" : "dim"} ${point.id === selectedProjectId ? "selected" : ""}`,
 
 
457
  );
458
  circle.setAttribute("tabindex", "0");
459
  circle.setAttribute("role", "button");
@@ -482,11 +633,21 @@ function visibleAtlasPoints(data) {
482
  return (data.points || []).filter((point) => {
483
  const clusterMatch = !selectedClusterId || point.cluster_id === selectedClusterId;
484
  const questMatch = !selectedQuestId || (point.quest_ids || []).includes(selectedQuestId);
485
- return clusterMatch && questMatch;
 
486
  });
487
  }
488
 
489
  function labelAtlasPoints(data) {
 
 
 
 
 
 
 
 
 
490
  const visible = visibleAtlasPoints(data);
491
  return [...visible].sort((left, right) => Number(right.likes || 0) - Number(left.likes || 0)).slice(0, 12);
492
  }
@@ -585,7 +746,11 @@ function atlasQuestLabel(questId) {
585
  }
586
 
587
  function atlasPointRadius(point) {
588
- return (0.62 + Math.min(0.72, Math.sqrt(Number(point.likes || 0)) * 0.12)).toFixed(3);
 
 
 
 
589
  }
590
 
591
  function atlasShortTitle(title) {
 
5
  const openAtlasButton = document.querySelector("#open-atlas");
6
  const refreshDashboardButton = document.querySelector("#refresh-dashboard");
7
  const atlasStatusEl = document.querySelector("#atlas-status");
8
+ const atlasSearchForm = document.querySelector("#atlas-search-form");
9
+ const atlasSearchInput = document.querySelector("#atlas-search");
10
+ const atlasSearchClearButton = document.querySelector("#atlas-search-clear");
11
+ const atlasSearchSectionEl = document.querySelector("#atlas-search-section");
12
+ const atlasSearchSummaryEl = document.querySelector("#atlas-search-summary");
13
+ const atlasSearchResultsEl = document.querySelector("#atlas-search-results");
14
  const atlasStatsEl = document.querySelector("#atlas-stats");
15
  const atlasClustersEl = document.querySelector("#atlas-clusters");
16
  const atlasQuestsEl = document.querySelector("#atlas-quests");
 
85
  let selectedQuestId = "";
86
  let selectedProjectId = "";
87
  let dashboardRefreshTimer = null;
88
+ let atlasSearchQuery = "";
89
+ let atlasSearchResults = [];
90
+ let atlasSearchResultIds = new Set();
91
+ let atlasSearchTimer = null;
92
+ let atlasSearchController = null;
93
+ let atlasSearchUnavailable = false;
94
+ let atlasSearchBusy = false;
95
 
96
  setVoiceRecordingState("idle");
97
  setupViewRouting();
 
153
  await startDashboardRefresh();
154
  });
155
 
156
+ atlasSearchForm?.addEventListener("submit", (event) => {
157
+ event.preventDefault();
158
+ runAtlasSearch(atlasSearchInput?.value || "");
159
+ });
160
+
161
+ atlasSearchInput?.addEventListener("input", () => {
162
+ scheduleAtlasSearch(atlasSearchInput.value || "");
163
+ });
164
+
165
+ atlasSearchClearButton?.addEventListener("click", () => {
166
+ clearAtlasSearch();
167
+ });
168
+
169
  recordVoiceButton.addEventListener("click", async () => {
170
  await toggleVoiceRecording();
171
  });
 
252
  }
253
 
254
  function handleDashboardError(error) {
255
+ console.error("Atlas could not load.", error);
256
  dashboardData = null;
257
+ if (atlasStatusEl) atlasStatusEl.textContent = "Atlas could not load.";
258
  if (atlasSvgEl) atlasSvgEl.innerHTML = "";
259
  if (atlasStatsEl) atlasStatsEl.innerHTML = "";
260
  if (atlasDetailEl) atlasDetailEl.innerHTML = `<p>Reload the page to try again.</p>`;
 
271
  renderRefreshState(data);
272
  scheduleRefreshPoll();
273
  } catch (error) {
274
+ console.error("Dashboard refresh could not start.", error);
275
+ if (atlasStatusEl) atlasStatusEl.textContent = "Refresh could not start.";
276
+ if (atlasRefreshProgressEl) atlasRefreshProgressEl.hidden = true;
 
 
277
  refreshDashboardButton.disabled = false;
278
  }
279
  }
 
297
  await loadDashboard();
298
  }
299
  } catch (error) {
300
+ console.error("Dashboard refresh status unavailable.", error);
301
+ if (atlasStatusEl) atlasStatusEl.textContent = "Refresh status unavailable.";
302
  } finally {
303
  if (_refreshIsSettled()) refreshDashboardButton.disabled = false;
304
  }
 
319
  } else if (status === "succeeded") {
320
  atlasStatusEl.textContent = `Atlas refreshed: ${state.result?.project_count || "current"} projects mapped.`;
321
  } else if (status === "failed") {
322
+ if (state.error) console.error("Dashboard refresh failed.", state.error);
323
+ atlasStatusEl.textContent = "Refresh did not complete; current map is unchanged.";
324
  } else if (dashboardData) {
325
+ atlasStatusEl.textContent = atlasSearchQuery ? atlasSearchStatusCopy() : atlasProvenanceCopy(dashboardData);
326
  }
327
  }
328
  if (atlasRefreshProgressEl) {
329
+ const show = status === "running";
330
  const cacheCopy = refreshQuestCacheCopy(state?.quest_cache || {});
331
  atlasRefreshProgressEl.hidden = !show;
332
  atlasRefreshProgressEl.textContent =
333
  status === "running"
334
  ? `${stage || "Working"}${cacheCopy ? ` · ${cacheCopy}` : ""} · run ${state.run_id || ""}`
335
+ : "";
336
  }
337
  if (refreshDashboardButton) refreshDashboardButton.disabled = status === "running";
338
  }
 
349
  return `${hits} cached, ${analyzed} analyzed`;
350
  }
351
 
352
+ function scheduleAtlasSearch(rawQuery) {
353
+ const query = String(rawQuery || "").trim();
354
+ if (atlasSearchTimer) window.clearTimeout(atlasSearchTimer);
355
+ if (!query) {
356
+ clearAtlasSearch();
357
+ return;
358
+ }
359
+ atlasSearchTimer = window.setTimeout(() => runAtlasSearch(query), 260);
360
+ }
361
+
362
+ async function runAtlasSearch(rawQuery) {
363
+ const query = String(rawQuery || "").trim();
364
+ if (!query) {
365
+ clearAtlasSearch();
366
+ return;
367
+ }
368
+ atlasSearchQuery = query;
369
+ atlasSearchUnavailable = false;
370
+ atlasSearchBusy = true;
371
+ renderAtlasSearch();
372
+ if (atlasSearchController) atlasSearchController.abort();
373
+ atlasSearchController = new AbortController();
374
+ try {
375
+ const response = await fetch(`/api/dashboard/search?q=${encodeURIComponent(query)}&limit=12`, {
376
+ signal: atlasSearchController.signal,
377
+ });
378
+ if (!response.ok) throw new Error(`search failed with ${response.status}`);
379
+ const payload = await response.json();
380
+ if (query !== String(atlasSearchInput?.value || "").trim()) return;
381
+ atlasSearchResults = payload.results || [];
382
+ atlasSearchResultIds = new Set(atlasSearchResults.map((result) => result.project_id).filter(Boolean));
383
+ atlasSearchUnavailable = false;
384
+ atlasSearchBusy = false;
385
+ if (atlasSearchResults.length) selectedProjectId = atlasSearchResults[0].project_id || selectedProjectId;
386
+ if (dashboardData) renderDashboard(dashboardData);
387
+ } catch (error) {
388
+ if (error.name === "AbortError") return;
389
+ console.error("Atlas search failed.", error);
390
+ atlasSearchResults = [];
391
+ atlasSearchResultIds = new Set();
392
+ atlasSearchUnavailable = true;
393
+ atlasSearchBusy = false;
394
+ if (dashboardData) renderDashboard(dashboardData);
395
+ }
396
+ }
397
+
398
+ function clearAtlasSearch() {
399
+ if (atlasSearchTimer) window.clearTimeout(atlasSearchTimer);
400
+ atlasSearchTimer = null;
401
+ if (atlasSearchController) atlasSearchController.abort();
402
+ atlasSearchController = null;
403
+ atlasSearchQuery = "";
404
+ atlasSearchResults = [];
405
+ atlasSearchResultIds = new Set();
406
+ atlasSearchUnavailable = false;
407
+ atlasSearchBusy = false;
408
+ if (atlasSearchInput) atlasSearchInput.value = "";
409
+ if (dashboardData) renderDashboard(dashboardData);
410
+ }
411
+
412
+ function atlasSearchStatusCopy() {
413
+ if (!atlasSearchQuery) return dashboardData ? atlasProvenanceCopy(dashboardData) : "";
414
+ if (atlasSearchBusy) return "Searching.";
415
+ if (atlasSearchUnavailable) return "Search unavailable.";
416
+ if (!atlasSearchResults.length) return `No matches for "${atlasSearchQuery}".`;
417
+ return `${atlasSearchResults.length} matches for "${atlasSearchQuery}".`;
418
+ }
419
+
420
+ function renderAtlasSearch() {
421
+ if (!atlasSearchSectionEl || !atlasSearchResultsEl || !atlasSearchSummaryEl) return;
422
+ const active = Boolean(atlasSearchQuery);
423
+ atlasSearchSectionEl.hidden = !active;
424
+ if (atlasSearchClearButton) atlasSearchClearButton.hidden = !active;
425
+ if (!active) {
426
+ atlasSearchResultsEl.innerHTML = "";
427
+ atlasSearchSummaryEl.textContent = "";
428
+ return;
429
+ }
430
+ atlasSearchSummaryEl.textContent = atlasSearchStatusCopy();
431
+ atlasSearchResultsEl.innerHTML = "";
432
+ if (atlasSearchUnavailable || !atlasSearchResults.length) return;
433
+ for (const result of atlasSearchResults.slice(0, 8)) {
434
+ atlasSearchResultsEl.append(atlasSearchResultButton(result));
435
+ }
436
+ }
437
+
438
+ function atlasSearchResultButton(result) {
439
+ const button = document.createElement("button");
440
+ button.type = "button";
441
+ button.className = `atlas-search-result ${result.project_id === selectedProjectId ? "active" : ""}`;
442
+ const title = result.title || result.project?.title || result.project_id || "Untitled project";
443
+ const terms = (result.matched_terms || []).slice(0, 4).join(", ");
444
+ const snippet = (result.snippets || [])[0];
445
+ const width = Math.max(8, Math.min(100, Number(result.score || 0) * 100)).toFixed(0);
446
+ button.innerHTML = `
447
+ <strong>${escapeHtml(title)}</strong>
448
+ <span class="atlas-search-meta">${escapeHtml(terms || "Related project")}</span>
449
+ <span class="atlas-search-score" aria-hidden="true"><i style="width: ${width}%"></i></span>
450
+ ${
451
+ snippet
452
+ ? `<span class="atlas-search-snippet">${escapeHtml(snippet.source)}: ${escapeHtml(snippet.text)}</span>`
453
+ : ""
454
+ }
455
+ `;
456
+ button.addEventListener("click", () => {
457
+ selectedProjectId = result.project_id || selectedProjectId;
458
+ if (dashboardData) renderDashboard(dashboardData);
459
+ });
460
+ return button;
461
+ }
462
+
463
  function renderDashboard(data) {
464
  if (!data?.points?.length) {
465
  handleDashboardError(new Error("empty dashboard payload"));
 
472
  renderAtlasSvg(data);
473
  renderAtlasDetail(currentAtlasPoint(data));
474
  renderAtlasReport(data);
475
+ renderAtlasSearch();
476
+ if (atlasStatusEl) atlasStatusEl.textContent = atlasSearchQuery ? atlasSearchStatusCopy() : atlasProvenanceCopy(data);
477
  }
478
 
479
  function atlasProvenanceCopy(data) {
 
584
  atlasSvgEl.append(line);
585
  }
586
 
587
+ for (const point of data.points || []) {
588
+ if (!atlasSearchResultIds.has(point.id)) continue;
589
+ const ring = svgEl("circle");
590
+ ring.setAttribute("cx", point.x);
591
+ ring.setAttribute("cy", point.y);
592
+ ring.setAttribute("r", (atlasPointRadiusNumber(point) + 0.62).toFixed(3));
593
+ ring.setAttribute("class", `atlas-search-ring ${visible.has(point.id) ? "" : "dim"}`);
594
+ atlasSvgEl.append(ring);
595
+ }
596
+
597
  for (const point of data.points || []) {
598
  const circle = svgEl("circle");
599
  circle.setAttribute("cx", point.x);
 
602
  circle.setAttribute("fill", atlasColor(clusterIndex.get(point.cluster_id) || 0));
603
  circle.setAttribute(
604
  "class",
605
+ `atlas-dot ${visible.has(point.id) ? "" : "dim"} ${point.id === selectedProjectId ? "selected" : ""} ${
606
+ atlasSearchResultIds.has(point.id) ? "search-match" : ""
607
+ }`,
608
  );
609
  circle.setAttribute("tabindex", "0");
610
  circle.setAttribute("role", "button");
 
633
  return (data.points || []).filter((point) => {
634
  const clusterMatch = !selectedClusterId || point.cluster_id === selectedClusterId;
635
  const questMatch = !selectedQuestId || (point.quest_ids || []).includes(selectedQuestId);
636
+ const searchMatch = !atlasSearchQuery || atlasSearchResultIds.has(point.id);
637
+ return clusterMatch && questMatch && searchMatch;
638
  });
639
  }
640
 
641
  function labelAtlasPoints(data) {
642
+ if (atlasSearchQuery && atlasSearchResults.length) {
643
+ const pointsById = new Map((data.points || []).map((point) => [point.id, point]));
644
+ const visibleIds = new Set(visibleAtlasPoints(data).map((point) => point.id));
645
+ return atlasSearchResults
646
+ .map((result) => pointsById.get(result.project_id))
647
+ .filter(Boolean)
648
+ .filter((point) => visibleIds.has(point.id))
649
+ .slice(0, 16);
650
+ }
651
  const visible = visibleAtlasPoints(data);
652
  return [...visible].sort((left, right) => Number(right.likes || 0) - Number(left.likes || 0)).slice(0, 12);
653
  }
 
746
  }
747
 
748
  function atlasPointRadius(point) {
749
+ return atlasPointRadiusNumber(point).toFixed(3);
750
+ }
751
+
752
+ function atlasPointRadiusNumber(point) {
753
+ return 0.62 + Math.min(0.72, Math.sqrt(Number(point.likes || 0)) * 0.12);
754
  }
755
 
756
  function atlasShortTitle(title) {
static/index.html CHANGED
@@ -33,6 +33,13 @@
33
  <path d="M4 5v5h5" />
34
  <path d="M5.5 14a7 7 0 1 0 1.2-6.7L4 10" />
35
  </symbol>
 
 
 
 
 
 
 
36
  <symbol id="icon-check" viewBox="0 0 24 24">
37
  <path d="M5 12l4 4 10-11" />
38
  </symbol>
@@ -49,10 +56,24 @@
49
  <main id="atlas-view" class="atlas-shell" aria-label="Live project atlas">
50
  <section class="atlas-stage">
51
  <header class="atlas-topbar">
52
- <div>
53
  <p class="atlas-kicker">Live project atlas</p>
54
  <h1>Idea Map</h1>
55
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  <div class="atlas-actions">
57
  <span id="atlas-status" class="atlas-status" aria-live="polite">Loading atlas.</span>
58
  <button id="refresh-dashboard" class="btn btn-ghost" type="button" title="Refresh the project atlas">
@@ -69,6 +90,11 @@
69
  <section class="atlas-layout">
70
  <aside class="atlas-panel atlas-left" aria-label="Atlas filters">
71
  <div class="atlas-stat-grid" id="atlas-stats"></div>
 
 
 
 
 
72
  <section class="atlas-section">
73
  <div class="eyebrow">Clusters</div>
74
  <div id="atlas-clusters" class="atlas-list"></div>
 
33
  <path d="M4 5v5h5" />
34
  <path d="M5.5 14a7 7 0 1 0 1.2-6.7L4 10" />
35
  </symbol>
36
+ <symbol id="icon-search" viewBox="0 0 24 24">
37
+ <circle cx="11" cy="11" r="6" />
38
+ <path d="M16 16l4 4" />
39
+ </symbol>
40
+ <symbol id="icon-x" viewBox="0 0 24 24">
41
+ <path d="M6 6l12 12M18 6L6 18" />
42
+ </symbol>
43
  <symbol id="icon-check" viewBox="0 0 24 24">
44
  <path d="M5 12l4 4 10-11" />
45
  </symbol>
 
56
  <main id="atlas-view" class="atlas-shell" aria-label="Live project atlas">
57
  <section class="atlas-stage">
58
  <header class="atlas-topbar">
59
+ <div class="atlas-title-block">
60
  <p class="atlas-kicker">Live project atlas</p>
61
  <h1>Idea Map</h1>
62
  </div>
63
+ <form id="atlas-search-form" class="atlas-search" role="search">
64
+ <label class="sr-only" for="atlas-search">Search the project atlas</label>
65
+ <svg class="icon" aria-hidden="true"><use href="#icon-search"></use></svg>
66
+ <input
67
+ id="atlas-search"
68
+ type="search"
69
+ autocomplete="off"
70
+ spellcheck="false"
71
+ placeholder="Search projects, ideas, quests..."
72
+ />
73
+ <button id="atlas-search-clear" class="atlas-search-clear" type="button" title="Clear search" hidden>
74
+ <svg class="icon" aria-hidden="true"><use href="#icon-x"></use></svg>
75
+ </button>
76
+ </form>
77
  <div class="atlas-actions">
78
  <span id="atlas-status" class="atlas-status" aria-live="polite">Loading atlas.</span>
79
  <button id="refresh-dashboard" class="btn btn-ghost" type="button" title="Refresh the project atlas">
 
90
  <section class="atlas-layout">
91
  <aside class="atlas-panel atlas-left" aria-label="Atlas filters">
92
  <div class="atlas-stat-grid" id="atlas-stats"></div>
93
+ <section id="atlas-search-section" class="atlas-section atlas-search-section" hidden>
94
+ <div class="eyebrow">Best matches</div>
95
+ <p id="atlas-search-summary" class="atlas-search-summary"></p>
96
+ <div id="atlas-search-results" class="atlas-list"></div>
97
+ </section>
98
  <section class="atlas-section">
99
  <div class="eyebrow">Clusters</div>
100
  <div id="atlas-clusters" class="atlas-list"></div>
static/styles.css CHANGED
@@ -26,6 +26,10 @@
26
  box-sizing: border-box;
27
  }
28
 
 
 
 
 
29
  html,
30
  body {
31
  min-height: 100%;
@@ -152,14 +156,18 @@ textarea:disabled {
152
  }
153
 
154
  .atlas-topbar {
155
- display: flex;
 
156
  align-items: flex-end;
157
- justify-content: space-between;
158
  gap: 20px;
159
  padding: 30px 42px 18px;
160
  border-bottom: 1px solid var(--rule);
161
  }
162
 
 
 
 
 
163
  .atlas-kicker {
164
  margin: 0 0 7px;
165
  color: var(--ink-faint);
@@ -181,6 +189,68 @@ textarea:disabled {
181
  letter-spacing: 0;
182
  }
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  .atlas-actions {
185
  display: flex;
186
  flex-wrap: wrap;
@@ -200,6 +270,70 @@ textarea:disabled {
200
  text-align: right;
201
  }
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  .atlas-layout {
204
  display: grid;
205
  grid-template-columns: 300px minmax(0, 1fr) 330px;
@@ -349,8 +483,22 @@ textarea:disabled {
349
  stroke-width: 0.42;
350
  }
351
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  .atlas-dot.dim,
353
- .atlas-link.dim {
 
354
  opacity: 0.08;
355
  }
356
 
@@ -1472,6 +1620,14 @@ textarea:disabled {
1472
  }
1473
 
1474
  @media (max-width: 1080px) {
 
 
 
 
 
 
 
 
1475
  .atlas-layout {
1476
  grid-template-columns: 260px minmax(0, 1fr);
1477
  }
@@ -1519,7 +1675,7 @@ textarea:disabled {
1519
 
1520
  .atlas-topbar {
1521
  align-items: flex-start;
1522
- flex-direction: column;
1523
  padding: 18px;
1524
  }
1525
 
@@ -1532,6 +1688,10 @@ textarea:disabled {
1532
  width: 100%;
1533
  }
1534
 
 
 
 
 
1535
  .atlas-status {
1536
  min-width: 100%;
1537
  text-align: left;
 
26
  box-sizing: border-box;
27
  }
28
 
29
+ [hidden] {
30
+ display: none !important;
31
+ }
32
+
33
  html,
34
  body {
35
  min-height: 100%;
 
156
  }
157
 
158
  .atlas-topbar {
159
+ display: grid;
160
+ grid-template-columns: minmax(155px, auto) minmax(260px, 540px) minmax(300px, 1fr);
161
  align-items: flex-end;
 
162
  gap: 20px;
163
  padding: 30px 42px 18px;
164
  border-bottom: 1px solid var(--rule);
165
  }
166
 
167
+ .atlas-title-block {
168
+ min-width: 0;
169
+ }
170
+
171
  .atlas-kicker {
172
  margin: 0 0 7px;
173
  color: var(--ink-faint);
 
189
  letter-spacing: 0;
190
  }
191
 
192
+ .atlas-search {
193
+ display: flex;
194
+ min-width: 0;
195
+ align-items: center;
196
+ gap: 9px;
197
+ height: 42px;
198
+ padding: 0 12px;
199
+ color: var(--ink-soft);
200
+ background: rgba(255, 247, 224, 0.4);
201
+ border: 1px solid var(--rule);
202
+ border-radius: 8px;
203
+ box-shadow: inset 0 1px 0 rgba(255, 247, 224, 0.42);
204
+ }
205
+
206
+ .atlas-search:focus-within {
207
+ border-color: rgba(154, 43, 34, 0.48);
208
+ box-shadow:
209
+ inset 0 1px 0 rgba(255, 247, 224, 0.42),
210
+ 0 0 0 3px rgba(176, 125, 18, 0.12);
211
+ }
212
+
213
+ .atlas-search input {
214
+ min-width: 0;
215
+ width: 100%;
216
+ height: 100%;
217
+ padding: 0;
218
+ color: var(--ink);
219
+ background: transparent;
220
+ border: 0;
221
+ outline: 0;
222
+ font-family: var(--label);
223
+ font-size: 0.86rem;
224
+ font-weight: 760;
225
+ }
226
+
227
+ .atlas-search input::placeholder {
228
+ color: var(--ink-faint);
229
+ opacity: 0.82;
230
+ }
231
+
232
+ .atlas-search input::-webkit-search-cancel-button {
233
+ display: none;
234
+ }
235
+
236
+ .atlas-search-clear {
237
+ display: inline-grid;
238
+ width: 26px;
239
+ height: 26px;
240
+ flex: 0 0 auto;
241
+ place-items: center;
242
+ padding: 0;
243
+ color: var(--ink-faint);
244
+ background: transparent;
245
+ border: 0;
246
+ border-radius: 50%;
247
+ }
248
+
249
+ .atlas-search-clear:hover {
250
+ color: var(--ink);
251
+ background: rgba(73, 49, 22, 0.08);
252
+ }
253
+
254
  .atlas-actions {
255
  display: flex;
256
  flex-wrap: wrap;
 
270
  text-align: right;
271
  }
272
 
273
+ .atlas-search-summary {
274
+ margin: 0 0 9px;
275
+ color: var(--ink-faint);
276
+ font-family: var(--label);
277
+ font-size: 0.72rem;
278
+ font-weight: 760;
279
+ line-height: 1.4;
280
+ }
281
+
282
+ .atlas-search-result {
283
+ display: grid;
284
+ width: 100%;
285
+ gap: 6px;
286
+ padding: 10px 11px;
287
+ color: var(--ink);
288
+ background: rgba(255, 247, 224, 0.38);
289
+ border: 1px solid var(--rule-soft);
290
+ border-left: 3px solid var(--leaf);
291
+ border-radius: 7px;
292
+ text-align: left;
293
+ }
294
+
295
+ .atlas-search-result:hover,
296
+ .atlas-search-result.active {
297
+ background: rgba(47, 107, 65, 0.1);
298
+ border-color: rgba(47, 107, 65, 0.35);
299
+ }
300
+
301
+ .atlas-search-result.active {
302
+ border-left-color: var(--oxblood);
303
+ }
304
+
305
+ .atlas-search-result strong {
306
+ color: var(--ink);
307
+ font-family: var(--serif);
308
+ font-size: 0.88rem;
309
+ line-height: 1.2;
310
+ }
311
+
312
+ .atlas-search-meta,
313
+ .atlas-search-snippet {
314
+ color: var(--ink-faint);
315
+ font-family: var(--label);
316
+ font-size: 0.68rem;
317
+ font-weight: 760;
318
+ line-height: 1.35;
319
+ }
320
+
321
+ .atlas-search-score {
322
+ display: block;
323
+ height: 5px;
324
+ overflow: hidden;
325
+ background: rgba(73, 49, 22, 0.14);
326
+ border-radius: 999px;
327
+ }
328
+
329
+ .atlas-search-score i {
330
+ display: block;
331
+ width: 0;
332
+ height: 100%;
333
+ background: linear-gradient(90deg, var(--leaf) 0%, var(--gold) 72%, var(--oxblood) 100%);
334
+ border-radius: inherit;
335
+ }
336
+
337
  .atlas-layout {
338
  display: grid;
339
  grid-template-columns: 300px minmax(0, 1fr) 330px;
 
483
  stroke-width: 0.42;
484
  }
485
 
486
+ .atlas-dot.search-match {
487
+ opacity: 1;
488
+ stroke: #fff0b5;
489
+ stroke-width: 0.45;
490
+ }
491
+
492
+ .atlas-search-ring {
493
+ pointer-events: none;
494
+ fill: none;
495
+ stroke: rgba(176, 125, 18, 0.55);
496
+ stroke-width: 0.35;
497
+ }
498
+
499
  .atlas-dot.dim,
500
+ .atlas-link.dim,
501
+ .atlas-search-ring.dim {
502
  opacity: 0.08;
503
  }
504
 
 
1620
  }
1621
 
1622
  @media (max-width: 1080px) {
1623
+ .atlas-topbar {
1624
+ grid-template-columns: 1fr minmax(260px, 1.2fr);
1625
+ }
1626
+
1627
+ .atlas-actions {
1628
+ grid-column: 1 / -1;
1629
+ }
1630
+
1631
  .atlas-layout {
1632
  grid-template-columns: 260px minmax(0, 1fr);
1633
  }
 
1675
 
1676
  .atlas-topbar {
1677
  align-items: flex-start;
1678
+ grid-template-columns: 1fr;
1679
  padding: 18px;
1680
  }
1681
 
 
1688
  width: 100%;
1689
  }
1690
 
1691
+ .atlas-search {
1692
+ width: 100%;
1693
+ }
1694
+
1695
  .atlas-status {
1696
  min-width: 100%;
1697
  text-align: left;
tests/test_app.py CHANGED
@@ -12,6 +12,7 @@ from app import (
12
  chapter_api,
13
  chapter_artifact,
14
  dashboard,
 
15
  dashboard_refresh_start,
16
  dashboard_refresh_status,
17
  demo_bundle,
@@ -143,6 +144,29 @@ def test_dashboard_endpoint_exposes_atlas_payload() -> None:
143
  )
144
 
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  def test_refresh_error_format_includes_exception_chain() -> None:
147
  try:
148
  try:
 
12
  chapter_api,
13
  chapter_artifact,
14
  dashboard,
15
+ dashboard_search,
16
  dashboard_refresh_start,
17
  dashboard_refresh_status,
18
  demo_bundle,
 
144
  )
145
 
146
 
147
+ def test_dashboard_search_endpoint_returns_bm25_matches() -> None:
148
+ payload = dashboard_search(q="surgical anatomy", limit=5)
149
+
150
+ assert payload["algorithm"] == "bm25-text-v1"
151
+ assert payload["query"] == "surgical anatomy"
152
+ assert payload["results"]
153
+ assert (
154
+ payload["results"][0]["project_id"]
155
+ == "build-small-hackathon/surgical-tissue-segmentation"
156
+ )
157
+ assert payload["results"][0]["point"]["id"] == payload["results"][0]["project_id"]
158
+ assert payload["results"][0]["snippets"]
159
+
160
+
161
+ def test_dashboard_search_endpoint_rejects_empty_query() -> None:
162
+ try:
163
+ dashboard_search(q=" ")
164
+ except Exception as error:
165
+ assert getattr(error, "status_code", None) == 400
166
+ else:
167
+ raise AssertionError("dashboard search should reject an empty query")
168
+
169
+
170
  def test_refresh_error_format_includes_exception_chain() -> None:
171
  try:
172
  try:
tests/test_dashboard_search.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from hackathon_advisor.dashboard import build_dashboard_payload
2
+ from hackathon_advisor.data import Project, ProjectIndex, build_index_payload
3
+ from hackathon_advisor.dashboard_search import DashboardSearchIndex
4
+
5
+
6
+ def test_dashboard_search_bm25_ranks_text_matches() -> None:
7
+ project_index = fake_project_index()
8
+ payload = build_dashboard_payload(project_index, generated_at="2026-06-08T00:00:00+00:00")
9
+ search_index = DashboardSearchIndex(project_index.projects, payload)
10
+
11
+ result = search_index.search("project 4 summary", limit=3)
12
+
13
+ assert result["algorithm"] == "bm25-text-v1"
14
+ assert result["results"]
15
+ assert result["results"][0]["project_id"] == "build-small-hackathon/project-4"
16
+ assert result["results"][0]["score"] == 1.0
17
+ assert result["results"][0]["snippets"]
18
+
19
+
20
+ def test_dashboard_search_splits_slug_tokens() -> None:
21
+ project_index = fake_project_index()
22
+ payload = build_dashboard_payload(project_index, generated_at="2026-06-08T00:00:00+00:00")
23
+ search_index = DashboardSearchIndex(project_index.projects, payload)
24
+
25
+ result = search_index.search("project-7", limit=3)
26
+
27
+ assert result["results"][0]["project_id"] == "build-small-hackathon/project-7"
28
+ assert "project-7" in result["results"][0]["matched_terms"]
29
+
30
+
31
+ def fake_project_index() -> ProjectIndex:
32
+ projects = [
33
+ Project(
34
+ id=f"build-small-hackathon/project-{index}",
35
+ title=f"Project {index}",
36
+ summary=f"Offline project planner {index}",
37
+ tags=("gradio", "local-first"),
38
+ models=("tiny-model",),
39
+ datasets=(),
40
+ likes=index,
41
+ sdk="gradio",
42
+ license="mit",
43
+ created_at="2026-06-01T00:00:00+00:00",
44
+ last_modified=f"2026-06-{index + 1:02d}T00:00:00+00:00",
45
+ host=f"https://project-{index}.hf.space",
46
+ url=f"https://huggingface.co/spaces/build-small-hackathon/project-{index}",
47
+ app_file="app.py",
48
+ app_file_embedding_text=f"local inference gradio small model artifact project {index}",
49
+ readme_body=f"README evidence for project {index}",
50
+ )
51
+ for index in range(10)
52
+ ]
53
+ embeddings = []
54
+ for index in range(10):
55
+ vector = [0.0] * 10
56
+ vector[index] = 1.0
57
+ embeddings.append(vector)
58
+ generated_at = "2026-06-08T00:00:00+00:00"
59
+ source = "https://example.test/spaces"
60
+ return ProjectIndex(
61
+ projects=projects,
62
+ generated_at=generated_at,
63
+ source=source,
64
+ index_payload=build_index_payload(projects, generated_at, source, embeddings),
65
+ )
tests/test_frontend_copy.py CHANGED
@@ -9,8 +9,11 @@ def test_main_interface_copy_is_builder_facing() -> None:
9
  assert "Live project atlas" in html
10
  assert "Refresh map" in html
11
  assert "Open advisor" in html
 
 
12
  assert 'id="advisor-view"' in html
13
  assert "/api/dashboard" in app_js
 
14
  assert "/api/dashboard/refresh" in app_js
15
  assert "renderAtlasSvg" in app_js
16
  assert "Directions to test" in html
@@ -37,6 +40,13 @@ def test_main_interface_copy_is_builder_facing() -> None:
37
  assert "Voice note" in html
38
  assert "ideaCardAriaLabel" in app_js
39
  assert "Select idea:" in app_js
 
 
 
 
 
 
 
40
 
41
  stale_jargon = [
42
  "No wax path pressed.",
 
9
  assert "Live project atlas" in html
10
  assert "Refresh map" in html
11
  assert "Open advisor" in html
12
+ assert 'id="atlas-search"' in html
13
+ assert "Search projects, ideas, quests..." in html
14
  assert 'id="advisor-view"' in html
15
  assert "/api/dashboard" in app_js
16
+ assert "/api/dashboard/search" in app_js
17
  assert "/api/dashboard/refresh" in app_js
18
  assert "renderAtlasSvg" in app_js
19
  assert "Directions to test" in html
 
40
  assert "Voice note" in html
41
  assert "ideaCardAriaLabel" in app_js
42
  assert "Select idea:" in app_js
43
+ assert "Hybrid" not in combined
44
+ assert "Keyword" not in combined
45
+ assert "Semantic" not in combined
46
+ assert "Refresh failed:" not in app_js
47
+ assert "Refresh could not start: ${error.message}" not in app_js
48
+ assert "Refresh status unavailable: ${error.message}" not in app_js
49
+ assert "atlasRefreshProgressEl.textContent = error.message" not in app_js
50
 
51
  stale_jargon = [
52
  "No wax path pressed.",