Spaces:
Running on Zero
Running on Zero
feat: add atlas project search
Browse files- app.py +38 -1
- hackathon_advisor/dashboard_search.py +388 -0
- static/app.js +180 -15
- static/index.html +27 -1
- static/styles.css +164 -4
- tests/test_app.py +24 -0
- tests/test_dashboard_search.py +65 -0
- tests/test_frontend_copy.py +10 -0
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 =
|
| 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 |
-
|
| 248 |
-
if (
|
| 249 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 297 |
} else if (dashboardData) {
|
| 298 |
-
atlasStatusEl.textContent = atlasProvenanceCopy(dashboardData);
|
| 299 |
}
|
| 300 |
}
|
| 301 |
if (atlasRefreshProgressEl) {
|
| 302 |
-
const show = status === "running"
|
| 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 |
-
:
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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 |
-
|
| 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.",
|