JacobLinCool Codex commited on
Commit
cb3451f
·
verified ·
1 Parent(s): 07b5572

deploy: sync GitHub main 07450c9

Browse files

Deploys 07450c98611aacaa07df903a778fada118aa3c7f.

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

AGENTS.md CHANGED
@@ -79,6 +79,9 @@ tests/ pytest suite (mirrors module names: test_<module>.py)
79
  | `data.py` | `ProjectIndex`: loads the snapshot + embedding index, `_embed_query()` via llama.cpp, cosine search. |
80
  | `llama_embedding.py` | `LlamaCppEmbedder` — EmbeddingGemma GGUF through llama-cpp-python (the Llama Champion path). |
81
  | `dashboard.py` / `dashboard_storage.py` / `dashboard_search.py` | Atlas payload (t-SNE / KMeans / nearest links), BM25 search, and the refresh **lease + heartbeat + atomic `latest.json` swap**. |
 
 
 
82
  | `quest_analysis.py` / `quest_taxonomy.py` / `quest_cache.py` | MiniCPM quest LoRA → strict quest JSON; the taxonomy; per-project cache keyed on prompt/taxonomy/model/adapter hashes. |
83
  | `scoring.py` | Deterministic idea rubric (the model only triggers + verbalizes it). |
84
  | `wood_map.py` / `png_export.py` | PCA projection + Pillow render of the shareable page PNG. |
@@ -97,6 +100,7 @@ First-party FastAPI routes power the visible app; `@app.api()` endpoints stay av
97
  | `POST /api/agent-turn` | The advisor turn — **NDJSON stream**; this is the `@spaces.GPU` boundary |
98
  | `POST /api/transcribe` | Voice note → transcript (NeMo, see ASR gotcha) |
99
  | `GET /api/dashboard` · `GET /api/dashboard/search` | Atlas payload · BM25 search |
 
100
  | `POST/GET /api/dashboard/refresh` | Start / poll one background refresh job |
101
  | `GET /api/bootstrap` · `GET /api/runtime` · `GET /api/prize-ledger` · `GET /api/tool-contracts` | Frontend bootstrap, runtime status, prize ledger, tool schema |
102
  | `GET /api/demo-bundle.zip` · `GET /api/lora-training-kit.zip` · `POST /api/artifact.png` · `POST /api/field-notes` · `POST /api/chapter` | Exports |
@@ -124,6 +128,13 @@ First-party FastAPI routes power the visible app; `@app.api()` endpoints stay av
124
  weights. Don't add module-top heavy imports that break CPU-only test collection.
125
  8. **ASR backend.** `asr_runtime.py` requires NVIDIA NeMo ASR for `nvidia/nemotron-speech-streaming-en-0.6b`; missing
126
  NeMo is a hard runtime error, locally and on the deployed Space. `status()` reports the configured Nemotron backend.
 
 
 
 
 
 
 
127
 
128
  ---
129
 
 
79
  | `data.py` | `ProjectIndex`: loads the snapshot + embedding index, `_embed_query()` via llama.cpp, cosine search. |
80
  | `llama_embedding.py` | `LlamaCppEmbedder` — EmbeddingGemma GGUF through llama-cpp-python (the Llama Champion path). |
81
  | `dashboard.py` / `dashboard_storage.py` / `dashboard_search.py` | Atlas payload (t-SNE / KMeans / nearest links), BM25 search, and the refresh **lease + heartbeat + atomic `latest.json` swap**. |
82
+ | `dashboard_repository.py` | `DashboardRepository`: read-only queries (overview, clusters, quests, leaderboard, search, recent) over one snapshot of `(dashboard_payload, search_index)`; no locks, no globals, plain dicts. |
83
+ | `dashboard_chat_contracts.py` | Atlas-chat tool specs (8 tools), `parse_native_tool_call()` for MiniCPM5's native `<function><param>` format, and the chat degradation ladder (`resolve_chat_tool_call` / `data_intent_call`). |
84
+ | `dashboard_chat.py` | `DashboardChatEngine.turn_stream()`: native two-pass loop on the **base** model with `enable_thinking` — reasoning streams as `thinking` events (split from content at `</think>`, which is NOT a special token), then tool pick (tools= injected) → repository execution → `tool_result` + `map_action` events **before** prose → grounded answer from a compact line-format digest (no history in pass 2: echo bait). 4096-token budget per generation; empty results skip pass 2 for a templated sentence. |
85
  | `quest_analysis.py` / `quest_taxonomy.py` / `quest_cache.py` | MiniCPM quest LoRA → strict quest JSON; the taxonomy; per-project cache keyed on prompt/taxonomy/model/adapter hashes. |
86
  | `scoring.py` | Deterministic idea rubric (the model only triggers + verbalizes it). |
87
  | `wood_map.py` / `png_export.py` | PCA projection + Pillow render of the shareable page PNG. |
 
100
  | `POST /api/agent-turn` | The advisor turn — **NDJSON stream**; this is the `@spaces.GPU` boundary |
101
  | `POST /api/transcribe` | Voice note → transcript (NeMo, see ASR gotcha) |
102
  | `GET /api/dashboard` · `GET /api/dashboard/search` | Atlas payload · BM25 search |
103
+ | `POST /api/dashboard/chat` | Atlas chat turn — **NDJSON stream** (base MiniCPM5-1B, native tool calling, two-pass) |
104
  | `POST/GET /api/dashboard/refresh` | Start / poll one background refresh job |
105
  | `GET /api/bootstrap` · `GET /api/runtime` · `GET /api/prize-ledger` · `GET /api/tool-contracts` | Frontend bootstrap, runtime status, prize ledger, tool schema |
106
  | `GET /api/demo-bundle.zip` · `GET /api/lora-training-kit.zip` · `POST /api/artifact.png` · `POST /api/field-notes` · `POST /api/chapter` | Exports |
 
128
  weights. Don't add module-top heavy imports that break CPU-only test collection.
129
  8. **ASR backend.** `asr_runtime.py` requires NVIDIA NeMo ASR for `nvidia/nemotron-speech-streaming-en-0.6b`; missing
130
  NeMo is a hard runtime error, locally and on the deployed Space. `status()` reports the configured Nemotron backend.
131
+ 9. **The atlas chat shares the advisor's model — never load a second MiniCPM.** `create_chat_runner(engine.planner)`
132
+ borrows the loaded PeftModel and runs chat generations inside `base_model_context()` (PEFT `disable_adapter()`), so
133
+ the chat speaks with BASE weights. Adapter toggling mutates shared model state, so **every** generation (advisor and
134
+ chat) goes through `_stream_minicpm_generation`, which holds the module-level `generation_lock()` for the full
135
+ streamer-worker lifetime. Gotcha 1 still binds the advisor; the chat's two-pass model-written answers are a
136
+ deliberate, separately guarded exception (verified cards always render from the real tool result, and empty results
137
+ skip the model entirely).
138
 
139
  ---
140
 
README.md CHANGED
@@ -68,6 +68,10 @@ export the session evidence.
68
  text, and declared app-file source.
69
  - Filter by cluster or quest, then inspect the selected project's summary, Space link, tags, quest matches, and evidence
70
  hints.
 
 
 
 
71
  - Refresh the atlas from the Space backend; validated artifacts are written to the mounted cache directory and swapped
72
  into the live app atomically.
73
  - Open the advisor workspace for idea comparison, gap exploration, score seals, profile-aware plans, voice input, and
@@ -212,6 +216,7 @@ credentials.
212
  | --- | --- |
213
  | `GET /api/dashboard` | Atlas points, links, clusters, quest report, provenance, and refresh status. |
214
  | `GET /api/dashboard/search?q=...` | BM25 search over project, cluster, quest, README, and app-file text. |
 
215
  | `POST /api/dashboard/refresh` | Starts one background refresh job. |
216
  | `GET /api/dashboard/refresh` | Reports refresh stage, result, and status. |
217
  | `POST /api/transcribe` | Transcribes uploaded voice notes with NVIDIA NeMo and Nemotron ASR. |
 
68
  text, and declared app-file source.
69
  - Filter by cluster or quest, then inspect the selected project's summary, Space link, tags, quest matches, and evidence
70
  hints.
71
+ - Chat with the atlas: the "Ask the atlas" drawer answers questions like "who completed the most quests" or "what
72
+ clusters exist" through the BASE MiniCPM5-1B model's native tool calling, with thinking enabled — the reasoning
73
+ trace streams live into a collapsible block. Verified tool results render as cards and can filter or highlight the
74
+ map directly; the model's prose is grounded on a compact digest of the same result.
75
  - Refresh the atlas from the Space backend; validated artifacts are written to the mounted cache directory and swapped
76
  into the live app atomically.
77
  - Open the advisor workspace for idea comparison, gap exploration, score seals, profile-aware plans, voice input, and
 
216
  | --- | --- |
217
  | `GET /api/dashboard` | Atlas points, links, clusters, quest report, provenance, and refresh status. |
218
  | `GET /api/dashboard/search?q=...` | BM25 search over project, cluster, quest, README, and app-file text. |
219
+ | `POST /api/dashboard/chat` | Atlas chat turn (NDJSON stream): base-model tool call, verified result + map action, grounded answer. |
220
  | `POST /api/dashboard/refresh` | Starts one background refresh job. |
221
  | `GET /api/dashboard/refresh` | Reports refresh stage, result, and status. |
222
  | `POST /api/transcribe` | Transcribes uploaded voice notes with NVIDIA NeMo and Nemotron ASR. |
app.py CHANGED
@@ -44,8 +44,10 @@ from hackathon_advisor.data import (
44
  ProjectIndex,
45
  normalize_project_tags,
46
  )
 
 
47
  from hackathon_advisor.demo_rehearsal import build_demo_rehearsal
48
- from hackathon_advisor.model_runtime import create_tool_planner
49
  from hackathon_advisor.profiling import (
50
  TurnProfiler,
51
  configure_logging,
@@ -136,7 +138,22 @@ engine = AdvisorEngine(index, create_tool_planner(device=gpu_device()))
136
  voice_transcriber = create_asr_transcriber()
137
  app = Server()
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  _cpu_engine: AdvisorEngine | None = None
 
140
  _refresh_state: dict[str, Any] = {
141
  "status": "idle",
142
  "run_id": "",
@@ -166,11 +183,28 @@ def _cpu_engine_instance() -> AdvisorEngine:
166
  return _cpu_engine
167
 
168
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  @gpu_task
170
  def _engine_turn_stream_gpu(message: str, session: dict[str, Any]) -> Iterator[dict[str, Any]]:
171
  yield from engine.turn_stream(message, session)
172
 
173
 
 
 
 
 
 
174
  @gpu_task
175
  def _transcribe_voice(audio_path: str) -> dict[str, Any]:
176
  return voice_transcriber.transcribe(Path(audio_path)).to_dict()
@@ -883,6 +917,70 @@ def _profiled_turn_events(
883
  yield event
884
 
885
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
886
  @app.get("/", response_class=HTMLResponse)
887
  def home() -> FileResponse:
888
  return FileResponse(STATIC_DIR / "index.html")
@@ -1137,6 +1235,22 @@ def agent_turn_stream(payload: dict[str, Any] | None = Body(default=None)) -> St
1137
  return StreamingResponse(stream(), media_type="application/x-ndjson")
1138
 
1139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1140
  def _normalize_compute(value: Any) -> str:
1141
  # Acceleration is automatic; "cpu" is the only manual override (not surfaced in the UI).
1142
  return "cpu" if str(value or "").strip().lower() == "cpu" else "gpu"
@@ -1288,6 +1402,11 @@ def agent_turn(message: str, session_json: str = "{}", compute: str = "gpu") ->
1288
  yield from _agent_turn_events(message, session_json, _normalize_compute(compute))
1289
 
1290
 
 
 
 
 
 
1291
  _start_scheduled_refresh_loop()
1292
 
1293
 
 
44
  ProjectIndex,
45
  normalize_project_tags,
46
  )
47
+ from hackathon_advisor.dashboard_chat import DashboardChatEngine
48
+ from hackathon_advisor.dashboard_repository import DashboardRepository
49
  from hackathon_advisor.demo_rehearsal import build_demo_rehearsal
50
+ from hackathon_advisor.model_runtime import create_chat_runner, create_tool_planner
51
  from hackathon_advisor.profiling import (
52
  TurnProfiler,
53
  configure_logging,
 
138
  voice_transcriber = create_asr_transcriber()
139
  app = Server()
140
 
141
+
142
+ def _current_dashboard_repository() -> DashboardRepository:
143
+ """Snapshot the swapped globals under the lock, build the repository outside it."""
144
+ with _runtime_lock:
145
+ payload = dashboard_payload
146
+ search_index = dashboard_search_index
147
+ return DashboardRepository(payload, search_index)
148
+
149
+
150
+ # The atlas chat shares the advisor's model (BASE weights via adapter-off generations);
151
+ # create_chat_runner never loads a second copy. The planner reference survives refresh
152
+ # swaps because _replace_runtime_from_files reuses engine.planner.
153
+ chat_engine = DashboardChatEngine(create_chat_runner(engine.planner), _current_dashboard_repository)
154
+
155
  _cpu_engine: AdvisorEngine | None = None
156
+ _cpu_chat_engine: DashboardChatEngine | None = None
157
  _refresh_state: dict[str, Any] = {
158
  "status": "idle",
159
  "run_id": "",
 
183
  return _cpu_engine
184
 
185
 
186
+ def _cpu_chat_engine_instance() -> DashboardChatEngine:
187
+ """CPU-pinned atlas chat used for the explicit CPU override and the ZeroGPU-quota
188
+ fallback. Shares the CPU advisor engine's model, mirroring _cpu_engine_instance()."""
189
+ global _cpu_chat_engine
190
+ if _cpu_chat_engine is None:
191
+ _cpu_chat_engine = DashboardChatEngine(
192
+ create_chat_runner(_cpu_engine_instance().planner),
193
+ _current_dashboard_repository,
194
+ )
195
+ return _cpu_chat_engine
196
+
197
+
198
  @gpu_task
199
  def _engine_turn_stream_gpu(message: str, session: dict[str, Any]) -> Iterator[dict[str, Any]]:
200
  yield from engine.turn_stream(message, session)
201
 
202
 
203
+ @gpu_task
204
+ def _chat_turn_stream_gpu(message: str, history: list[dict[str, Any]]) -> Iterator[dict[str, Any]]:
205
+ yield from chat_engine.turn_stream(message, history)
206
+
207
+
208
  @gpu_task
209
  def _transcribe_voice(audio_path: str) -> dict[str, Any]:
210
  return voice_transcriber.transcribe(Path(audio_path)).to_dict()
 
917
  yield event
918
 
919
 
920
+ def _history_from_json(history_json: str = "[]") -> list[dict[str, Any]]:
921
+ try:
922
+ history = json.loads(history_json or "[]")
923
+ except json.JSONDecodeError:
924
+ return []
925
+ return history if isinstance(history, list) else []
926
+
927
+
928
+ def _primary_chat_stream(message: str, history: list[dict[str, Any]]) -> Iterator[dict[str, Any]]:
929
+ if zero_gpu_enabled():
930
+ yield from _chat_turn_stream_gpu(message, history)
931
+ else:
932
+ yield from chat_engine.turn_stream(message, history)
933
+
934
+
935
+ def _chat_turn_events(
936
+ message: str,
937
+ history_json: str = "[]",
938
+ compute: str = "gpu",
939
+ ) -> Iterator[str]:
940
+ profiler = TurnProfiler(
941
+ message_index=next_message_index(),
942
+ compute=compute,
943
+ backend=str(getattr(chat_engine.runner, "backend", "")),
944
+ message_chars=len(message),
945
+ )
946
+ profiler.log_start()
947
+ try:
948
+ for event in _profiled_chat_events(message, history_json, compute):
949
+ profiler.observe(event)
950
+ yield _json_event(event)
951
+ profiler.device = _active_device(compute)
952
+ profiler.log_summary()
953
+ except Exception as error: # noqa: BLE001 - log timing/resources even when a turn fails
954
+ profiler.device = _active_device(compute)
955
+ profiler.log_summary(error)
956
+ raise
957
+
958
+
959
+ def _profiled_chat_events(
960
+ message: str,
961
+ history_json: str,
962
+ compute: str,
963
+ ) -> Iterator[dict[str, Any]]:
964
+ history = _history_from_json(history_json)
965
+ if compute != "cpu":
966
+ produced = False
967
+ try:
968
+ for event in _primary_chat_stream(message, history):
969
+ produced = True
970
+ yield event
971
+ return
972
+ except Exception as error: # noqa: BLE001 - fall back to local on a clean quota failure
973
+ if produced or not is_gpu_quota_error(error):
974
+ raise
975
+ yield {
976
+ "type": "fallback",
977
+ "to": "cpu",
978
+ "reason": "ZeroGPU quota reached — running this turn locally (slower).",
979
+ }
980
+
981
+ yield from _cpu_chat_engine_instance().turn_stream(message, history)
982
+
983
+
984
  @app.get("/", response_class=HTMLResponse)
985
  def home() -> FileResponse:
986
  return FileResponse(STATIC_DIR / "index.html")
 
1235
  return StreamingResponse(stream(), media_type="application/x-ndjson")
1236
 
1237
 
1238
+ @app.post("/api/dashboard/chat")
1239
+ def dashboard_chat_stream(payload: dict[str, Any] | None = Body(default=None)) -> StreamingResponse:
1240
+ payload = payload or {}
1241
+ message = str(payload.get("message") or "").strip()
1242
+ if not message:
1243
+ raise HTTPException(status_code=400, detail="Chat message is required.")
1244
+ history_json = str(payload.get("history_json") or "[]")
1245
+ compute = _normalize_compute(payload.get("compute"))
1246
+
1247
+ def stream() -> Iterator[str]:
1248
+ for event in _chat_turn_events(message, history_json, compute):
1249
+ yield f"{event}\n"
1250
+
1251
+ return StreamingResponse(stream(), media_type="application/x-ndjson")
1252
+
1253
+
1254
  def _normalize_compute(value: Any) -> str:
1255
  # Acceleration is automatic; "cpu" is the only manual override (not surfaced in the UI).
1256
  return "cpu" if str(value or "").strip().lower() == "cpu" else "gpu"
 
1402
  yield from _agent_turn_events(message, session_json, _normalize_compute(compute))
1403
 
1404
 
1405
+ @app.api(name="dashboard_chat", concurrency_limit=4, stream_every=0.04)
1406
+ def dashboard_chat(message: str, history_json: str = "[]", compute: str = "gpu") -> Iterator[str]:
1407
+ yield from _chat_turn_events(message, history_json, _normalize_compute(compute))
1408
+
1409
+
1410
  _start_scheduled_refresh_loop()
1411
 
1412
 
hackathon_advisor/dashboard_chat.py ADDED
@@ -0,0 +1,643 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The atlas chat engine: a two-pass, tool-grounded conversation over the idea map.
2
+
3
+ Flow per turn (the native MiniCPM5 tool protocol, run on the BASE model):
4
+
5
+ 1. *Pass 1* — the model sees the chat history plus the tool schemas (injected by the
6
+ chat template via ``tools=``) and either calls one tool or answers plain prose.
7
+ 2. The call is validated and degraded through the chat-specific ladder
8
+ (``resolve_chat_tool_call``), then executed against a fresh
9
+ :class:`~hackathon_advisor.dashboard_repository.DashboardRepository` snapshot.
10
+ 3. The full verified result streams to the UI *first* (``tool_result`` + optional
11
+ ``map_action``) so cards and the map always carry the real numbers.
12
+ 4. *Pass 2* — a compact digest (urls/ids/scores stripped, so the model cannot
13
+ misquote what it never saw) goes back as a ``role:"tool"`` message and the model
14
+ writes a short grounded answer at temperature 0 with NO tools injected.
15
+ Empty results skip pass 2 entirely: a 1B narrating absent data is where it
16
+ hallucinates, so those turns get a deterministic templated sentence instead.
17
+
18
+ The engine is UI- and app-agnostic: it depends only on a ChatRunner and a
19
+ repository factory, and every yielded event is a JSON-serializable dict.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Callable, Iterator
25
+ import re
26
+ from typing import Any
27
+
28
+ from hackathon_advisor._text import clean
29
+ from hackathon_advisor.aliases import normalize_text
30
+ from hackathon_advisor.dashboard_chat_contracts import (
31
+ ChatToolResolution,
32
+ chat_tool_schemas,
33
+ data_intent_call,
34
+ resolve_chat_tool_call,
35
+ smalltalk_intent,
36
+ strip_function_blocks,
37
+ )
38
+ from hackathon_advisor.dashboard_repository import DashboardRepository
39
+ from hackathon_advisor.model_runtime import ChatRunner
40
+ from hackathon_advisor.tool_contracts import ToolCall
41
+
42
+ # One generous budget for every chat generation: with thinking enabled the model
43
+ # reasons inside <think>...</think> before the tool call / answer, and the trace
44
+ # alone can run long. The model stops at EOS well before the cap on normal turns.
45
+ MAX_CHAT_GENERATION_TOKENS = 4096
46
+ MAX_HISTORY_MESSAGES = 12 # six user/assistant turns
47
+ MAX_ANSWER_HISTORY_MESSAGES = 4 # two turns of context for the prose passes
48
+ MAX_HISTORY_MESSAGE_CHARS = 600
49
+
50
+ CHAT_PLANNING_PROMPT = (
51
+ "You are the Atlas Guide for the Build Small hackathon idea map. "
52
+ "You cannot see the atlas directly: the ONLY way to answer a question about projects, "
53
+ "clusters, quests, teams, or recent activity is to call one of the provided tools, which "
54
+ "read the live atlas. For any such question respond with exactly one tool call and no "
55
+ 'other text, for example: <function name="search_projects"><param name="query">voice'
56
+ "</param></function>. Reply in plain prose only for greetings or questions about yourself."
57
+ )
58
+
59
+ CHAT_ANSWER_PROMPT = (
60
+ "You are the Atlas Guide for the Build Small hackathon idea map. "
61
+ "Write a short conversational answer to the user's question using ONLY the facts in the "
62
+ "tool response. Quote counts and names exactly as given. Do not invent projects, numbers, "
63
+ "or links. Do not enumerate every item: summarize, naming at most three examples. "
64
+ "Two to four sentences, no lists, no markdown."
65
+ )
66
+
67
+ CHAT_SMALLTALK_PROMPT = (
68
+ "You are the Atlas Guide for the Build Small hackathon idea map. "
69
+ "Reply briefly and warmly. You have NO project data in this conversation: never state "
70
+ "project names, counts, likes, or rankings, and do not defend earlier numbers — if asked "
71
+ "about data, say you should look it up and suggest asking what everyone is building, "
72
+ "which projects completed the most quests, or what clusters exist. One or two sentences."
73
+ )
74
+
75
+ # Keys stripped from the model-facing digest. The UI renders links and ids from the
76
+ # verified payload; the model only needs labels, titles, and counts.
77
+ _DIGEST_DROPPED_KEYS = frozenset({"url", "id", "score", "host", "quest_ids"})
78
+
79
+ # A trailing fragment of "<function" left by a max_new_tokens cut mid-marker.
80
+ _PARTIAL_TAG_RE = re.compile(r"<[a-z]{0,8}$")
81
+
82
+ THINK_END_MARKER = "</think>"
83
+
84
+
85
+ class _ThinkSplitter:
86
+ """Incrementally split a thinking-mode stream into (kind, text) chunks.
87
+
88
+ With ``enable_thinking`` the chat template ends the prompt with ``<think>\\n``,
89
+ so the generation is reasoning text up to ``</think>`` followed by the real
90
+ content. The marker can arrive split across stream pieces, so a small tail
91
+ buffer is kept until it can no longer be a marker prefix. When the runner does
92
+ not think (rules backend) every piece passes straight through as answer text."""
93
+
94
+ def __init__(self, active: bool) -> None:
95
+ self._thinking = bool(active)
96
+ self._buffer = ""
97
+
98
+ def feed(self, piece: str) -> list[tuple[str, str]]:
99
+ if not self._thinking:
100
+ return [("answer", piece)] if piece else []
101
+ self._buffer += piece
102
+ marker = self._buffer.find(THINK_END_MARKER)
103
+ if marker >= 0:
104
+ thought = self._buffer[:marker]
105
+ rest = self._buffer[marker + len(THINK_END_MARKER) :].lstrip("\n")
106
+ self._buffer = ""
107
+ self._thinking = False
108
+ chunks: list[tuple[str, str]] = []
109
+ if thought:
110
+ chunks.append(("thinking", thought))
111
+ if rest:
112
+ chunks.append(("answer", rest))
113
+ return chunks
114
+ keep = _marker_prefix_length(self._buffer)
115
+ flush, self._buffer = (
116
+ self._buffer[: len(self._buffer) - keep],
117
+ self._buffer[len(self._buffer) - keep :],
118
+ )
119
+ return [("thinking", flush)] if flush else []
120
+
121
+ def finish(self) -> list[tuple[str, str]]:
122
+ """Flush the tail when the stream ends mid-thought (max_new_tokens cut)."""
123
+ if self._thinking and self._buffer:
124
+ tail, self._buffer = self._buffer, ""
125
+ return [("thinking", tail)]
126
+ return []
127
+
128
+
129
+ def _marker_prefix_length(text: str) -> int:
130
+ for length in range(min(len(text), len(THINK_END_MARKER) - 1), 0, -1):
131
+ if THINK_END_MARKER.startswith(text[-length:]):
132
+ return length
133
+ return 0
134
+
135
+
136
+ class DashboardChatEngine:
137
+ def __init__(
138
+ self,
139
+ runner: ChatRunner,
140
+ repository_factory: Callable[[], DashboardRepository],
141
+ ) -> None:
142
+ self.runner = runner
143
+ self.repository_factory = repository_factory
144
+
145
+ def turn_stream(
146
+ self,
147
+ message: str,
148
+ history: list[dict[str, Any]] | None = None,
149
+ ) -> Iterator[dict[str, Any]]:
150
+ history = _normalize_history(history)
151
+ normalized, corrections = normalize_text(message)
152
+ yield {
153
+ "type": "start",
154
+ "normalized_text": normalized,
155
+ "corrections": [correction.to_dict() for correction in corrections],
156
+ }
157
+ repository = self.repository_factory()
158
+
159
+ yield {"type": "stage", "stage": "planning", "label": "Reading the atlas"}
160
+ resolution, raw_output = yield from self._pick_tool(normalized, history)
161
+ if resolution.status == "none":
162
+ # Accuracy backstop: when the model answers in prose (declining its tools),
163
+ # route any substantive question to a tool — a matched intent first, BM25
164
+ # search otherwise. Only greetings/meta/short follow-ups may stay on the
165
+ # ungrounded small-talk path; this is a data surface, and letting a question
166
+ # like "how many voice apps" through is how invented facts reach the user.
167
+ intent = data_intent_call(normalized)
168
+ if intent is None and not smalltalk_intent(normalized):
169
+ intent = ToolCall("search_projects", {"query": normalized})
170
+ if intent is not None:
171
+ resolution = ChatToolResolution(
172
+ status="defaulted",
173
+ call=intent,
174
+ errors=("model answered without a tool; routed by intent",),
175
+ )
176
+ yield {
177
+ "type": "tool_call",
178
+ "name": resolution.call.name if resolution.call else "",
179
+ "arguments": resolution.call.arguments if resolution.call else {},
180
+ "status": resolution.status,
181
+ "errors": list(resolution.errors),
182
+ }
183
+
184
+ if resolution.status == "none":
185
+ response = yield from self._smalltalk(normalized, history, raw_output)
186
+ yield self._done(normalized, history, response, tool="", data={}, map_action=None)
187
+ return
188
+
189
+ call = resolution.call
190
+ assert call is not None
191
+ yield {
192
+ "type": "stage",
193
+ "stage": "running_tool",
194
+ "tool": call.name,
195
+ "label": f"Calling {call.name}",
196
+ }
197
+ # _execute may swap the tool (show_project falls back to search when no
198
+ # project matches), so the executed name drives rendering from here on.
199
+ tool_name, data, map_action, empty_reason = self._execute(call, repository)
200
+ yield {"type": "tool_result", "tool": tool_name, "data": data, "map_action": map_action}
201
+
202
+ if empty_reason:
203
+ response = _templated_sentence(call, data, empty_reason)
204
+ yield {"type": "answer_skipped", "reason": empty_reason, "text": response}
205
+ else:
206
+ yield {"type": "stage", "stage": "writing", "label": "Writing the answer"}
207
+ executed = ToolCall(tool_name, call.arguments)
208
+ response = yield from self._grounded_answer(normalized, history, executed, data)
209
+ if not response:
210
+ response = _templated_sentence(call, data, "empty_answer")
211
+ yield {"type": "answer_skipped", "reason": "empty_answer", "text": response}
212
+
213
+ yield self._done(
214
+ normalized, history, response, tool=tool_name, data=data, map_action=map_action
215
+ )
216
+
217
+ def _pick_tool(
218
+ self,
219
+ message: str,
220
+ history: list[dict[str, Any]],
221
+ ) -> Iterator[dict[str, Any]]:
222
+ messages = [
223
+ {"role": "system", "content": CHAT_PLANNING_PROMPT},
224
+ *history,
225
+ {"role": "user", "content": message},
226
+ ]
227
+ splitter = _ThinkSplitter(getattr(self.runner, "supports_thinking", False))
228
+ answer_pieces: list[str] = []
229
+ for count, piece in self.runner.stream(
230
+ messages,
231
+ tools=chat_tool_schemas(),
232
+ max_new_tokens=MAX_CHAT_GENERATION_TOKENS,
233
+ enable_thinking=True,
234
+ ):
235
+ for kind, text in splitter.feed(piece):
236
+ if kind == "thinking":
237
+ yield {"type": "thinking", "pass": 1, "text": text}
238
+ else:
239
+ answer_pieces.append(text)
240
+ yield {
241
+ "type": "model_progress",
242
+ "pass": 1,
243
+ "tokens": count,
244
+ "max_tokens": MAX_CHAT_GENERATION_TOKENS,
245
+ }
246
+ for _kind, text in splitter.finish():
247
+ yield {"type": "thinking", "pass": 1, "text": text}
248
+ # Only the post-thinking text may be parsed: the reasoning trace legitimately
249
+ # talks about <function ...> syntax without being a call.
250
+ raw_output = "".join(answer_pieces).strip()
251
+ return resolve_chat_tool_call(raw_output, fallback_query=message), raw_output
252
+
253
+ def _smalltalk(
254
+ self,
255
+ message: str,
256
+ history: list[dict[str, Any]],
257
+ raw_output: str,
258
+ ) -> Iterator[dict[str, Any]]:
259
+ """Dedicated no-tools generation: the pass-1 output is tuned for tool
260
+ selection, not for a satisfying greeting, so chit-chat gets its own pass."""
261
+ yield {"type": "stage", "stage": "writing", "label": "Writing the answer"}
262
+ messages = [
263
+ {"role": "system", "content": CHAT_SMALLTALK_PROMPT},
264
+ *_answer_history(history),
265
+ {"role": "user", "content": message},
266
+ ]
267
+ response = yield from self._stream_prose(messages, MAX_CHAT_GENERATION_TOKENS)
268
+ if not response:
269
+ response = strip_function_blocks(raw_output) or (
270
+ "Hello! Ask me what everyone is building, which projects completed the most "
271
+ "quests, or what clusters exist."
272
+ )
273
+ yield {"type": "answer_skipped", "reason": "empty_answer", "text": response}
274
+ return response
275
+
276
+ def _grounded_answer(
277
+ self,
278
+ message: str,
279
+ history: list[dict[str, Any]],
280
+ call: ToolCall,
281
+ data: dict[str, Any],
282
+ ) -> Iterator[dict[str, Any]]:
283
+ digest = render_digest(_digest_for_model(call.name, data))
284
+ # NO history here: every fact the answer needs is in the digest, and a greedy
285
+ # 1B echoes similar-sounding lines from prior turns over the digest in front
286
+ # of it. Conversation context only matters for pass-1's tool choice.
287
+ messages = [
288
+ {"role": "system", "content": CHAT_ANSWER_PROMPT},
289
+ {"role": "user", "content": message},
290
+ {
291
+ "role": "assistant",
292
+ "content": "",
293
+ "tool_calls": [{"name": call.name, "arguments": call.arguments}],
294
+ },
295
+ {"role": "tool", "content": digest},
296
+ ]
297
+ return (yield from self._stream_prose(messages, MAX_CHAT_GENERATION_TOKENS))
298
+
299
+ def _stream_prose(
300
+ self,
301
+ messages: list[dict[str, Any]],
302
+ max_new_tokens: int,
303
+ ) -> Iterator[dict[str, Any]]:
304
+ """Stream a no-tools generation as thinking + token events; returns the prose.
305
+
306
+ The reasoning trace streams as ``thinking`` events; only the post-think text
307
+ becomes the answer. If a stray ``<function`` shows up in the answer the stream
308
+ stops early; the ``done`` response carries the stripped text, which the UI
309
+ treats as authoritative."""
310
+ splitter = _ThinkSplitter(getattr(self.runner, "supports_thinking", False))
311
+ pieces: list[str] = []
312
+ stream = self.runner.stream(messages, max_new_tokens=max_new_tokens, enable_thinking=True)
313
+ stray_function = False
314
+ try:
315
+ for count, piece in stream:
316
+ for kind, text in splitter.feed(piece):
317
+ if kind == "thinking":
318
+ yield {"type": "thinking", "pass": 2, "text": text}
319
+ continue
320
+ pieces.append(text)
321
+ if "<function" in "".join(pieces[-4:]):
322
+ stray_function = True
323
+ break
324
+ yield {"type": "token", "text": text}
325
+ if stray_function:
326
+ break
327
+ yield {
328
+ "type": "model_progress",
329
+ "pass": 2,
330
+ "tokens": count,
331
+ "max_tokens": max_new_tokens,
332
+ }
333
+ finally:
334
+ close = getattr(stream, "close", None)
335
+ if close is not None:
336
+ close()
337
+ for _kind, text in splitter.finish():
338
+ yield {"type": "thinking", "pass": 2, "text": text}
339
+ text = "".join(pieces)
340
+ marker = text.find("<function")
341
+ if marker >= 0:
342
+ text = text[:marker]
343
+ # A generation cut at max_new_tokens can end mid-marker ("<fun"); drop any
344
+ # trailing partial tag so it never reaches the authoritative response.
345
+ text = _PARTIAL_TAG_RE.sub("", text)
346
+ return clean(strip_function_blocks(text))
347
+
348
+ def _execute(
349
+ self,
350
+ call: ToolCall,
351
+ repository: DashboardRepository,
352
+ ) -> tuple[str, dict[str, Any], dict[str, Any] | None, str]:
353
+ """Run one validated tool; returns (executed tool, data, map action, empty reason)."""
354
+ name = call.name
355
+ if name == "atlas_overview":
356
+ data = repository.overview()
357
+ return name, data, {"type": "clear_filters"}, ""
358
+ if name == "list_clusters":
359
+ data = repository.list_clusters()
360
+ return name, data, None, "" if data["clusters"] else "no_clusters"
361
+ if name == "show_cluster":
362
+ label = clean(call.arguments.get("label"))
363
+ detail = repository.cluster_detail(label)
364
+ if detail is None:
365
+ return name, {"requested_label": label}, None, "unknown_cluster"
366
+ return name, detail, {"type": "filter_cluster", "label": detail["label"]}, ""
367
+ if name == "list_quests":
368
+ data = repository.list_quests()
369
+ if data["status"] != "analyzed":
370
+ return name, data, None, "quests_not_analyzed"
371
+ return name, data, None, ""
372
+ if name == "show_quest":
373
+ quest = clean(call.arguments.get("quest"))
374
+ detail = repository.quest_detail(quest)
375
+ if detail is None:
376
+ return name, {"requested_quest": quest}, None, "unknown_quest"
377
+ if detail["status"] != "analyzed":
378
+ return name, detail, None, "quests_not_analyzed"
379
+ map_action = {"type": "filter_quest", "quest": detail["id"]}
380
+ if detail["project_count"] == 0:
381
+ return name, detail, map_action, "quest_no_projects"
382
+ return name, detail, map_action, ""
383
+ if name == "show_project":
384
+ requested = clean(call.arguments.get("project"))
385
+ detail = repository.project_detail(requested)
386
+ if detail is None:
387
+ # Half-remembered names still get useful cards: fall back to search.
388
+ return self._search(repository, requested)
389
+ return name, detail, {"type": "highlight_projects", "ids": [detail["id"]]}, ""
390
+ if name == "top_projects_by_quests":
391
+ data = repository.top_by_quests()
392
+ if data["status"] != "analyzed":
393
+ return name, data, None, "quests_not_analyzed"
394
+ if not data["rows"]:
395
+ return name, data, None, "no_leaderboard_rows"
396
+ ids = [row["id"] for row in data["rows"]]
397
+ return name, data, {"type": "highlight_projects", "ids": ids}, ""
398
+ if name == "search_projects":
399
+ return self._search(repository, clean(call.arguments.get("query")))
400
+ if name == "recent_activity":
401
+ data = repository.recent_activity()
402
+ if not data["projects"]:
403
+ return name, data, None, "no_projects"
404
+ ids = [project["id"] for project in data["projects"]]
405
+ return name, data, {"type": "highlight_projects", "ids": ids}, ""
406
+ # Unreachable for validated calls; degrade to a safe overview.
407
+ return "atlas_overview", repository.overview(), None, ""
408
+
409
+ def _search(
410
+ self,
411
+ repository: DashboardRepository,
412
+ query: str,
413
+ ) -> tuple[str, dict[str, Any], dict[str, Any] | None, str]:
414
+ data = repository.search(query)
415
+ if not data["results"]:
416
+ return "search_projects", data, None, "no_search_results"
417
+ ids = [result["id"] for result in data["results"]]
418
+ return (
419
+ "search_projects",
420
+ data,
421
+ {"type": "highlight_projects", "ids": ids, "query": query},
422
+ "",
423
+ )
424
+
425
+ def _done(
426
+ self,
427
+ message: str,
428
+ history: list[dict[str, Any]],
429
+ response: str,
430
+ *,
431
+ tool: str,
432
+ data: dict[str, Any],
433
+ map_action: dict[str, Any] | None,
434
+ ) -> dict[str, Any]:
435
+ new_history = [
436
+ *history,
437
+ {"role": "user", "content": message},
438
+ {"role": "assistant", "content": response},
439
+ ]
440
+ return {
441
+ "type": "done",
442
+ "response": response,
443
+ "tool": tool,
444
+ "data": data,
445
+ "map_action": map_action,
446
+ "history": _normalize_history(new_history),
447
+ }
448
+
449
+
450
+ def _normalize_history(history: Any) -> list[dict[str, Any]]:
451
+ """Keep only well-formed prior prose turns, clipped, deduplicated, and capped.
452
+
453
+ Tool digests are deliberately dropped from history: stale counts must never
454
+ leak into a later answer — every turn re-reads a fresh repository snapshot.
455
+ Repeated assistant sentences are collapsed too: a greedy 1B that sees the
456
+ same line twice in history will echo it a third time regardless of the
457
+ digest in front of it."""
458
+ if not isinstance(history, list):
459
+ return []
460
+ cleaned: list[dict[str, Any]] = []
461
+ for item in history:
462
+ if not isinstance(item, dict):
463
+ continue
464
+ role = str(item.get("role") or "")
465
+ content = clean(item.get("content"))
466
+ if role not in ("user", "assistant") or not content:
467
+ continue
468
+ cleaned.append({"role": role, "content": content[:MAX_HISTORY_MESSAGE_CHARS]})
469
+ return _dedupe_assistant_echoes(cleaned)[-MAX_HISTORY_MESSAGES:]
470
+
471
+
472
+ def _dedupe_assistant_echoes(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
473
+ """Collapse consecutive identical assistant answers, keeping the NEWEST turn.
474
+
475
+ Walks backwards so the latest user/assistant pair always survives; the older
476
+ repeats (and the user turns that elicited them) are dropped."""
477
+ deduped_reversed: list[dict[str, Any]] = []
478
+ previous_assistant = None
479
+ skip_next_user = False
480
+ for item in reversed(messages):
481
+ if item["role"] == "assistant":
482
+ if item["content"] == previous_assistant:
483
+ skip_next_user = True
484
+ continue
485
+ previous_assistant = item["content"]
486
+ deduped_reversed.append(item)
487
+ else:
488
+ if skip_next_user:
489
+ skip_next_user = False
490
+ continue
491
+ deduped_reversed.append(item)
492
+ return list(reversed(deduped_reversed))
493
+
494
+
495
+ def _answer_history(history: list[dict[str, Any]]) -> list[dict[str, Any]]:
496
+ """The short tail of history given to answer generations.
497
+
498
+ Facts come from the digest, not from history; the prose passes only need
499
+ enough context for follow-ups, and a longer tail mostly adds echo bait."""
500
+ return history[-MAX_ANSWER_HISTORY_MESSAGES:]
501
+
502
+
503
+ def _digest_for_model(tool: str, data: dict[str, Any]) -> Any:
504
+ """Compact the verified payload into what the model may safely restate.
505
+
506
+ Beyond stripping urls/ids/scores, long listings are trimmed per tool: a 1B asked
507
+ to repeat ten labels starts blending them, so it only sees the few it may name.
508
+ The UI renders the FULL verified payload independently."""
509
+ trimmed: dict[str, Any] = dict(data)
510
+ if tool == "atlas_overview":
511
+ # Self-describing keys, most-liked first: with three lists in one digest a 1B
512
+ # answering "what's the coolest project" otherwise grabs the wrong column.
513
+ trimmed = {
514
+ "most_liked_projects": data.get("most_liked"),
515
+ "project_count": data.get("project_count"),
516
+ "cluster_count": data.get("cluster_count"),
517
+ "largest_clusters": data.get("top_clusters"),
518
+ "most_completed_quests": data.get("top_quests"),
519
+ "quest_status": data.get("quest_status"),
520
+ }
521
+ if tool == "list_clusters":
522
+ # Ten compound labels is past what a 1B can restate without blending them;
523
+ # it gets the count and the largest cluster, the cards carry the full list.
524
+ clusters = data.get("clusters") or []
525
+ trimmed = {
526
+ "cluster_count": data.get("cluster_count"),
527
+ "largest_cluster": clusters[0] if clusters else None,
528
+ "note": "the full cluster list is already shown to the user as cards",
529
+ }
530
+ if tool == "list_quests":
531
+ quests = data.get("quests") or []
532
+ trimmed = {
533
+ "status": data.get("status"),
534
+ "quest_count": len(quests),
535
+ "most_completed_quest": quests[0] if quests else None,
536
+ "note": "the full quest list is already shown to the user as cards",
537
+ }
538
+ if tool == "show_cluster":
539
+ trimmed["examples"] = (data.get("examples") or [])[:3]
540
+ if tool == "show_quest":
541
+ trimmed["examples"] = (data.get("examples") or [])[:3]
542
+ if tool == "search_projects":
543
+ # BM25 "total" counts any term overlap; quoting it as "N projects about X"
544
+ # would mislead, so the model only sees the close matches themselves.
545
+ trimmed.pop("total", None)
546
+ return _strip_digest_keys(trimmed)
547
+
548
+
549
+ def _strip_digest_keys(data: Any) -> Any:
550
+ if isinstance(data, dict):
551
+ return {
552
+ key: _strip_digest_keys(value)
553
+ for key, value in data.items()
554
+ if key not in _DIGEST_DROPPED_KEYS
555
+ }
556
+ if isinstance(data, list):
557
+ return [_strip_digest_keys(item) for item in data]
558
+ return data
559
+
560
+
561
+ def render_digest(data: Any, indent: int = 0) -> str:
562
+ """Render the digest as plain ``key: value`` lines instead of JSON.
563
+
564
+ A 1B model copying labels out of nested JSON starts blending adjacent strings;
565
+ one fact per line keeps its quotes literal."""
566
+ return "\n".join(_digest_lines(data, indent))
567
+
568
+
569
+ def _digest_lines(value: Any, indent: int) -> list[str]:
570
+ pad = " " * indent
571
+ if isinstance(value, dict):
572
+ lines: list[str] = []
573
+ for key, item in value.items():
574
+ if isinstance(item, (dict, list)):
575
+ lines.append(f"{pad}{key}:")
576
+ lines.extend(_digest_lines(item, indent + 1))
577
+ else:
578
+ lines.append(f"{pad}{key}: {_digest_value(item)}")
579
+ return lines
580
+ if isinstance(value, list):
581
+ lines = []
582
+ for item in value:
583
+ if isinstance(item, dict):
584
+ flat = ", ".join(
585
+ f"{key}: {_digest_value(entry)}"
586
+ for key, entry in item.items()
587
+ if not isinstance(entry, (dict, list))
588
+ )
589
+ lines.append(f"{pad}- {flat}")
590
+ else:
591
+ lines.append(f"{pad}- {_digest_value(item)}")
592
+ return lines
593
+ return [f"{pad}{_digest_value(value)}"]
594
+
595
+
596
+ def _digest_value(value: Any) -> Any:
597
+ # Quote strings so compound labels like "Dream / Oracle" keep hard copy
598
+ # boundaries — a greedy 1B blends adjacent unquoted multi-word labels.
599
+ if isinstance(value, str):
600
+ return f'"{value}"'
601
+ return value
602
+
603
+
604
+ def _templated_sentence(call: ToolCall, data: dict[str, Any], reason: str) -> str:
605
+ """Deterministic sentences for the turns where the model must not improvise."""
606
+ if reason == "quests_not_analyzed":
607
+ return (
608
+ "Quest analysis has not run for this snapshot yet, so quest coverage is empty. "
609
+ "Refresh the map to classify the field, or ask about clusters and projects instead."
610
+ )
611
+ if reason == "unknown_cluster":
612
+ requested = clean(data.get("requested_label")) or "that name"
613
+ return (
614
+ f"I could not find a cluster matching {requested} in the current snapshot. "
615
+ "Ask me to list the clusters to see the live labels."
616
+ )
617
+ if reason == "unknown_quest":
618
+ requested = clean(data.get("requested_quest")) or "that name"
619
+ return (
620
+ f"I could not match {requested} to a hackathon quest. "
621
+ "Ask me to list the quests to see the official names."
622
+ )
623
+ if reason == "quest_no_projects":
624
+ label = clean(data.get("label"))
625
+ if label:
626
+ return f"No project in the current snapshot has completed {label} yet."
627
+ return "No project in the current snapshot has completed that quest yet."
628
+ if reason == "no_leaderboard_rows":
629
+ return (
630
+ "Quest analysis ran, but no project in the current snapshot has completed a "
631
+ "quest yet — the leaderboard is empty."
632
+ )
633
+ if reason == "no_search_results":
634
+ query = clean(data.get("query")) or "that"
635
+ return (
636
+ f"The atlas has no match for {query}. "
637
+ "That can be good news for originality — try a broader term to double-check."
638
+ )
639
+ if reason == "no_clusters" or reason == "no_projects":
640
+ return "The current snapshot has no data for that yet. Try refreshing the map."
641
+ if reason == "empty_answer":
642
+ return "The verified results are on the cards below."
643
+ return "The verified results are on the cards below."
hackathon_advisor/dashboard_chat_contracts.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tool contracts for the atlas chat: native MiniCPM5 format, chat-specific fallback.
2
+
3
+ The dashboard chat drives the BASE MiniCPM5-1B model through its native
4
+ tool-calling protocol: tool JSON schemas go in via ``apply_chat_template(...,
5
+ tools=...)`` and the model answers either with plain prose (no tool needed) or
6
+ with one XML call of the form::
7
+
8
+ <function name="tool_name"><param name="arg">value</param></function>
9
+
10
+ That argument encoding (``<param>`` children, CDATA for special characters)
11
+ differs from the advisor's JSON-body format in ``tool_contracts.py``, so it gets
12
+ its own parser here. Validation reuses ``validate_tool_call`` against the chat
13
+ tool specs. The degradation ladder is chat-specific: prose with no function call
14
+ is a deliberate "no tool" outcome, while a malformed call degrades through a
15
+ keyword intent router and finally to a BM25 search of the raw message — never to
16
+ the advisor's ``search_projects``/``find_whitespace`` defaults, which assume the
17
+ advisor's idea-board context.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ import re
24
+ from typing import Any, Literal
25
+ from xml.etree import ElementTree
26
+
27
+ from hackathon_advisor.tool_contracts import (
28
+ ToolCall,
29
+ ToolContractError,
30
+ ToolField,
31
+ ToolSpec,
32
+ validate_tool_call,
33
+ )
34
+
35
+ CHAT_TOOL_SPECS: dict[str, ToolSpec] = {
36
+ "atlas_overview": ToolSpec(
37
+ name="atlas_overview",
38
+ description="Summarize the whole field: project totals, biggest clusters, quest coverage.",
39
+ fields={},
40
+ ),
41
+ "list_clusters": ToolSpec(
42
+ name="list_clusters",
43
+ description="List the project clusters (themes) with sizes and keywords.",
44
+ fields={},
45
+ ),
46
+ "show_cluster": ToolSpec(
47
+ name="show_cluster",
48
+ description="Inspect one cluster by its label and show example projects.",
49
+ fields={
50
+ "label": ToolField("string", "Cluster label, such as Voice / Chatbot.", required=True)
51
+ },
52
+ ),
53
+ "list_quests": ToolSpec(
54
+ name="list_quests",
55
+ description="List the hackathon quests with how many projects completed each.",
56
+ fields={},
57
+ ),
58
+ "show_quest": ToolSpec(
59
+ name="show_quest",
60
+ description="Inspect one quest: its description, coverage, and example projects.",
61
+ fields={
62
+ "quest": ToolField(
63
+ "string", "Quest name, such as Off the Grid or Tiny Titan.", required=True
64
+ )
65
+ },
66
+ ),
67
+ "show_project": ToolSpec(
68
+ name="show_project",
69
+ description="Read one project's README and main app file by project name.",
70
+ fields={"project": ToolField("string", "Project name, id, or slug.", required=True)},
71
+ ),
72
+ "top_projects_by_quests": ToolSpec(
73
+ name="top_projects_by_quests",
74
+ description="Rank projects by how many quests they completed (the quest leaderboard).",
75
+ fields={},
76
+ ),
77
+ "search_projects": ToolSpec(
78
+ name="search_projects",
79
+ description="Full-text search across all projects on the map.",
80
+ fields={
81
+ "query": ToolField("string", "Topic, model, or idea to search for.", required=True)
82
+ },
83
+ ),
84
+ "recent_activity": ToolSpec(
85
+ name="recent_activity",
86
+ description="Show the most recently updated projects.",
87
+ fields={},
88
+ ),
89
+ }
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class ChatToolResolution:
94
+ """Outcome of reading one pass-1 model output.
95
+
96
+ ``none`` means the model deliberately answered without a tool (chit-chat);
97
+ ``call`` is None only in that case.
98
+ """
99
+
100
+ status: Literal["valid", "defaulted", "none"]
101
+ call: ToolCall | None
102
+ errors: tuple[str, ...]
103
+
104
+ def to_dict(self) -> dict[str, Any]:
105
+ return {
106
+ "status": self.status,
107
+ "call": self.call.to_dict() if self.call else None,
108
+ "errors": list(self.errors),
109
+ }
110
+
111
+
112
+ def chat_tool_schemas() -> list[dict[str, Any]]:
113
+ return [spec.to_schema() for spec in CHAT_TOOL_SPECS.values()]
114
+
115
+
116
+ _FUNCTION_BLOCK_RE = re.compile(r"<function\b.*?</function>", re.DOTALL)
117
+ _FUNCTION_OPEN_RE = re.compile(r"<function\b")
118
+
119
+
120
+ def parse_native_tool_call(text: str) -> ToolCall:
121
+ """Extract and parse the first native-format function call in ``text``.
122
+
123
+ The native template lets the model wrap a call in prose, so surrounding text
124
+ is ignored; only the ``<function ...>...</function>`` block is parsed.
125
+ """
126
+ block = _FUNCTION_BLOCK_RE.search(text or "")
127
+ if block is None:
128
+ raise ToolContractError("no <function> call found in model output")
129
+ try:
130
+ node = ElementTree.fromstring(block.group(0))
131
+ except ElementTree.ParseError as error:
132
+ raise ToolContractError(f"invalid native tool call XML: {error}") from error
133
+ name = str(node.attrib.get("name") or "").strip()
134
+ if not name:
135
+ raise ToolContractError("function call is missing a name")
136
+ arguments: dict[str, Any] = {}
137
+ for child in node:
138
+ if child.tag != "param":
139
+ raise ToolContractError(f"unexpected element <{child.tag}> in function call")
140
+ param_name = str(child.attrib.get("name") or "").strip()
141
+ if not param_name:
142
+ raise ToolContractError("param is missing a name")
143
+ arguments[param_name] = _element_text(child).strip()
144
+ return ToolCall(name=name, arguments=arguments)
145
+
146
+
147
+ def resolve_chat_tool_call(model_output: str, fallback_query: str = "") -> ChatToolResolution:
148
+ """Validate one pass-1 output, or degrade: intent router, then BM25 search."""
149
+ text = str(model_output or "")
150
+ if _FUNCTION_OPEN_RE.search(text) is None:
151
+ return ChatToolResolution(status="none", call=None, errors=())
152
+
153
+ errors: list[str] = []
154
+ try:
155
+ call = validate_tool_call(parse_native_tool_call(text), specs=CHAT_TOOL_SPECS)
156
+ return ChatToolResolution(status="valid", call=call, errors=())
157
+ except ToolContractError as error:
158
+ errors.append(str(error))
159
+
160
+ call = heuristic_chat_call(fallback_query)
161
+ return ChatToolResolution(status="defaulted", call=call, errors=tuple(errors))
162
+
163
+
164
+ def data_intent_call(message: str) -> ToolCall | None:
165
+ """Map a message with a CLEAR data intent to a tool call; None means no clear intent.
166
+
167
+ Used as the accuracy backstop when the model answers a data-shaped question in plain
168
+ prose: an explicit intent routes to the matching tool, anything else (greetings,
169
+ meta questions) stays conversational."""
170
+ lower = " ".join(str(message or "").casefold().split())
171
+ cleaned = " ".join(str(message or "").split())
172
+ detail_intent = _mentions(
173
+ lower, ("what is in", "what's in", "inside", "show me the", "tell me about", "about the")
174
+ )
175
+ if _mentions(
176
+ lower, ("leaderboard", "most quest", "who completed", "top project", "top team", "winning")
177
+ ):
178
+ return ToolCall("top_projects_by_quests", {})
179
+ if _mentions(lower, ("cluster", "theme", "group", "region")):
180
+ if detail_intent:
181
+ # cluster_detail() fuzzy-resolves a label embedded in the question.
182
+ return ToolCall("show_cluster", {"label": cleaned})
183
+ return ToolCall("list_clusters", {})
184
+ if _mentions(lower, ("quest", "badge", "challenge")):
185
+ if detail_intent:
186
+ return ToolCall("show_quest", {"quest": cleaned})
187
+ return ToolCall("list_quests", {})
188
+ if _mentions(lower, ("recent", "latest", "newest", "just updated", "activity")):
189
+ return ToolCall("recent_activity", {})
190
+ if _mentions(
191
+ lower,
192
+ (
193
+ "overview",
194
+ "everyone building",
195
+ "everyone doing",
196
+ "whole field",
197
+ "summary of the",
198
+ "most liked",
199
+ "most popular",
200
+ "coolest",
201
+ "best project",
202
+ "favorite project",
203
+ ),
204
+ ):
205
+ return ToolCall("atlas_overview", {})
206
+ if (
207
+ _mentions(
208
+ lower,
209
+ ("readme", "app file", "source code", "how does", "how is", "what does", "built with"),
210
+ )
211
+ or detail_intent
212
+ ):
213
+ # project_detail() spots a title embedded in the question; the engine falls
214
+ # back to BM25 search when no project matches.
215
+ return ToolCall("show_project", {"project": cleaned})
216
+ if _mentions(
217
+ lower,
218
+ (
219
+ "find ",
220
+ "search",
221
+ "looking for",
222
+ "projects about",
223
+ "projects on",
224
+ "show me",
225
+ "anything about",
226
+ "who is building",
227
+ "how many",
228
+ "number of",
229
+ "count of",
230
+ "is there a",
231
+ "are there any",
232
+ ),
233
+ ):
234
+ return ToolCall("search_projects", {"query": " ".join(str(message).split())})
235
+ return None
236
+
237
+
238
+ _SMALLTALK_PATTERNS = (
239
+ "hi",
240
+ "hello",
241
+ "hey",
242
+ "yo",
243
+ "thanks",
244
+ "thank you",
245
+ "ok",
246
+ "okay",
247
+ "cool",
248
+ "nice",
249
+ "bye",
250
+ "goodbye",
251
+ "why",
252
+ "really",
253
+ "are you sure",
254
+ "who are you",
255
+ "what are you",
256
+ "what can you do",
257
+ "how do you work",
258
+ "help",
259
+ )
260
+
261
+
262
+ def smalltalk_intent(message: str) -> bool:
263
+ """True only for greetings, meta questions, and short follow-ups.
264
+
265
+ The chat is a data-exploration surface, so the safe default for anything
266
+ substantive is a tool (BM25 search) — letting an unmatched question fall
267
+ through to ungrounded small talk is how the model ends up inventing facts."""
268
+ lower = " ".join(str(message or "").casefold().split()).rstrip(".!?")
269
+ if not lower:
270
+ return True
271
+ # Pattern-table only: an unknown two-word phrase like "knitting helpers" is a
272
+ # search, and an unmatched search honestly answers "no match" — never invents.
273
+ return any(
274
+ lower == pattern or lower.startswith(f"{pattern} ") for pattern in _SMALLTALK_PATTERNS
275
+ )
276
+
277
+
278
+ def heuristic_chat_call(message: str) -> ToolCall:
279
+ """Keyword intent router used when the model's tool call cannot be salvaged."""
280
+ intent = data_intent_call(message)
281
+ if intent is not None:
282
+ return intent
283
+ cleaned = " ".join(str(message or "").split())
284
+ if cleaned:
285
+ return ToolCall("search_projects", {"query": cleaned})
286
+ return ToolCall("atlas_overview", {})
287
+
288
+
289
+ def strip_function_blocks(text: str) -> str:
290
+ """Remove any stray function-call XML a pass-2 generation might emit."""
291
+ return _FUNCTION_BLOCK_RE.sub("", str(text or "")).strip()
292
+
293
+
294
+ def _mentions(lower_text: str, phrases: tuple[str, ...]) -> bool:
295
+ return any(phrase in lower_text for phrase in phrases)
296
+
297
+
298
+ def _element_text(node: ElementTree.Element) -> str:
299
+ return "".join(node.itertext())
hackathon_advisor/dashboard_repository.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read-only query layer over one immutable dashboard snapshot.
2
+
3
+ The atlas chat feature (and any future consumer) asks questions like "what is
4
+ everyone building", "which projects completed the most quests", or "what does the
5
+ Voice cluster contain". Those queries belong in one place — not in tool prompts,
6
+ not in route handlers — so this module wraps a single dashboard snapshot
7
+ (``dashboard_payload`` + its ``DashboardSearchIndex``) behind typed query methods
8
+ that return plain JSON-ready dicts.
9
+
10
+ A repository instance never touches module globals, locks, or models: the caller
11
+ captures a consistent snapshot (app.py does so under ``_runtime_lock``, the same
12
+ pattern as ``/api/dashboard/search``) and constructs the repository outside the
13
+ lock. Quest data may be absent (``quest_report.status == "not_analyzed"``); every
14
+ method degrades to empty-but-well-formed results in that case.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Mapping
20
+ from difflib import SequenceMatcher
21
+ from typing import Any
22
+
23
+ from hackathon_advisor._text import clean, list_of_dicts
24
+ from hackathon_advisor.dashboard_search import DashboardSearchIndex
25
+ from hackathon_advisor.data import (
26
+ normalize_project_tags,
27
+ public_project_summary,
28
+ public_project_title,
29
+ )
30
+ from hackathon_advisor.quest_taxonomy import (
31
+ build_app_segment,
32
+ build_readme_segment,
33
+ canonical_quest_id,
34
+ quest_label,
35
+ quest_profiles,
36
+ )
37
+
38
+ CLUSTER_LABEL_MATCH_THRESHOLD = 0.6
39
+ DEFAULT_LEADERBOARD_LIMIT = 8
40
+ DEFAULT_SEARCH_LIMIT = 8
41
+ DEFAULT_RECENT_LIMIT = 6
42
+ DEFAULT_EXAMPLE_LIMIT = 6
43
+ README_EXCERPT_CHARS = 1500
44
+ APP_EXCERPT_CHARS = 1900
45
+
46
+
47
+ class DashboardRepository:
48
+ """Pure queries over one dashboard snapshot; safe to use without locks."""
49
+
50
+ def __init__(
51
+ self, dashboard_payload: Mapping[str, Any], search_index: DashboardSearchIndex
52
+ ) -> None:
53
+ self._payload = dashboard_payload
54
+ self._search_index = search_index
55
+ self._points = list_of_dicts(dashboard_payload.get("points"))
56
+ self._clusters = list_of_dicts(dashboard_payload.get("clusters"))
57
+ quest_report = dashboard_payload.get("quest_report")
58
+ self._quest_report = quest_report if isinstance(quest_report, Mapping) else {}
59
+ self._cluster_label_by_id = {
60
+ str(cluster.get("id") or ""): clean(cluster.get("label")) for cluster in self._clusters
61
+ }
62
+ # Full Project objects (incl. readme_body / app_file_source, which the public
63
+ # dashboard points strip) ride along inside the search index's documents.
64
+ self._project_by_id = {
65
+ document.project.id: document.project for document in search_index.documents
66
+ }
67
+ self._point_by_id = {str(point.get("id") or ""): point for point in self._points}
68
+
69
+ def quests_analyzed(self) -> bool:
70
+ return str(self._quest_report.get("status") or "") == "analyzed"
71
+
72
+ def overview(self) -> dict[str, Any]:
73
+ """Field-wide counts plus the brightest clusters, quests, and projects."""
74
+ top_quests = [
75
+ {"id": quest["id"], "label": quest["label"], "project_count": quest["project_count"]}
76
+ for quest in self._quests_by_coverage()[:3]
77
+ if quest["project_count"] > 0
78
+ ]
79
+ most_liked = sorted(
80
+ self._points,
81
+ key=lambda point: (int(point.get("likes") or 0), clean(point.get("title")).casefold()),
82
+ reverse=True,
83
+ )[:3]
84
+ return {
85
+ "project_count": int(self._payload.get("project_count") or len(self._points)),
86
+ "cluster_count": len(self._clusters),
87
+ "generated_at": str(self._payload.get("generated_at") or ""),
88
+ "quest_status": str(self._quest_report.get("status") or "not_analyzed"),
89
+ "top_clusters": [
90
+ {
91
+ "label": clean(cluster.get("label")),
92
+ "project_count": int(cluster.get("project_count") or 0),
93
+ }
94
+ for cluster in self._clusters[:3]
95
+ ],
96
+ "top_quests": top_quests,
97
+ "most_liked": [self._project_row(point) for point in most_liked],
98
+ }
99
+
100
+ def list_clusters(self) -> dict[str, Any]:
101
+ return {
102
+ "cluster_count": len(self._clusters),
103
+ "clusters": [
104
+ {
105
+ "label": clean(cluster.get("label")),
106
+ "project_count": int(cluster.get("project_count") or 0),
107
+ "keywords": [clean(keyword) for keyword in (cluster.get("keywords") or [])[:4]],
108
+ }
109
+ for cluster in self._clusters
110
+ ],
111
+ }
112
+
113
+ def cluster_detail(self, label: str) -> dict[str, Any] | None:
114
+ """Resolve a cluster by (fuzzy) label or id; cluster ids are unstable across refreshes."""
115
+ cluster = self._resolve_cluster(label)
116
+ if cluster is None:
117
+ return None
118
+ examples = list_of_dicts(cluster.get("representative_projects"))[:DEFAULT_EXAMPLE_LIMIT]
119
+ return {
120
+ "label": clean(cluster.get("label")),
121
+ "project_count": int(cluster.get("project_count") or 0),
122
+ "keywords": [clean(keyword) for keyword in (cluster.get("keywords") or [])[:5]],
123
+ "examples": [self._project_row(example) for example in examples],
124
+ }
125
+
126
+ def list_quests(self) -> dict[str, Any]:
127
+ return {
128
+ "status": str(self._quest_report.get("status") or "not_analyzed"),
129
+ "quests": self._quests_by_coverage(),
130
+ }
131
+
132
+ def quest_detail(self, quest: str) -> dict[str, Any] | None:
133
+ try:
134
+ quest_id = canonical_quest_id(quest)
135
+ except ValueError:
136
+ quest_id = self._find_quest_in_text(quest)
137
+ if quest_id is None:
138
+ return None
139
+ report_entry = next(
140
+ (
141
+ entry
142
+ for entry in list_of_dicts(self._quest_report.get("quests"))
143
+ if str(entry.get("id") or "") == quest_id
144
+ ),
145
+ {},
146
+ )
147
+ profile = next(
148
+ (profile for profile in quest_profiles() if profile["id"] == quest_id),
149
+ {"id": quest_id, "label": quest_id, "description": ""},
150
+ )
151
+ matched = [point for point in self._points if quest_id in (point.get("quest_ids") or [])]
152
+ examples = list_of_dicts(report_entry.get("examples"))[:DEFAULT_EXAMPLE_LIMIT] or [
153
+ self._project_row(point) for point in matched[:DEFAULT_EXAMPLE_LIMIT]
154
+ ]
155
+ return {
156
+ "id": quest_id,
157
+ "label": profile["label"],
158
+ "description": profile["description"],
159
+ "status": str(self._quest_report.get("status") or "not_analyzed"),
160
+ "project_count": int(report_entry.get("project_count") or len(matched)),
161
+ "examples": [self._project_row(example) for example in examples],
162
+ }
163
+
164
+ def top_by_quests(self, limit: int = DEFAULT_LEADERBOARD_LIMIT) -> dict[str, Any]:
165
+ """Per-project quest leaderboard (projects ARE the teams: no author field exists)."""
166
+ rows = [
167
+ {
168
+ **self._project_row(point),
169
+ "quest_count": len(point.get("quest_ids") or []),
170
+ "quest_ids": [str(quest) for quest in point.get("quest_ids") or []],
171
+ }
172
+ for point in self._points
173
+ if point.get("quest_ids")
174
+ ]
175
+ rows.sort(
176
+ key=lambda row: (row["quest_count"], row["likes"], row["title"].casefold()),
177
+ reverse=True,
178
+ )
179
+ return {
180
+ "status": str(self._quest_report.get("status") or "not_analyzed"),
181
+ "rows": rows[: max(1, int(limit))],
182
+ "projects_with_quests": len(rows),
183
+ }
184
+
185
+ def search(self, query: str, limit: int = DEFAULT_SEARCH_LIMIT) -> dict[str, Any]:
186
+ payload = self._search_index.search(clean(query), limit=max(1, int(limit)))
187
+ return {
188
+ "query": payload["query"],
189
+ "total": int(payload["total"]),
190
+ "results": [
191
+ {
192
+ "id": str(result.get("project_id") or ""),
193
+ "title": clean(result.get("title")),
194
+ "summary": clean(result.get("summary")),
195
+ "url": str(result.get("url") or ""),
196
+ "score": float(result.get("score") or 0.0),
197
+ }
198
+ for result in payload["results"]
199
+ ],
200
+ }
201
+
202
+ def recent_activity(self, limit: int = DEFAULT_RECENT_LIMIT) -> dict[str, Any]:
203
+ ordered = sorted(
204
+ self._points,
205
+ key=lambda point: str(point.get("last_modified") or ""),
206
+ reverse=True,
207
+ )[: max(1, int(limit))]
208
+ return {
209
+ "projects": [
210
+ {
211
+ **self._project_row(point),
212
+ "last_modified": str(point.get("last_modified") or ""),
213
+ "cluster_label": self._cluster_label_by_id.get(
214
+ str(point.get("cluster_id") or ""), ""
215
+ ),
216
+ }
217
+ for point in ordered
218
+ ],
219
+ }
220
+
221
+ def _quests_by_coverage(self) -> list[dict[str, Any]]:
222
+ entries = [
223
+ {
224
+ "id": str(entry.get("id") or ""),
225
+ "label": clean(entry.get("label")) or str(entry.get("id") or ""),
226
+ "description": clean(entry.get("description")),
227
+ "project_count": int(entry.get("project_count") or 0),
228
+ }
229
+ for entry in list_of_dicts(self._quest_report.get("quests"))
230
+ ]
231
+ return sorted(
232
+ entries, key=lambda entry: (-entry["project_count"], entry["label"].casefold())
233
+ )
234
+
235
+ def project_detail(self, name: str) -> dict[str, Any] | None:
236
+ """One project's card plus its README and main-app-file excerpts.
237
+
238
+ The excerpts reuse the quest classifier's prompt view (build_readme_segment /
239
+ build_app_segment) — the same budgeted slices MiniCPM already reads well."""
240
+ project = self._resolve_project(name)
241
+ if project is None:
242
+ return None
243
+ point = self._point_by_id.get(project.id, {})
244
+ app_excerpt = _clip_excerpt(
245
+ build_app_segment(project.app_file_source, project.app_file_embedding_text),
246
+ APP_EXCERPT_CHARS,
247
+ )
248
+ return {
249
+ "id": project.id,
250
+ "title": public_project_title(project.title),
251
+ "summary": public_project_summary(project.summary),
252
+ "url": project.url,
253
+ "likes": project.likes,
254
+ "sdk": project.sdk,
255
+ "models": list(project.models)[:4],
256
+ "tags": list(normalize_project_tags(project.tags))[:6],
257
+ "last_modified": project.last_modified,
258
+ "cluster_label": self._cluster_label_by_id.get(str(point.get("cluster_id") or ""), ""),
259
+ "quests": [quest_label(str(quest)) for quest in point.get("quest_ids") or []],
260
+ "readme_excerpt": _clip_excerpt(
261
+ build_readme_segment(project.readme_body), README_EXCERPT_CHARS
262
+ ),
263
+ "app_file": project.app_file,
264
+ "app_excerpt": app_excerpt,
265
+ }
266
+
267
+ def _resolve_project(self, name: str) -> Any | None:
268
+ """Match a project by id, slug, or title — exact first, then embedded in a
269
+ longer question ("tell me about Jawbreaker"), longest title winning."""
270
+ wanted = clean(name).casefold()
271
+ if not wanted:
272
+ return None
273
+ for project in self._project_by_id.values():
274
+ slug = project.id.rsplit("/", 1)[-1]
275
+ if wanted in (project.id.casefold(), slug.casefold()):
276
+ return project
277
+ if public_project_title(project.title).casefold() == wanted:
278
+ return project
279
+ best, best_length = None, 0
280
+ for project in self._project_by_id.values():
281
+ title = public_project_title(project.title).casefold()
282
+ slug = project.id.rsplit("/", 1)[-1].casefold()
283
+ for candidate in (title, slug):
284
+ if len(candidate) > 3 and candidate in wanted and len(candidate) > best_length:
285
+ best, best_length = project, len(candidate)
286
+ return best
287
+
288
+ def _find_quest_in_text(self, text: str) -> str | None:
289
+ """Spot a quest id or label embedded in a longer question."""
290
+ wanted = clean(text).casefold()
291
+ if not wanted:
292
+ return None
293
+ for profile in quest_profiles():
294
+ if profile["id"].casefold() in wanted or profile["label"].casefold() in wanted:
295
+ return profile["id"]
296
+ return None
297
+
298
+ def _resolve_cluster(self, label: str) -> Mapping[str, Any] | None:
299
+ wanted = clean(label).casefold()
300
+ if not wanted:
301
+ return None
302
+ for cluster in self._clusters:
303
+ if str(cluster.get("id") or "").casefold() == wanted:
304
+ return cluster
305
+ for cluster in self._clusters:
306
+ if clean(cluster.get("label")).casefold() == wanted:
307
+ return cluster
308
+ for cluster in self._clusters:
309
+ cluster_label = clean(cluster.get("label")).casefold()
310
+ if wanted in cluster_label or cluster_label in wanted:
311
+ return cluster
312
+ for cluster in self._clusters:
313
+ keywords = {clean(keyword).casefold() for keyword in cluster.get("keywords") or []}
314
+ if any(token in keywords for token in wanted.split()):
315
+ return cluster
316
+ best, best_score = None, 0.0
317
+ for cluster in self._clusters:
318
+ score = SequenceMatcher(None, wanted, clean(cluster.get("label")).casefold()).ratio()
319
+ if score > best_score:
320
+ best, best_score = cluster, score
321
+ return best if best_score >= CLUSTER_LABEL_MATCH_THRESHOLD else None
322
+
323
+ def _project_row(self, point: Mapping[str, Any]) -> dict[str, Any]:
324
+ return {
325
+ "id": str(point.get("id") or ""),
326
+ "title": clean(point.get("title")) or str(point.get("id") or ""),
327
+ "url": str(point.get("url") or ""),
328
+ "likes": int(point.get("likes") or 0),
329
+ }
330
+
331
+
332
+ def _clip_excerpt(text: str, limit: int) -> str:
333
+ # Newlines stay (app files read as code); only the length is bounded.
334
+ cleaned = str(text or "").strip()
335
+ if len(cleaned) <= limit:
336
+ return cleaned
337
+ return cleaned[:limit].rstrip() + " ..."
hackathon_advisor/model_runtime.py CHANGED
@@ -24,6 +24,17 @@ MAX_TOOL_CALL_TOKENS = 180
24
  MINICPM_DEMO_TEMPERATURE = 0.9
25
  MINICPM_DEMO_TOP_P = 0.95
26
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  class ToolPlanner(Protocol):
29
  backend: str
@@ -157,6 +168,7 @@ class MiniCPMTransformersPlanner:
157
  self._tokenizer = None
158
  self._model = None
159
  self._inference_mode = None
 
160
 
161
  def plan(self, message: str, state: dict[str, Any]) -> ToolResolution:
162
  resolution: ToolResolution | None = None
@@ -179,6 +191,15 @@ class MiniCPMTransformersPlanner:
179
  def _ensure_loaded(self) -> None:
180
  if self._model is not None and self._tokenizer is not None:
181
  return
 
 
 
 
 
 
 
 
 
182
  try:
183
  import torch
184
  from transformers import AutoModelForCausalLM, AutoTokenizer
@@ -235,44 +256,31 @@ class MiniCPMTransformersPlanner:
235
  )
236
 
237
  def _stream_tool_call(self, prompt: str) -> Iterator[tuple[int, str]]:
238
- from transformers import TextIteratorStreamer
239
-
240
  assert self._tokenizer is not None
241
  assert self._model is not None
242
  inputs = self._prepare_inputs(prompt)
243
- streamer = TextIteratorStreamer(
244
- self._tokenizer, skip_prompt=True, skip_special_tokens=True
245
- )
246
- generation_kwargs = _minicpm_generation_kwargs(
247
  inputs,
248
  max_new_tokens=MAX_TOOL_CALL_TOKENS,
249
  temperature=0.0,
250
- streamer=streamer,
251
  )
252
- errors: list[BaseException] = []
253
-
254
- def _run() -> None:
255
- context = self._inference_mode() if self._inference_mode is not None else nullcontext()
256
- try:
257
- with context:
258
- self._model.generate(**generation_kwargs)
259
- except BaseException as error: # surfaced after the streamer drains
260
- errors.append(error)
261
- # generate() never reached its end sentinel, so wake the consumer instead of
262
- # letting it block forever, then re-raise from the main thread below.
263
- streamer.end()
264
-
265
- worker = threading.Thread(target=_run, daemon=True)
266
- worker.start()
267
- tokens = 0
268
- for piece in streamer:
269
- if not piece:
270
- continue
271
- tokens += 1
272
- yield tokens, piece
273
- worker.join()
274
- if errors:
275
- raise errors[0]
276
 
277
 
278
  def _device_available(device: str, torch: Any) -> bool:
@@ -359,6 +367,30 @@ def _minicpm_chat_inputs(
359
  return inputs
360
 
361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  def _minicpm_generation_kwargs(
363
  inputs: dict[str, Any],
364
  *,
@@ -380,6 +412,188 @@ def _minicpm_generation_kwargs(
380
  return generation_kwargs
381
 
382
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  def create_tool_planner(device: str = "auto") -> ToolPlanner:
384
  backend = os.environ.get("ADVISOR_MODEL_BACKEND", "").strip().lower() or DEFAULT_BACKEND
385
  if backend == "rules":
 
24
  MINICPM_DEMO_TEMPERATURE = 0.9
25
  MINICPM_DEMO_TOP_P = 0.95
26
 
27
+ # One lock for every MiniCPM generation in this process. The atlas chat borrows the
28
+ # advisor's loaded model and toggles its LoRA off via PeftModel.disable_adapter(),
29
+ # which mutates shared model state — so adapter toggling and generate() must never
30
+ # interleave across threads. The lock is held for the FULL lifetime of the streaming
31
+ # worker thread (acquired before it starts, released after it joins).
32
+ _GENERATION_LOCK = threading.Lock()
33
+
34
+
35
+ def generation_lock() -> threading.Lock:
36
+ return _GENERATION_LOCK
37
+
38
 
39
  class ToolPlanner(Protocol):
40
  backend: str
 
168
  self._tokenizer = None
169
  self._model = None
170
  self._inference_mode = None
171
+ self._load_lock = threading.Lock()
172
 
173
  def plan(self, message: str, state: dict[str, Any]) -> ToolResolution:
174
  resolution: ToolResolution | None = None
 
191
  def _ensure_loaded(self) -> None:
192
  if self._model is not None and self._tokenizer is not None:
193
  return
194
+ # Double-checked: the advisor and the atlas chat share this planner, so two
195
+ # cold-start requests could otherwise both run the full from_pretrained load
196
+ # (a ~2x transient memory spike). _GENERATION_LOCK starts too late to help.
197
+ with self._load_lock:
198
+ if self._model is not None and self._tokenizer is not None:
199
+ return
200
+ self._load()
201
+
202
+ def _load(self) -> None:
203
  try:
204
  import torch
205
  from transformers import AutoModelForCausalLM, AutoTokenizer
 
256
  )
257
 
258
  def _stream_tool_call(self, prompt: str) -> Iterator[tuple[int, str]]:
 
 
259
  assert self._tokenizer is not None
260
  assert self._model is not None
261
  inputs = self._prepare_inputs(prompt)
262
+ yield from _stream_minicpm_generation(
263
+ self._model,
264
+ self._tokenizer,
 
265
  inputs,
266
  max_new_tokens=MAX_TOOL_CALL_TOKENS,
267
  temperature=0.0,
268
+ inference_mode=self._inference_mode,
269
  )
270
+
271
+ def ensure_loaded(self) -> None:
272
+ """Public lazy-load trigger so a borrower (the atlas chat) can share the model."""
273
+ self._ensure_loaded()
274
+
275
+ def base_model_context(self):
276
+ """Context manager that exposes the BASE weights of the loaded model.
277
+
278
+ With a LoRA adapter attached this is PeftModel.disable_adapter(); without one
279
+ the model already is the base, so a nullcontext suffices. Callers must hold
280
+ generation_lock() around the entered context (see _stream_minicpm_generation)."""
281
+ if self.adapter_id and self._model is not None and hasattr(self._model, "disable_adapter"):
282
+ return self._model.disable_adapter()
283
+ return nullcontext()
 
 
 
 
 
 
 
 
 
 
284
 
285
 
286
  def _device_available(device: str, torch: Any) -> bool:
 
367
  return inputs
368
 
369
 
370
+ def _minicpm_chat_inputs_with_tools(
371
+ tokenizer: Any,
372
+ messages: list[dict[str, Any]],
373
+ *,
374
+ tools: list[dict[str, Any]],
375
+ enable_thinking: bool,
376
+ device: Any,
377
+ ) -> Any:
378
+ """Chat inputs with the native tools= injection (atlas chat pass 1).
379
+
380
+ Kept separate from _minicpm_chat_inputs so the advisor's exact template call —
381
+ asserted verbatim in tests — stays untouched."""
382
+ prompt_text = tokenizer.apply_chat_template(
383
+ messages,
384
+ tools=tools,
385
+ tokenize=False,
386
+ add_generation_prompt=True,
387
+ enable_thinking=enable_thinking,
388
+ )
389
+ inputs = tokenizer([prompt_text], return_tensors="pt").to(device)
390
+ _strip_unused_generation_inputs(inputs)
391
+ return inputs
392
+
393
+
394
  def _minicpm_generation_kwargs(
395
  inputs: dict[str, Any],
396
  *,
 
412
  return generation_kwargs
413
 
414
 
415
+ def _stream_minicpm_generation(
416
+ model: Any,
417
+ tokenizer: Any,
418
+ inputs: dict[str, Any],
419
+ *,
420
+ max_new_tokens: int,
421
+ temperature: float = 0.0,
422
+ inference_mode: Any | None = None,
423
+ model_context: Any | None = None,
424
+ ) -> Iterator[tuple[int, str]]:
425
+ """Stream one MiniCPM generation as (token_count, text_piece) tuples.
426
+
427
+ Shared by the advisor tool-call pass and both atlas-chat passes. generate() runs in
428
+ a daemon thread feeding a TextIteratorStreamer; the process-wide generation lock is
429
+ held from before the worker starts until after it joins, so an adapter toggle
430
+ (``model_context`` — e.g. PeftModel.disable_adapter()) can never interleave with a
431
+ concurrent adapter-on generation. The ``finally`` also covers a consumer that
432
+ abandons the generator mid-stream."""
433
+ from transformers import TextIteratorStreamer
434
+
435
+ streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
436
+ generation_kwargs = _minicpm_generation_kwargs(
437
+ inputs,
438
+ max_new_tokens=max_new_tokens,
439
+ temperature=temperature,
440
+ streamer=streamer,
441
+ )
442
+ errors: list[BaseException] = []
443
+
444
+ def _run() -> None:
445
+ context = inference_mode() if inference_mode is not None else nullcontext()
446
+ weights = model_context() if model_context is not None else nullcontext()
447
+ try:
448
+ with weights, context:
449
+ model.generate(**generation_kwargs)
450
+ except BaseException as error: # surfaced after the streamer drains
451
+ errors.append(error)
452
+ # generate() never reached its end sentinel, so wake the consumer instead of
453
+ # letting it block forever, then re-raise from the main thread below.
454
+ streamer.end()
455
+
456
+ worker = threading.Thread(target=_run, daemon=True)
457
+ with _GENERATION_LOCK:
458
+ worker.start()
459
+ try:
460
+ tokens = 0
461
+ for piece in streamer:
462
+ if not piece:
463
+ continue
464
+ tokens += 1
465
+ yield tokens, piece
466
+ finally:
467
+ worker.join()
468
+ if errors:
469
+ raise errors[0]
470
+
471
+
472
+ class ChatRunner(Protocol):
473
+ """Streams atlas-chat generations over the (shared) base model."""
474
+
475
+ backend: str
476
+ model_id: str
477
+
478
+ supports_thinking: bool
479
+
480
+ def stream(
481
+ self,
482
+ messages: list[dict[str, Any]],
483
+ *,
484
+ tools: list[dict[str, Any]] | None = None,
485
+ max_new_tokens: int,
486
+ enable_thinking: bool = False,
487
+ ) -> Iterator[tuple[int, str]]:
488
+ ...
489
+
490
+
491
+ class MiniCPMChatRunner:
492
+ """Atlas-chat generations on the advisor's MiniCPM instance with the LoRA disabled.
493
+
494
+ Borrows the advisor planner's model and tokenizer (never loads its own copy) and
495
+ runs every generation under base_model_context() + the shared generation lock, so
496
+ the chat speaks with the BASE MiniCPM5-1B voice while the advisor keeps its adapter."""
497
+
498
+ backend = "minicpm-transformers"
499
+ # With enable_thinking the template ends the prompt with "<think>\n", so the
500
+ # stream is reasoning text up to "</think>" followed by the actual content.
501
+ supports_thinking = True
502
+
503
+ def __init__(self, planner: MiniCPMTransformersPlanner) -> None:
504
+ self._planner = planner
505
+
506
+ @property
507
+ def model_id(self) -> str:
508
+ return self._planner.model_id
509
+
510
+ def stream(
511
+ self,
512
+ messages: list[dict[str, Any]],
513
+ *,
514
+ tools: list[dict[str, Any]] | None = None,
515
+ max_new_tokens: int,
516
+ enable_thinking: bool = False,
517
+ ) -> Iterator[tuple[int, str]]:
518
+ planner = self._planner
519
+ planner.ensure_loaded()
520
+ assert planner._model is not None and planner._tokenizer is not None
521
+ device = next(planner._model.parameters()).device
522
+ if tools:
523
+ inputs = _minicpm_chat_inputs_with_tools(
524
+ planner._tokenizer,
525
+ messages,
526
+ tools=tools,
527
+ enable_thinking=enable_thinking,
528
+ device=device,
529
+ )
530
+ else:
531
+ inputs = _minicpm_chat_inputs(
532
+ planner._tokenizer,
533
+ messages,
534
+ enable_thinking=enable_thinking,
535
+ device=device,
536
+ )
537
+ yield from _stream_minicpm_generation(
538
+ planner._model,
539
+ planner._tokenizer,
540
+ inputs,
541
+ max_new_tokens=max_new_tokens,
542
+ temperature=0.0,
543
+ inference_mode=planner._inference_mode,
544
+ model_context=planner.base_model_context,
545
+ )
546
+
547
+
548
+ class RuleBasedChatRunner:
549
+ """Deterministic ChatRunner for the rules backend (tests, weight-free UI work).
550
+
551
+ Pass 1 (tools given) emits a native-format call chosen by the keyword intent
552
+ router; pass 2 emits a fixed grounded sentence — the UI's verified cards carry
553
+ the actual data either way."""
554
+
555
+ backend = "rules"
556
+ model_id = "deterministic-chat-router"
557
+ supports_thinking = False
558
+
559
+ def stream(
560
+ self,
561
+ messages: list[dict[str, Any]],
562
+ *,
563
+ tools: list[dict[str, Any]] | None = None,
564
+ max_new_tokens: int,
565
+ enable_thinking: bool = False,
566
+ ) -> Iterator[tuple[int, str]]:
567
+ from xml.sax.saxutils import escape
568
+
569
+ from hackathon_advisor.dashboard_chat_contracts import heuristic_chat_call
570
+
571
+ if tools:
572
+ message = _last_user_content(messages)
573
+ call = heuristic_chat_call(message)
574
+ params = "".join(
575
+ f'<param name="{name}">{escape(str(value))}</param>'
576
+ for name, value in call.arguments.items()
577
+ )
578
+ yield 1, f'<function name="{call.name}">{params}</function>'
579
+ return
580
+ yield 1, "Here is what the atlas snapshot shows; the cards below are the verified data."
581
+
582
+
583
+ def _last_user_content(messages: list[dict[str, Any]]) -> str:
584
+ for message in reversed(messages):
585
+ if message.get("role") == "user":
586
+ return str(message.get("content") or "")
587
+ return ""
588
+
589
+
590
+ def create_chat_runner(planner: ToolPlanner) -> ChatRunner:
591
+ """Build the atlas ChatRunner for an advisor planner; never loads a second model."""
592
+ if isinstance(planner, MiniCPMTransformersPlanner):
593
+ return MiniCPMChatRunner(planner)
594
+ return RuleBasedChatRunner()
595
+
596
+
597
  def create_tool_planner(device: str = "auto") -> ToolPlanner:
598
  backend = os.environ.get("ADVISOR_MODEL_BACKEND", "").strip().lower() or DEFAULT_BACKEND
599
  if backend == "rules":
static/app.js CHANGED
@@ -477,6 +477,7 @@ function renderDashboard(data) {
477
  renderAtlasDetail(currentAtlasPoint(data));
478
  renderAtlasReport(data);
479
  renderAtlasSearch();
 
480
  if (atlasStatusEl) atlasStatusEl.textContent = atlasSearchQuery ? atlasSearchStatusCopy() : atlasProvenanceCopy(data);
481
  }
482
 
@@ -2130,3 +2131,626 @@ function shortDate(value) {
2130
  if (!value) return "unknown";
2131
  return String(value).replace("T", " ").replace(/\+00:00$/, "Z").slice(0, 16);
2132
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  renderAtlasDetail(currentAtlasPoint(data));
478
  renderAtlasReport(data);
479
  renderAtlasSearch();
480
+ renderChatActionChip(); // keep the chat's applied-filter chip in sync with manual clicks
481
  if (atlasStatusEl) atlasStatusEl.textContent = atlasSearchQuery ? atlasSearchStatusCopy() : atlasProvenanceCopy(data);
482
  }
483
 
 
2131
  if (!value) return "unknown";
2132
  return String(value).replace("T", " ").replace(/\+00:00$/, "Z").slice(0, 16);
2133
  }
2134
+
2135
+ /* ---------------------------------------------------------------------------
2136
+ * Atlas chat drawer — conversational access to the live dashboard.
2137
+ *
2138
+ * Streams POST /api/dashboard/chat (NDJSON). Verified tool results render as
2139
+ * cards and drive the map through map_action BEFORE the model prose arrives;
2140
+ * the prose is labeled "Model summary" and the done.response is authoritative.
2141
+ * ------------------------------------------------------------------------- */
2142
+
2143
+ const openAtlasChatButton = document.querySelector("#open-atlas-chat");
2144
+ const atlasChatEl = document.querySelector("#atlas-chat");
2145
+ const atlasChatScrimEl = document.querySelector("#atlas-chat-scrim");
2146
+ const atlasChatLogEl = document.querySelector("#atlas-chat-log");
2147
+ const atlasChatFormEl = document.querySelector("#atlas-chat-form");
2148
+ const atlasChatInputEl = document.querySelector("#atlas-chat-input");
2149
+ const atlasChatSendEl = document.querySelector("#atlas-chat-send");
2150
+ const atlasChatCloseEl = document.querySelector("#atlas-chat-close");
2151
+ const atlasChatClearEl = document.querySelector("#atlas-chat-clear");
2152
+ const atlasChatSuggestionsEl = document.querySelector("#atlas-chat-suggestions");
2153
+ const atlasChatActionEl = document.querySelector("#atlas-chat-action");
2154
+
2155
+ const ATLAS_CHAT_STORAGE_KEY = "hackathon-advisor-atlas-chat-v1";
2156
+ const ATLAS_CHAT_MAX_MESSAGES = 30;
2157
+ const ATLAS_CHAT_SUGGESTIONS = [
2158
+ "What is everyone building?",
2159
+ "Who completed the most quests?",
2160
+ "What clusters exist?",
2161
+ "What changed recently?",
2162
+ ];
2163
+
2164
+ let atlasChatMessages = loadAtlasChatMessages();
2165
+ let atlasChatBusy = false;
2166
+ let atlasChatLastFocus = null;
2167
+ let chatMapActionApplied = false;
2168
+
2169
+ openAtlasChatButton?.addEventListener("click", () => openAtlasChat());
2170
+ atlasChatCloseEl?.addEventListener("click", () => closeAtlasChat());
2171
+ atlasChatScrimEl?.addEventListener("click", () => closeAtlasChat());
2172
+ atlasChatClearEl?.addEventListener("click", () => {
2173
+ if (atlasChatBusy) return; // an in-flight stream still mutates the live message
2174
+ atlasChatMessages = [];
2175
+ persistAtlasChatMessages();
2176
+ renderAtlasChatLog();
2177
+ });
2178
+ atlasChatFormEl?.addEventListener("submit", async (event) => {
2179
+ event.preventDefault();
2180
+ const message = String(atlasChatInputEl?.value || "").trim();
2181
+ if (!message || atlasChatBusy) return;
2182
+ atlasChatInputEl.value = "";
2183
+ await runAtlasChatTurn(message);
2184
+ });
2185
+ // Non-modal dialog (aria-modal=false): no Tab trap — the map stays reachable.
2186
+ atlasChatEl?.addEventListener("keydown", (event) => {
2187
+ if (event.key === "Escape") {
2188
+ event.stopPropagation();
2189
+ closeAtlasChat();
2190
+ }
2191
+ });
2192
+
2193
+ function openAtlasChat() {
2194
+ if (!atlasChatEl) return;
2195
+ atlasChatLastFocus = document.activeElement;
2196
+ atlasChatEl.hidden = false;
2197
+ if (atlasChatScrimEl) atlasChatScrimEl.hidden = false;
2198
+ window.requestAnimationFrame(() => atlasChatEl.classList.add("open"));
2199
+ renderAtlasChatLog();
2200
+ window.setTimeout(() => atlasChatInputEl?.focus(), 60);
2201
+ }
2202
+
2203
+ function closeAtlasChat() {
2204
+ if (!atlasChatEl || atlasChatEl.hidden) return;
2205
+ atlasChatEl.classList.remove("open");
2206
+ if (atlasChatScrimEl) atlasChatScrimEl.hidden = true;
2207
+ window.setTimeout(() => {
2208
+ atlasChatEl.hidden = true;
2209
+ }, 290);
2210
+ if (atlasChatLastFocus?.focus) atlasChatLastFocus.focus();
2211
+ }
2212
+
2213
+ function loadAtlasChatMessages() {
2214
+ try {
2215
+ const stored = JSON.parse(window.localStorage.getItem(ATLAS_CHAT_STORAGE_KEY) || "[]");
2216
+ return Array.isArray(stored) ? stored.slice(-ATLAS_CHAT_MAX_MESSAGES) : [];
2217
+ } catch {
2218
+ return [];
2219
+ }
2220
+ }
2221
+
2222
+ function persistAtlasChatMessages() {
2223
+ try {
2224
+ window.localStorage.setItem(
2225
+ ATLAS_CHAT_STORAGE_KEY,
2226
+ JSON.stringify(atlasChatMessages.slice(-ATLAS_CHAT_MAX_MESSAGES)),
2227
+ );
2228
+ } catch {
2229
+ /* storage may be unavailable; chat still works in-memory */
2230
+ }
2231
+ }
2232
+
2233
+ function atlasChatServerHistory() {
2234
+ return atlasChatMessages
2235
+ .filter((message) => message.text)
2236
+ .map((message) => ({
2237
+ role: message.role === "user" ? "user" : "assistant",
2238
+ content: message.text,
2239
+ }));
2240
+ }
2241
+
2242
+ async function runAtlasChatTurn(message) {
2243
+ if (atlasChatBusy) return;
2244
+ atlasChatBusy = true;
2245
+ if (atlasChatSendEl) atlasChatSendEl.disabled = true;
2246
+ const historyJson = JSON.stringify(atlasChatServerHistory());
2247
+ atlasChatMessages.push({ role: "user", text: message });
2248
+ const guide = {
2249
+ role: "guide",
2250
+ text: "",
2251
+ thinking: "",
2252
+ tool: "",
2253
+ data: null,
2254
+ mapAction: null,
2255
+ skipped: "",
2256
+ };
2257
+ atlasChatMessages.push(guide);
2258
+ renderAtlasChatLog();
2259
+ const live = atlasChatLogEl?.lastElementChild;
2260
+ const typingEl = live?.querySelector(".atlas-chat-typing");
2261
+ const proseEl = live?.querySelector(".atlas-chat-prose");
2262
+ const thinkEl = live?.querySelector(".atlas-chat-think");
2263
+ const thinkTextEl = live?.querySelector(".atlas-chat-think-text");
2264
+
2265
+ try {
2266
+ const response = await fetch("/api/dashboard/chat", {
2267
+ method: "POST",
2268
+ headers: { "Content-Type": "application/json" },
2269
+ body: JSON.stringify({ message, history_json: historyJson }),
2270
+ });
2271
+ if (!response.ok) throw new Error(`atlas chat failed with ${response.status}`);
2272
+ if (!response.body) throw new Error("atlas chat stream was empty");
2273
+
2274
+ for await (const raw of readNdjson(response.body)) {
2275
+ const event = JSON.parse(raw);
2276
+ handleAtlasChatEvent(event, guide, { live, typingEl, proseEl, thinkEl, thinkTextEl });
2277
+ }
2278
+ } catch (error) {
2279
+ console.error("Atlas chat turn failed.", error);
2280
+ guide.text = guide.text || `The atlas guide could not answer: ${error.message}`;
2281
+ guide.skipped = guide.skipped || "error";
2282
+ } finally {
2283
+ atlasChatBusy = false;
2284
+ if (atlasChatSendEl) atlasChatSendEl.disabled = false;
2285
+ persistAtlasChatMessages();
2286
+ renderAtlasChatLog();
2287
+ atlasChatInputEl?.focus();
2288
+ }
2289
+ }
2290
+
2291
+ function handleAtlasChatEvent(event, guide, nodes) {
2292
+ switch (event.type) {
2293
+ case "stage":
2294
+ if (nodes.typingEl) nodes.typingEl.textContent = event.label || "Thinking.";
2295
+ break;
2296
+ case "thinking":
2297
+ guide.thinking += event.text || "";
2298
+ if (nodes.thinkEl) {
2299
+ nodes.thinkEl.hidden = false;
2300
+ nodes.thinkEl.open = true;
2301
+ }
2302
+ if (nodes.thinkTextEl) nodes.thinkTextEl.textContent = guide.thinking;
2303
+ if (nodes.typingEl) nodes.typingEl.textContent = "Thinking.";
2304
+ scrollAtlasChatLog();
2305
+ break;
2306
+ case "tool_call":
2307
+ guide.tool = event.name || "";
2308
+ if (nodes.typingEl && event.name) {
2309
+ nodes.typingEl.textContent = `Checking ${event.name.replaceAll("_", " ")}.`;
2310
+ }
2311
+ break;
2312
+ case "tool_result":
2313
+ guide.data = event.data || null;
2314
+ guide.tool = event.tool || guide.tool;
2315
+ guide.mapAction = event.map_action || null;
2316
+ if (guide.mapAction) applyChatMapAction(guide.mapAction);
2317
+ if (nodes.live) {
2318
+ const cards = nodes.live.querySelector(".atlas-chat-cards");
2319
+ if (cards) cards.replaceChildren(...atlasChatCards(guide.tool, guide.data));
2320
+ }
2321
+ break;
2322
+ case "token":
2323
+ guide.text += event.text || "";
2324
+ if (nodes.proseEl) {
2325
+ nodes.proseEl.hidden = false;
2326
+ nodes.proseEl.textContent = guide.text;
2327
+ }
2328
+ if (nodes.typingEl) nodes.typingEl.hidden = true;
2329
+ if (nodes.thinkEl) nodes.thinkEl.open = false; // fold the trace once the answer starts
2330
+ scrollAtlasChatLog();
2331
+ break;
2332
+ case "answer_skipped":
2333
+ guide.text = event.text || "";
2334
+ guide.skipped = event.reason || "skipped";
2335
+ break;
2336
+ case "fallback":
2337
+ guide.fallback = event.reason || "Running locally.";
2338
+ break;
2339
+ case "done":
2340
+ guide.text = event.response || guide.text;
2341
+ guide.tool = event.tool || guide.tool;
2342
+ if (guide.tool && event.data && Object.keys(event.data).length && !guide.data) {
2343
+ guide.data = event.data;
2344
+ }
2345
+ announceAtlasChat(guide.text);
2346
+ break;
2347
+ default:
2348
+ break;
2349
+ }
2350
+ }
2351
+
2352
+ function renderAtlasChatLog() {
2353
+ if (!atlasChatLogEl) return;
2354
+ atlasChatLogEl.replaceChildren();
2355
+ for (const message of atlasChatMessages) {
2356
+ atlasChatLogEl.append(
2357
+ message.role === "user" ? atlasChatUserNode(message) : atlasChatGuideNode(message),
2358
+ );
2359
+ }
2360
+ renderAtlasChatSuggestions();
2361
+ renderChatActionChip();
2362
+ scrollAtlasChatLog();
2363
+ }
2364
+
2365
+ function atlasChatUserNode(message) {
2366
+ const node = document.createElement("div");
2367
+ node.className = "atlas-chat-msg user";
2368
+ node.textContent = message.text;
2369
+ return node;
2370
+ }
2371
+
2372
+ function atlasChatGuideNode(message) {
2373
+ const node = document.createElement("div");
2374
+ node.className = "atlas-chat-msg guide";
2375
+
2376
+ const hasCards = Boolean(message.tool && message.data && Object.keys(message.data).length);
2377
+ if (hasCards) {
2378
+ const dataLabel = document.createElement("span");
2379
+ dataLabel.className = "atlas-chat-label";
2380
+ dataLabel.textContent = "Data";
2381
+ node.append(dataLabel);
2382
+ }
2383
+ const cards = document.createElement("div");
2384
+ cards.className = "atlas-chat-cards";
2385
+ cards.append(...atlasChatCards(message.tool, message.data));
2386
+ node.append(cards);
2387
+
2388
+ // The model's reasoning trace: streams open, folds once the answer starts.
2389
+ const think = document.createElement("details");
2390
+ think.className = "atlas-chat-think";
2391
+ const summary = document.createElement("summary");
2392
+ summary.textContent = "Thinking";
2393
+ const thinkText = document.createElement("div");
2394
+ thinkText.className = "atlas-chat-think-text";
2395
+ thinkText.textContent = message.thinking || "";
2396
+ think.append(summary, thinkText);
2397
+ think.hidden = !message.thinking;
2398
+ node.append(think);
2399
+
2400
+ const proseLabel = document.createElement("span");
2401
+ proseLabel.className = "atlas-chat-label";
2402
+ proseLabel.textContent = message.skipped ? "Atlas note" : "Model summary";
2403
+ const prose = document.createElement("p");
2404
+ prose.className = `atlas-chat-prose ${message.skipped ? "skipped" : ""}`;
2405
+ prose.textContent = message.text;
2406
+ prose.hidden = !message.text;
2407
+ proseLabel.hidden = !message.text;
2408
+
2409
+ if (!message.text && atlasChatBusy && message === atlasChatMessages.at(-1)) {
2410
+ const typing = document.createElement("span");
2411
+ typing.className = "atlas-chat-typing";
2412
+ typing.textContent = "Reading the atlas.";
2413
+ node.append(typing);
2414
+ }
2415
+ node.append(proseLabel, prose);
2416
+
2417
+ if (message.fallback) {
2418
+ const fallback = document.createElement("span");
2419
+ fallback.className = "atlas-chat-fallback";
2420
+ fallback.textContent = message.fallback;
2421
+ node.append(fallback);
2422
+ }
2423
+ if (message.skipped === "quests_not_analyzed") {
2424
+ node.append(atlasChatRefreshCard());
2425
+ }
2426
+ return node;
2427
+ }
2428
+
2429
+ function atlasChatRefreshCard() {
2430
+ const card = document.createElement("div");
2431
+ card.className = "atlas-chat-empty";
2432
+ const copy = document.createElement("span");
2433
+ copy.textContent = "Quest coverage appears after a map refresh classifies the field.";
2434
+ const button = document.createElement("button");
2435
+ button.type = "button";
2436
+ button.className = "btn btn-ghost";
2437
+ button.textContent = "Refresh map";
2438
+ button.addEventListener("click", () => startDashboardRefresh());
2439
+ card.append(copy, button);
2440
+ return card;
2441
+ }
2442
+
2443
+ function atlasChatCards(tool, data) {
2444
+ if (!data) return [];
2445
+ switch (tool) {
2446
+ case "atlas_overview":
2447
+ return overviewCards(data);
2448
+ case "list_clusters":
2449
+ return clusterListCards(data);
2450
+ case "show_cluster":
2451
+ return clusterDetailCards(data);
2452
+ case "show_project":
2453
+ return projectReadmeCards(data);
2454
+ case "list_quests":
2455
+ return questListCards(data);
2456
+ case "show_quest":
2457
+ return questDetailCards(data);
2458
+ case "top_projects_by_quests":
2459
+ return leaderboardCards(data);
2460
+ case "search_projects":
2461
+ return searchCards(data);
2462
+ case "recent_activity":
2463
+ return recentCards(data);
2464
+ default:
2465
+ return [];
2466
+ }
2467
+ }
2468
+
2469
+ function overviewCards(data) {
2470
+ const statline = document.createElement("div");
2471
+ statline.className = "atlas-chat-statline";
2472
+ statline.innerHTML = `
2473
+ <span><strong>${Number(data.project_count || 0)}</strong> projects</span>
2474
+ <span><strong>${Number(data.cluster_count || 0)}</strong> clusters</span>
2475
+ <span><strong>${escapeHtml(String(data.quest_status || ""))}</strong> quests</span>
2476
+ `;
2477
+ const chips = document.createElement("div");
2478
+ chips.className = "atlas-chat-chips";
2479
+ for (const cluster of data.top_clusters || []) {
2480
+ chips.append(
2481
+ chatChipButton(`${cluster.label} · ${cluster.project_count}`, () =>
2482
+ applyChatMapAction({ type: "filter_cluster", label: cluster.label }),
2483
+ ),
2484
+ );
2485
+ }
2486
+ const nodes = [statline, chips];
2487
+ for (const project of data.most_liked || []) {
2488
+ nodes.push(projectCard(project, `${project.likes} likes`));
2489
+ }
2490
+ return nodes;
2491
+ }
2492
+
2493
+ function clusterListCards(data) {
2494
+ const chips = document.createElement("div");
2495
+ chips.className = "atlas-chat-chips";
2496
+ for (const cluster of data.clusters || []) {
2497
+ chips.append(
2498
+ chatChipButton(`${cluster.label} · ${cluster.project_count}`, () =>
2499
+ applyChatMapAction({ type: "filter_cluster", label: cluster.label }),
2500
+ ),
2501
+ );
2502
+ }
2503
+ return [chips];
2504
+ }
2505
+
2506
+ function clusterDetailCards(data) {
2507
+ const nodes = [];
2508
+ if (data.keywords?.length) {
2509
+ const chips = document.createElement("div");
2510
+ chips.className = "atlas-chat-chips";
2511
+ for (const keyword of data.keywords) {
2512
+ const chip = document.createElement("span");
2513
+ chip.textContent = keyword;
2514
+ chips.append(chip);
2515
+ }
2516
+ nodes.push(chips);
2517
+ }
2518
+ for (const project of data.examples || []) {
2519
+ nodes.push(projectCard(project, `${project.likes} likes`));
2520
+ }
2521
+ return nodes;
2522
+ }
2523
+
2524
+ function projectReadmeCards(data) {
2525
+ const nodes = [];
2526
+ const metaParts = [
2527
+ data.likes ? `${data.likes} likes` : "",
2528
+ data.sdk || "",
2529
+ data.cluster_label || "",
2530
+ ].filter(Boolean);
2531
+ nodes.push(projectCard(data, [data.summary, metaParts.join(" · ")].filter(Boolean).join(" — ")));
2532
+
2533
+ const chips = document.createElement("div");
2534
+ chips.className = "atlas-chat-chips";
2535
+ for (const label of [...(data.quests || []), ...(data.tags || [])].slice(0, 8)) {
2536
+ const chip = document.createElement("span");
2537
+ chip.textContent = label;
2538
+ chips.append(chip);
2539
+ }
2540
+ if (chips.childElementCount) nodes.push(chips);
2541
+
2542
+ if (data.readme_excerpt) {
2543
+ const readme = document.createElement("div");
2544
+ readme.className = "atlas-chat-card";
2545
+ readme.innerHTML = `<span class="atlas-chat-label">README</span>`;
2546
+ const body = document.createElement("p");
2547
+ body.className = "atlas-chat-excerpt";
2548
+ body.textContent = data.readme_excerpt;
2549
+ readme.append(body);
2550
+ nodes.push(readme);
2551
+ }
2552
+ if (data.app_excerpt) {
2553
+ const app = document.createElement("div");
2554
+ app.className = "atlas-chat-card";
2555
+ app.innerHTML = `<span class="atlas-chat-label">${escapeHtml(data.app_file || "app file")}</span>`;
2556
+ const code = document.createElement("pre");
2557
+ code.className = "atlas-chat-code";
2558
+ code.textContent = data.app_excerpt;
2559
+ app.append(code);
2560
+ nodes.push(app);
2561
+ }
2562
+ return nodes;
2563
+ }
2564
+
2565
+ function questListCards(data) {
2566
+ const quests = (data.quests || []).filter((quest) => Number(quest.project_count || 0) > 0);
2567
+ const max = Math.max(1, ...quests.map((quest) => Number(quest.project_count || 0)));
2568
+ return quests.slice(0, 8).map((quest) => barCard(quest.label, quest.project_count, max));
2569
+ }
2570
+
2571
+ function questDetailCards(data) {
2572
+ const nodes = [];
2573
+ const card = document.createElement("div");
2574
+ card.className = "atlas-chat-card";
2575
+ card.innerHTML = `
2576
+ <strong>${escapeHtml(data.label || data.id || "")}</strong>
2577
+ <span class="atlas-chat-card-meta">${escapeHtml(data.description || "")}</span>
2578
+ <span class="atlas-chat-card-meta">${Number(data.project_count || 0)} projects</span>
2579
+ `;
2580
+ nodes.push(card);
2581
+ for (const project of data.examples || []) {
2582
+ nodes.push(projectCard(project, ""));
2583
+ }
2584
+ return nodes;
2585
+ }
2586
+
2587
+ function leaderboardCards(data) {
2588
+ const rows = data.rows || [];
2589
+ const max = Math.max(1, ...rows.map((row) => Number(row.quest_count || 0)));
2590
+ return rows.map((row) =>
2591
+ barCard(row.title, row.quest_count, max, row.url, `${row.quest_count} quests · ${row.likes} likes`),
2592
+ );
2593
+ }
2594
+
2595
+ function searchCards(data) {
2596
+ return (data.results || []).map((result) =>
2597
+ projectCard(result, result.summary || "Matched project"),
2598
+ );
2599
+ }
2600
+
2601
+ function recentCards(data) {
2602
+ return (data.projects || []).map((project) =>
2603
+ projectCard(project, `${shortDate(project.last_modified)}${project.cluster_label ? ` · ${project.cluster_label}` : ""}`),
2604
+ );
2605
+ }
2606
+
2607
+ function projectCard(project, meta) {
2608
+ const card = document.createElement("div");
2609
+ card.className = "atlas-chat-card";
2610
+ const title = escapeHtml(project.title || project.id || "Untitled project");
2611
+ const body = `
2612
+ <strong>${title}</strong>
2613
+ ${meta ? `<span class="atlas-chat-card-meta">${escapeHtml(meta)}</span>` : ""}
2614
+ `;
2615
+ const url = safeChatUrl(project.url);
2616
+ card.innerHTML = url
2617
+ ? `<a href="${escapeHtml(url)}" target="_blank" rel="noreferrer noopener">${body}</a>`
2618
+ : body;
2619
+ return card;
2620
+ }
2621
+
2622
+ function barCard(label, count, max, url = "", meta = "") {
2623
+ const card = document.createElement("div");
2624
+ card.className = "atlas-chat-card";
2625
+ const width = Math.max(8, Math.min(100, (Number(count || 0) / max) * 100)).toFixed(0);
2626
+ const title = escapeHtml(label || "");
2627
+ const safeUrl = safeChatUrl(url);
2628
+ const heading = safeUrl
2629
+ ? `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noreferrer noopener"><strong>${title}</strong></a>`
2630
+ : `<strong>${title}</strong>`;
2631
+ card.innerHTML = `
2632
+ ${heading}
2633
+ <span class="atlas-search-score" aria-hidden="true"><i style="width: ${width}%"></i></span>
2634
+ <span class="atlas-chat-card-meta">${escapeHtml(meta || `${count} projects`)}</span>
2635
+ `;
2636
+ return card;
2637
+ }
2638
+
2639
+ function safeChatUrl(url) {
2640
+ // Card hrefs come from crawled metadata; only plain web links are clickable.
2641
+ const value = String(url || "");
2642
+ return /^https?:\/\//i.test(value) ? value : "";
2643
+ }
2644
+
2645
+ function chatChipButton(label, onClick) {
2646
+ const button = document.createElement("button");
2647
+ button.type = "button";
2648
+ button.textContent = label;
2649
+ button.addEventListener("click", onClick);
2650
+ return button;
2651
+ }
2652
+
2653
+ function renderAtlasChatSuggestions() {
2654
+ if (!atlasChatSuggestionsEl) return;
2655
+ atlasChatSuggestionsEl.replaceChildren();
2656
+ if (atlasChatMessages.length) return;
2657
+ for (const suggestion of ATLAS_CHAT_SUGGESTIONS) {
2658
+ const button = document.createElement("button");
2659
+ button.type = "button";
2660
+ button.textContent = suggestion;
2661
+ button.addEventListener("click", () => runAtlasChatTurn(suggestion));
2662
+ atlasChatSuggestionsEl.append(button);
2663
+ }
2664
+ }
2665
+
2666
+ /* --- chat -> map cooperation ---------------------------------------------- */
2667
+
2668
+ function applyChatMapAction(action) {
2669
+ if (!action || !dashboardData) return;
2670
+ switch (action.type) {
2671
+ case "clear_filters":
2672
+ selectedClusterId = "";
2673
+ selectedQuestId = "";
2674
+ atlasSearchResultIds = new Set();
2675
+ break;
2676
+ case "filter_cluster": {
2677
+ // Cluster ids are unstable across refreshes; resolve the label live.
2678
+ const wanted = String(action.label || "").toLowerCase();
2679
+ const cluster = (dashboardData.clusters || []).find(
2680
+ (entry) => String(entry.label || "").toLowerCase() === wanted,
2681
+ );
2682
+ if (cluster) selectedClusterId = cluster.id;
2683
+ break;
2684
+ }
2685
+ case "filter_quest":
2686
+ selectedQuestId = String(action.quest || "");
2687
+ break;
2688
+ case "highlight_projects": {
2689
+ const ids = (action.ids || []).filter(Boolean);
2690
+ atlasSearchResultIds = new Set(ids);
2691
+ if (ids.length) selectedProjectId = ids[0];
2692
+ break;
2693
+ }
2694
+ default:
2695
+ return;
2696
+ }
2697
+ chatMapActionApplied = true;
2698
+ renderDashboard(dashboardData);
2699
+ }
2700
+
2701
+ function clearChatMapAction() {
2702
+ // "Clear" rather than snapshot-rollback: a rollback would silently revert any
2703
+ // manual cluster/quest clicks the user made after the chat action.
2704
+ if (!dashboardData) return;
2705
+ selectedClusterId = "";
2706
+ selectedQuestId = "";
2707
+ atlasSearchResultIds = new Set();
2708
+ chatMapActionApplied = false;
2709
+ renderDashboard(dashboardData);
2710
+ }
2711
+
2712
+ function renderChatActionChip() {
2713
+ if (!atlasChatActionEl) return;
2714
+ // Describe the LIVE filter state so a manual sidebar click can never desync the chip.
2715
+ const description = liveMapStateCopy();
2716
+ if (!chatMapActionApplied || !description) {
2717
+ atlasChatActionEl.hidden = true;
2718
+ atlasChatActionEl.replaceChildren();
2719
+ return;
2720
+ }
2721
+ atlasChatActionEl.hidden = false;
2722
+ atlasChatActionEl.replaceChildren();
2723
+ const copy = document.createElement("span");
2724
+ copy.innerHTML = `Map: <strong>${escapeHtml(description)}</strong>`;
2725
+ const clear = document.createElement("button");
2726
+ clear.type = "button";
2727
+ clear.className = "atlas-chat-undo";
2728
+ clear.textContent = "Clear";
2729
+ clear.addEventListener("click", clearChatMapAction);
2730
+ atlasChatActionEl.append(copy, clear);
2731
+ }
2732
+
2733
+ function liveMapStateCopy() {
2734
+ if (!dashboardData) return "";
2735
+ const parts = [];
2736
+ if (selectedClusterId) {
2737
+ const cluster = (dashboardData.clusters || []).find((entry) => entry.id === selectedClusterId);
2738
+ if (cluster) parts.push(`cluster ${cluster.label}`);
2739
+ }
2740
+ if (selectedQuestId) parts.push(`quest ${selectedQuestId}`);
2741
+ if (atlasSearchResultIds.size && !atlasSearchQuery) {
2742
+ parts.push(`${atlasSearchResultIds.size} highlighted`);
2743
+ }
2744
+ return parts.join(" · ");
2745
+ }
2746
+
2747
+ function scrollAtlasChatLog() {
2748
+ if (atlasChatLogEl) atlasChatLogEl.scrollTop = atlasChatLogEl.scrollHeight;
2749
+ }
2750
+
2751
+ function announceAtlasChat(text) {
2752
+ // One announcement per finished answer; live-updating the whole log would make
2753
+ // screen readers re-read every streamed token.
2754
+ const status = document.querySelector("#atlas-chat-status");
2755
+ if (status) status.textContent = text || "";
2756
+ }
static/index.html CHANGED
@@ -40,6 +40,10 @@
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>
@@ -80,6 +84,17 @@
80
  <svg class="icon"><use href="#icon-reset"></use></svg>
81
  Refresh map
82
  </button>
 
 
 
 
 
 
 
 
 
 
 
83
  <button id="open-advisor" class="btn btn-ink" type="button" title="Open the idea advisor">
84
  <svg class="icon"><use href="#icon-quill"></use></svg>
85
  Open advisor
@@ -121,6 +136,46 @@
121
  </section>
122
  </aside>
123
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  </section>
125
  </main>
126
 
 
40
  <symbol id="icon-x" viewBox="0 0 24 24">
41
  <path d="M6 6l12 12M18 6L6 18" />
42
  </symbol>
43
+ <symbol id="icon-chat" viewBox="0 0 24 24">
44
+ <path d="M4 5h16v11H9l-5 4z" />
45
+ <path d="M8 9h8M8 12h5" />
46
+ </symbol>
47
  <symbol id="icon-check" viewBox="0 0 24 24">
48
  <path d="M5 12l4 4 10-11" />
49
  </symbol>
 
84
  <svg class="icon"><use href="#icon-reset"></use></svg>
85
  Refresh map
86
  </button>
87
+ <button
88
+ id="open-atlas-chat"
89
+ class="btn btn-ghost"
90
+ type="button"
91
+ title="Chat with the atlas guide"
92
+ aria-haspopup="dialog"
93
+ aria-controls="atlas-chat"
94
+ >
95
+ <svg class="icon"><use href="#icon-chat"></use></svg>
96
+ Ask the atlas
97
+ </button>
98
  <button id="open-advisor" class="btn btn-ink" type="button" title="Open the idea advisor">
99
  <svg class="icon"><use href="#icon-quill"></use></svg>
100
  Open advisor
 
136
  </section>
137
  </aside>
138
  </section>
139
+
140
+ <div id="atlas-chat-scrim" class="atlas-chat-scrim" hidden></div>
141
+ <aside
142
+ id="atlas-chat"
143
+ class="atlas-chat-drawer"
144
+ role="dialog"
145
+ aria-modal="false"
146
+ aria-label="Atlas guide chat"
147
+ hidden
148
+ >
149
+ <header class="atlas-chat-header">
150
+ <div class="atlas-chat-title">
151
+ <p class="atlas-kicker">Atlas guide</p>
152
+ <h2>Ask the field</h2>
153
+ </div>
154
+ <div class="atlas-chat-header-actions">
155
+ <button id="atlas-chat-clear" class="atlas-chat-iconbtn" type="button" title="Clear conversation">
156
+ <svg class="icon"><use href="#icon-reset"></use></svg>
157
+ </button>
158
+ <button id="atlas-chat-close" class="atlas-chat-iconbtn" type="button" title="Close chat" aria-label="Close chat">
159
+ <svg class="icon"><use href="#icon-x"></use></svg>
160
+ </button>
161
+ </div>
162
+ </header>
163
+ <div id="atlas-chat-action" class="atlas-chat-action" hidden></div>
164
+ <div id="atlas-chat-log" class="atlas-chat-log"></div>
165
+ <div id="atlas-chat-status" class="sr-only" role="status" aria-live="polite"></div>
166
+ <div id="atlas-chat-suggestions" class="atlas-chat-suggestions"></div>
167
+ <form id="atlas-chat-form" class="atlas-chat-composer">
168
+ <label class="sr-only" for="atlas-chat-input">Ask about the atlas</label>
169
+ <input
170
+ id="atlas-chat-input"
171
+ type="text"
172
+ autocomplete="off"
173
+ spellcheck="false"
174
+ placeholder="Ask what everyone is building..."
175
+ />
176
+ <button id="atlas-chat-send" class="btn btn-ink" type="submit">Ask</button>
177
+ </form>
178
+ </aside>
179
  </section>
180
  </main>
181
 
static/styles.css CHANGED
@@ -2073,3 +2073,409 @@ textarea:disabled {
2073
  background: rgba(154, 43, 34, 0.1);
2074
  color: var(--oxblood);
2075
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2073
  background: rgba(154, 43, 34, 0.1);
2074
  color: var(--oxblood);
2075
  }
2076
+
2077
+ /* Atlas chat drawer (the "Ask the atlas" guide) */
2078
+ .atlas-chat-scrim {
2079
+ position: absolute;
2080
+ inset: 0;
2081
+ z-index: 5;
2082
+ display: none; /* desktop chat is non-modal: the map stays interactive */
2083
+ background: rgba(39, 26, 14, 0.34);
2084
+ }
2085
+
2086
+ .atlas-chat-drawer {
2087
+ position: absolute;
2088
+ top: 0;
2089
+ right: 0;
2090
+ bottom: 0;
2091
+ z-index: 6;
2092
+ display: flex;
2093
+ flex-direction: column;
2094
+ width: min(400px, 92%);
2095
+ color: var(--ink);
2096
+ background:
2097
+ linear-gradient(180deg, var(--paper-3), var(--paper) 22%, var(--paper) 82%, var(--paper-2)),
2098
+ var(--paper);
2099
+ border-left: 1px solid var(--edge);
2100
+ box-shadow: -26px 0 54px -18px rgba(0, 0, 0, 0.5);
2101
+ transform: translateX(103%);
2102
+ transition: transform 0.28s ease;
2103
+ }
2104
+
2105
+ .atlas-chat-drawer.open {
2106
+ transform: translateX(0);
2107
+ }
2108
+
2109
+ .atlas-chat-drawer[hidden] {
2110
+ display: none;
2111
+ }
2112
+
2113
+ .atlas-chat-header {
2114
+ display: flex;
2115
+ align-items: flex-start;
2116
+ justify-content: space-between;
2117
+ gap: 10px;
2118
+ padding: 18px 18px 12px;
2119
+ border-bottom: 1px solid var(--rule);
2120
+ }
2121
+
2122
+ .atlas-chat-title h2 {
2123
+ margin: 0;
2124
+ font-family: var(--serif);
2125
+ font-size: 1.18rem;
2126
+ line-height: 1.1;
2127
+ }
2128
+
2129
+ .atlas-chat-header-actions {
2130
+ display: flex;
2131
+ gap: 6px;
2132
+ }
2133
+
2134
+ .atlas-chat-iconbtn {
2135
+ display: grid;
2136
+ place-items: center;
2137
+ width: 30px;
2138
+ height: 30px;
2139
+ padding: 0;
2140
+ color: var(--ink-soft);
2141
+ background: rgba(255, 247, 224, 0.4);
2142
+ border: 1px solid var(--rule-soft);
2143
+ border-radius: 7px;
2144
+ cursor: pointer;
2145
+ }
2146
+
2147
+ .atlas-chat-iconbtn:hover {
2148
+ color: var(--oxblood);
2149
+ border-color: rgba(154, 43, 34, 0.4);
2150
+ }
2151
+
2152
+ .atlas-chat-iconbtn .icon {
2153
+ width: 14px;
2154
+ height: 14px;
2155
+ }
2156
+
2157
+ .atlas-chat-action {
2158
+ display: flex;
2159
+ align-items: center;
2160
+ gap: 8px;
2161
+ margin: 10px 16px 0;
2162
+ padding: 7px 10px;
2163
+ color: var(--ink-soft);
2164
+ background: rgba(176, 125, 18, 0.1);
2165
+ border: 1px solid rgba(176, 125, 18, 0.35);
2166
+ border-radius: 999px;
2167
+ font-family: var(--label);
2168
+ font-size: 0.68rem;
2169
+ font-weight: 760;
2170
+ }
2171
+
2172
+ .atlas-chat-action strong {
2173
+ color: var(--ink);
2174
+ }
2175
+
2176
+ .atlas-chat-action .atlas-chat-undo {
2177
+ margin-left: auto;
2178
+ padding: 2px 8px;
2179
+ color: var(--oxblood);
2180
+ background: none;
2181
+ border: 1px solid rgba(154, 43, 34, 0.35);
2182
+ border-radius: 999px;
2183
+ font: inherit;
2184
+ cursor: pointer;
2185
+ }
2186
+
2187
+ .atlas-chat-log {
2188
+ display: flex;
2189
+ flex: 1;
2190
+ flex-direction: column;
2191
+ gap: 12px;
2192
+ min-height: 0;
2193
+ padding: 14px 16px;
2194
+ overflow-y: auto;
2195
+ scrollbar-width: thin;
2196
+ }
2197
+
2198
+ .atlas-chat-msg {
2199
+ display: grid;
2200
+ gap: 7px;
2201
+ max-width: 94%;
2202
+ }
2203
+
2204
+ .atlas-chat-msg.user {
2205
+ justify-self: end;
2206
+ padding: 8px 12px;
2207
+ color: var(--paper-3);
2208
+ background: var(--ink);
2209
+ border-radius: 12px 12px 3px 12px;
2210
+ font-family: var(--serif);
2211
+ font-size: 0.86rem;
2212
+ line-height: 1.4;
2213
+ }
2214
+
2215
+ .atlas-chat-msg.guide {
2216
+ justify-self: start;
2217
+ width: 100%;
2218
+ }
2219
+
2220
+ .atlas-chat-label {
2221
+ color: var(--ink-faint);
2222
+ font-family: var(--label);
2223
+ font-size: 0.6rem;
2224
+ font-weight: 850;
2225
+ letter-spacing: 0.14em;
2226
+ text-transform: uppercase;
2227
+ }
2228
+
2229
+ .atlas-chat-prose {
2230
+ padding: 9px 12px;
2231
+ background: rgba(255, 247, 224, 0.5);
2232
+ border: 1px solid var(--rule-soft);
2233
+ border-left: 3px solid var(--gold);
2234
+ border-radius: 3px 12px 12px 12px;
2235
+ font-family: var(--serif);
2236
+ font-size: 0.86rem;
2237
+ line-height: 1.45;
2238
+ overflow-wrap: anywhere;
2239
+ }
2240
+
2241
+ .atlas-chat-prose.skipped {
2242
+ border-left-color: var(--leaf);
2243
+ }
2244
+
2245
+ .atlas-chat-typing {
2246
+ color: var(--ink-faint);
2247
+ font-family: var(--label);
2248
+ font-size: 0.7rem;
2249
+ font-weight: 760;
2250
+ animation: breathe 1.6s ease-in-out infinite;
2251
+ }
2252
+
2253
+ .atlas-chat-think {
2254
+ padding: 6px 10px;
2255
+ background: rgba(73, 49, 22, 0.05);
2256
+ border: 1px dashed var(--rule-soft);
2257
+ border-radius: 7px;
2258
+ }
2259
+
2260
+ .atlas-chat-think[hidden] {
2261
+ display: none;
2262
+ }
2263
+
2264
+ .atlas-chat-think summary {
2265
+ color: var(--ink-faint);
2266
+ cursor: pointer;
2267
+ font-family: var(--label);
2268
+ font-size: 0.62rem;
2269
+ font-weight: 850;
2270
+ letter-spacing: 0.14em;
2271
+ text-transform: uppercase;
2272
+ }
2273
+
2274
+ .atlas-chat-think-text {
2275
+ margin-top: 6px;
2276
+ max-height: 220px;
2277
+ overflow-y: auto;
2278
+ color: var(--ink-soft);
2279
+ font-family: var(--serif);
2280
+ font-size: 0.76rem;
2281
+ font-style: italic;
2282
+ line-height: 1.45;
2283
+ white-space: pre-wrap;
2284
+ overflow-wrap: anywhere;
2285
+ scrollbar-width: thin;
2286
+ }
2287
+
2288
+ .atlas-chat-cards {
2289
+ display: grid;
2290
+ gap: 7px;
2291
+ }
2292
+
2293
+ .atlas-chat-card {
2294
+ display: grid;
2295
+ gap: 5px;
2296
+ padding: 9px 11px;
2297
+ background: rgba(255, 247, 224, 0.38);
2298
+ border: 1px solid var(--rule-soft);
2299
+ border-left: 3px solid var(--leaf);
2300
+ border-radius: 7px;
2301
+ }
2302
+
2303
+ .atlas-chat-card strong {
2304
+ font-family: var(--serif);
2305
+ font-size: 0.84rem;
2306
+ line-height: 1.2;
2307
+ }
2308
+
2309
+ .atlas-chat-card a {
2310
+ color: inherit;
2311
+ text-decoration: none;
2312
+ }
2313
+
2314
+ .atlas-chat-card a:hover strong {
2315
+ color: var(--oxblood);
2316
+ }
2317
+
2318
+ .atlas-chat-card-meta {
2319
+ color: var(--ink-faint);
2320
+ font-family: var(--label);
2321
+ font-size: 0.66rem;
2322
+ font-weight: 760;
2323
+ line-height: 1.35;
2324
+ }
2325
+
2326
+ .atlas-chat-excerpt {
2327
+ margin: 0;
2328
+ max-height: 180px;
2329
+ overflow-y: auto;
2330
+ color: var(--ink-soft);
2331
+ font-family: var(--serif);
2332
+ font-size: 0.78rem;
2333
+ line-height: 1.45;
2334
+ overflow-wrap: anywhere;
2335
+ scrollbar-width: thin;
2336
+ }
2337
+
2338
+ .atlas-chat-code {
2339
+ margin: 0;
2340
+ max-height: 200px;
2341
+ overflow: auto;
2342
+ padding: 7px 9px;
2343
+ color: var(--ink-soft);
2344
+ background: rgba(73, 49, 22, 0.07);
2345
+ border-radius: 6px;
2346
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2347
+ font-size: 0.66rem;
2348
+ line-height: 1.45;
2349
+ white-space: pre-wrap;
2350
+ overflow-wrap: anywhere;
2351
+ scrollbar-width: thin;
2352
+ }
2353
+
2354
+ .atlas-chat-statline {
2355
+ display: flex;
2356
+ flex-wrap: wrap;
2357
+ gap: 12px;
2358
+ padding: 8px 11px;
2359
+ background: rgba(73, 49, 22, 0.06);
2360
+ border: 1px solid var(--rule-soft);
2361
+ border-radius: 7px;
2362
+ }
2363
+
2364
+ .atlas-chat-statline span {
2365
+ color: var(--ink-soft);
2366
+ font-family: var(--label);
2367
+ font-size: 0.68rem;
2368
+ font-weight: 800;
2369
+ }
2370
+
2371
+ .atlas-chat-statline strong {
2372
+ color: var(--ink);
2373
+ font-family: var(--serif);
2374
+ font-size: 0.9rem;
2375
+ }
2376
+
2377
+ .atlas-chat-chips {
2378
+ display: flex;
2379
+ flex-wrap: wrap;
2380
+ gap: 6px;
2381
+ }
2382
+
2383
+ .atlas-chat-chips button,
2384
+ .atlas-chat-chips span {
2385
+ padding: 4px 8px;
2386
+ color: var(--ink-soft);
2387
+ background: rgba(73, 49, 22, 0.08);
2388
+ border: 1px solid var(--rule-soft);
2389
+ border-radius: 999px;
2390
+ font-family: var(--label);
2391
+ font-size: 0.66rem;
2392
+ font-weight: 800;
2393
+ }
2394
+
2395
+ .atlas-chat-chips button {
2396
+ cursor: pointer;
2397
+ }
2398
+
2399
+ .atlas-chat-chips button:hover {
2400
+ color: var(--leaf);
2401
+ border-color: rgba(47, 107, 65, 0.4);
2402
+ }
2403
+
2404
+ .atlas-chat-empty {
2405
+ display: grid;
2406
+ gap: 8px;
2407
+ padding: 10px 12px;
2408
+ background: rgba(176, 125, 18, 0.08);
2409
+ border: 1px dashed rgba(176, 125, 18, 0.45);
2410
+ border-radius: 7px;
2411
+ color: var(--ink-soft);
2412
+ font-family: var(--label);
2413
+ font-size: 0.7rem;
2414
+ font-weight: 700;
2415
+ }
2416
+
2417
+ .atlas-chat-suggestions {
2418
+ display: flex;
2419
+ flex-wrap: wrap;
2420
+ gap: 6px;
2421
+ padding: 0 16px 10px;
2422
+ }
2423
+
2424
+ .atlas-chat-suggestions button {
2425
+ padding: 5px 10px;
2426
+ color: var(--ink-soft);
2427
+ background: rgba(255, 247, 224, 0.45);
2428
+ border: 1px solid var(--rule-soft);
2429
+ border-radius: 999px;
2430
+ font-family: var(--label);
2431
+ font-size: 0.68rem;
2432
+ font-weight: 760;
2433
+ cursor: pointer;
2434
+ }
2435
+
2436
+ .atlas-chat-suggestions button:hover {
2437
+ color: var(--oxblood);
2438
+ border-color: rgba(154, 43, 34, 0.4);
2439
+ }
2440
+
2441
+ .atlas-chat-composer {
2442
+ display: flex;
2443
+ gap: 8px;
2444
+ padding: 12px 16px 16px;
2445
+ border-top: 1px solid var(--rule);
2446
+ }
2447
+
2448
+ .atlas-chat-composer input {
2449
+ flex: 1;
2450
+ min-width: 0;
2451
+ padding: 9px 12px;
2452
+ color: var(--ink);
2453
+ background: rgba(255, 247, 224, 0.6);
2454
+ border: 1px solid var(--edge);
2455
+ border-radius: 8px;
2456
+ font-family: var(--serif);
2457
+ font-size: 0.86rem;
2458
+ }
2459
+
2460
+ .atlas-chat-composer input:focus {
2461
+ outline: 2px solid var(--gold-glow);
2462
+ border-color: var(--gold);
2463
+ }
2464
+
2465
+ .atlas-chat-fallback {
2466
+ color: var(--oxblood);
2467
+ font-family: var(--label);
2468
+ font-size: 0.66rem;
2469
+ font-weight: 760;
2470
+ }
2471
+
2472
+ @media (max-width: 760px) {
2473
+ .atlas-chat-drawer {
2474
+ width: 100%;
2475
+ border-left: none;
2476
+ }
2477
+
2478
+ .atlas-chat-scrim:not([hidden]) {
2479
+ display: block;
2480
+ }
2481
+ }
tests/test_app.py CHANGED
@@ -4,6 +4,9 @@ import time
4
  from io import BytesIO
5
  from zipfile import ZipFile
6
 
 
 
 
7
  import app as app_module
8
  from app import (
9
  agent_turn_stream,
@@ -12,6 +15,7 @@ from app import (
12
  chapter_api,
13
  chapter_artifact,
14
  dashboard,
 
15
  dashboard_search,
16
  dashboard_refresh_start,
17
  dashboard_refresh_status,
@@ -583,6 +587,120 @@ def test_agent_turn_stream_runs_on_cpu_compute() -> None:
583
  assert lines[-1]["state"]["ideas"]
584
 
585
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  def test_transcribe_audio_endpoint_saves_audio(monkeypatch) -> None:
587
  captured = {}
588
 
 
4
  from io import BytesIO
5
  from zipfile import ZipFile
6
 
7
+ import pytest
8
+ from fastapi import HTTPException
9
+
10
  import app as app_module
11
  from app import (
12
  agent_turn_stream,
 
15
  chapter_api,
16
  chapter_artifact,
17
  dashboard,
18
+ dashboard_chat_stream,
19
  dashboard_search,
20
  dashboard_refresh_start,
21
  dashboard_refresh_status,
 
587
  assert lines[-1]["state"]["ideas"]
588
 
589
 
590
+ def _run_dashboard_chat(payload: dict) -> list[dict]:
591
+ response = dashboard_chat_stream(payload)
592
+ assert response.media_type == "application/x-ndjson"
593
+ body = asyncio.run(_read_streaming_response(response))
594
+ return [json.loads(line) for line in body.splitlines()]
595
+
596
+
597
+ def test_dashboard_chat_stream_exports_ndjson_events() -> None:
598
+ lines = _run_dashboard_chat({"message": "what clusters exist on the map?"})
599
+ types = [line["type"] for line in lines]
600
+
601
+ assert types[0] == "start"
602
+ assert types[-1] == "done"
603
+ tool_call = next(line for line in lines if line["type"] == "tool_call")
604
+ assert tool_call["status"] == "valid"
605
+ assert tool_call["name"] == "list_clusters"
606
+ tool_result = next(line for line in lines if line["type"] == "tool_result")
607
+ assert tool_result["data"]["clusters"]
608
+ assert types.index("tool_result") < types.index("token")
609
+ done = lines[-1]
610
+ assert done["response"]
611
+ assert done["history"][-1]["role"] == "assistant"
612
+
613
+
614
+ def test_dashboard_chat_stream_skips_answer_for_unanalyzed_quests() -> None:
615
+ lines = _run_dashboard_chat({"message": "who completed the most quests?"})
616
+ types = [line["type"] for line in lines]
617
+
618
+ tool_call = next(line for line in lines if line["type"] == "tool_call")
619
+ assert tool_call["name"] == "top_projects_by_quests"
620
+ skipped = next(line for line in lines if line["type"] == "answer_skipped")
621
+ assert skipped["reason"] == "quests_not_analyzed"
622
+ assert "token" not in types
623
+ assert lines[-1]["response"] == skipped["text"]
624
+
625
+
626
+ def test_dashboard_chat_stream_emits_map_action_for_overview() -> None:
627
+ lines = _run_dashboard_chat({"message": "what is everyone building right now?"})
628
+
629
+ tool_result = next(line for line in lines if line["type"] == "tool_result")
630
+ assert tool_result["tool"] == "atlas_overview"
631
+ assert tool_result["map_action"] == {"type": "clear_filters"}
632
+ assert tool_result["data"]["project_count"] == len(index.projects)
633
+
634
+
635
+ def test_dashboard_chat_stream_round_trips_history() -> None:
636
+ first = _run_dashboard_chat({"message": "what clusters exist?"})
637
+ history = first[-1]["history"]
638
+ assert history[-2]["content"] == "what clusters exist?"
639
+
640
+ second = _run_dashboard_chat(
641
+ {"message": "what changed recently?", "history_json": json.dumps(history)}
642
+ )
643
+
644
+ # The rules backend answers every turn with the same fixed sentence, and repeated
645
+ # assistant lines are deliberately deduplicated — so assert the round trip keeps
646
+ # the latest turn rather than a fixed length.
647
+ final_history = second[-1]["history"]
648
+ assert {"role": "user", "content": "what changed recently?"} in final_history
649
+ assert final_history[-1]["role"] == "assistant"
650
+
651
+
652
+ def test_dashboard_chat_stream_runs_on_cpu_compute() -> None:
653
+ lines = _run_dashboard_chat({"message": "what clusters exist?", "compute": "cpu"})
654
+
655
+ assert lines[0]["type"] == "start"
656
+ assert lines[-1]["type"] == "done"
657
+
658
+
659
+ def test_dashboard_chat_stream_requires_message() -> None:
660
+ with pytest.raises(HTTPException) as error:
661
+ dashboard_chat_stream({"message": " "})
662
+
663
+ assert error.value.status_code == 400
664
+
665
+
666
+ class _QuotaError(Exception):
667
+ pass
668
+
669
+
670
+ def test_dashboard_chat_falls_back_to_cpu_on_pre_stream_quota_error(monkeypatch) -> None:
671
+ def quota_stream(message, history):
672
+ raise _QuotaError("gpu quota exceeded")
673
+ yield # pragma: no cover - makes this a generator
674
+
675
+ monkeypatch.setattr(app_module, "_primary_chat_stream", quota_stream)
676
+ monkeypatch.setattr(app_module, "is_gpu_quota_error", lambda error: isinstance(error, _QuotaError))
677
+
678
+ events = list(app_module._profiled_chat_events("what clusters exist?", "[]", "gpu"))
679
+
680
+ types = [event["type"] for event in events]
681
+ assert types[0] == "fallback"
682
+ assert types.count("start") == 1
683
+ assert types.count("done") == 1
684
+
685
+
686
+ def test_dashboard_chat_does_not_restart_after_mid_stream_failure(monkeypatch) -> None:
687
+ def mid_stream_failure(message, history):
688
+ yield {"type": "start", "normalized_text": message, "corrections": []}
689
+ raise _QuotaError("gpu quota exceeded")
690
+
691
+ monkeypatch.setattr(app_module, "_primary_chat_stream", mid_stream_failure)
692
+ monkeypatch.setattr(app_module, "is_gpu_quota_error", lambda error: isinstance(error, _QuotaError))
693
+
694
+ events = []
695
+ with pytest.raises(_QuotaError):
696
+ for event in app_module._profiled_chat_events("what clusters exist?", "[]", "gpu"):
697
+ events.append(event)
698
+
699
+ # A mid-stream failure must re-raise, never silently restart on CPU and emit
700
+ # a second start/done into the same NDJSON stream.
701
+ assert [event["type"] for event in events] == ["start"]
702
+
703
+
704
  def test_transcribe_audio_endpoint_saves_audio(monkeypatch) -> None:
705
  captured = {}
706
 
tests/test_dashboard_chat.py ADDED
@@ -0,0 +1,487 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from hackathon_advisor.dashboard_chat import DashboardChatEngine
2
+ from tests.test_dashboard_repository import analyzed_repository, not_analyzed_repository
3
+
4
+
5
+ class ScriptedRunner:
6
+ """ChatRunner double that replays prepared model outputs and records calls."""
7
+
8
+ backend = "scripted"
9
+ model_id = "scripted-test-model"
10
+ supports_thinking = False
11
+
12
+ def __init__(self, outputs: list[object]) -> None:
13
+ self.outputs = list(outputs)
14
+ self.calls: list[dict] = []
15
+
16
+ def stream(self, messages, *, tools=None, max_new_tokens, enable_thinking=False):
17
+ self.calls.append(
18
+ {
19
+ "messages": messages,
20
+ "tools": tools,
21
+ "max_new_tokens": max_new_tokens,
22
+ "enable_thinking": enable_thinking,
23
+ }
24
+ )
25
+ output = self.outputs.pop(0)
26
+ pieces = output if isinstance(output, list) else [output]
27
+ for count, piece in enumerate(pieces, start=1):
28
+ yield count, piece
29
+
30
+
31
+ class ThinkingScriptedRunner(ScriptedRunner):
32
+ """ScriptedRunner whose outputs start inside a <think> block (native thinking)."""
33
+
34
+ supports_thinking = True
35
+
36
+
37
+ def run_turn(runner, repository, message, history=None):
38
+ engine = DashboardChatEngine(runner, lambda: repository)
39
+ return list(engine.turn_stream(message, history))
40
+
41
+
42
+ def events_of(events, event_type):
43
+ return [event for event in events if event["type"] == event_type]
44
+
45
+
46
+ def test_tool_turn_streams_verified_data_before_prose() -> None:
47
+ runner = ScriptedRunner(
48
+ [
49
+ '<function name="top_projects_by_quests"></function>',
50
+ "Project 9 leads with two quests.",
51
+ ]
52
+ )
53
+
54
+ events = run_turn(runner, analyzed_repository(), "who completed the most quests?")
55
+
56
+ tool_call = events_of(events, "tool_call")[0]
57
+ assert tool_call["status"] == "valid"
58
+ assert tool_call["name"] == "top_projects_by_quests"
59
+
60
+ tool_result = events_of(events, "tool_result")[0]
61
+ assert tool_result["data"]["rows"][0]["id"] == "build-small-hackathon/project-9"
62
+ assert tool_result["map_action"]["type"] == "highlight_projects"
63
+
64
+ types = [event["type"] for event in events]
65
+ assert types.index("tool_result") < types.index("token")
66
+
67
+ done = events_of(events, "done")[0]
68
+ assert done["response"] == "Project 9 leads with two quests."
69
+ assert done["tool"] == "top_projects_by_quests"
70
+ assert done["history"][-2:] == [
71
+ {"role": "user", "content": "who completed the most quests?"},
72
+ {"role": "assistant", "content": "Project 9 leads with two quests."},
73
+ ]
74
+
75
+
76
+ def test_model_digest_strips_urls_and_ids() -> None:
77
+ runner = ScriptedRunner(
78
+ [
79
+ '<function name="top_projects_by_quests"></function>',
80
+ "Project 9 leads.",
81
+ ]
82
+ )
83
+
84
+ run_turn(runner, analyzed_repository(), "quest leaderboard")
85
+
86
+ answer_call = runner.calls[1]
87
+ assert answer_call["tools"] is None
88
+ digest = answer_call["messages"][-1]["content"]
89
+ assert "url" not in digest
90
+ assert "huggingface.co" not in digest
91
+ assert 'title: "Project 9"' in digest
92
+ assert "quest_count: 2" in digest
93
+
94
+
95
+ def test_model_digest_trims_long_listings_and_bm25_total() -> None:
96
+ repository = analyzed_repository()
97
+ runner = ScriptedRunner(
98
+ ['<function name="search_projects"><param name="query">project</param></function>', "Found a few."]
99
+ )
100
+ run_turn(runner, repository, "find project planners")
101
+ search_digest = runner.calls[1]["messages"][-1]["content"]
102
+ assert "total:" not in search_digest
103
+
104
+ runner = ScriptedRunner(['<function name="list_clusters"></function>', "A few clusters."])
105
+ run_turn(runner, repository, "what clusters exist")
106
+ cluster_digest = runner.calls[1]["messages"][-1]["content"]
107
+ # Only the largest cluster is restatable; the full list lives on the UI cards.
108
+ assert cluster_digest.count("label:") == 1
109
+ assert f"cluster_count: {repository.list_clusters()['cluster_count']}" in cluster_digest
110
+
111
+
112
+ def test_not_analyzed_quests_skip_the_answer_pass() -> None:
113
+ runner = ScriptedRunner(['<function name="top_projects_by_quests"></function>'])
114
+
115
+ events = run_turn(runner, not_analyzed_repository(), "who completed the most quests?")
116
+
117
+ skipped = events_of(events, "answer_skipped")[0]
118
+ assert skipped["reason"] == "quests_not_analyzed"
119
+ assert events_of(events, "token") == []
120
+ assert len(runner.calls) == 1 # pass 2 never ran
121
+ assert events_of(events, "done")[0]["response"] == skipped["text"]
122
+
123
+
124
+ def test_empty_search_skips_the_answer_pass() -> None:
125
+ runner = ScriptedRunner(
126
+ ['<function name="search_projects"><param name="query">zzzz qqqq</param></function>']
127
+ )
128
+
129
+ events = run_turn(runner, analyzed_repository(), "find zzzz qqqq")
130
+
131
+ skipped = events_of(events, "answer_skipped")[0]
132
+ assert skipped["reason"] == "no_search_results"
133
+ assert "zzzz qqqq" in skipped["text"]
134
+ assert len(runner.calls) == 1
135
+
136
+
137
+ def test_unknown_cluster_uses_templated_sentence() -> None:
138
+ runner = ScriptedRunner(
139
+ ['<function name="show_cluster"><param name="label">flying castles</param></function>']
140
+ )
141
+
142
+ events = run_turn(runner, analyzed_repository(), "show me the flying castles cluster")
143
+
144
+ skipped = events_of(events, "answer_skipped")[0]
145
+ assert skipped["reason"] == "unknown_cluster"
146
+ assert "flying castles" in skipped["text"]
147
+ assert events_of(events, "tool_result")[0]["map_action"] is None
148
+
149
+
150
+ def test_show_cluster_emits_filter_map_action() -> None:
151
+ repository = analyzed_repository()
152
+ label = repository.list_clusters()["clusters"][0]["label"]
153
+ runner = ScriptedRunner(
154
+ [
155
+ f'<function name="show_cluster"><param name="label">{label}</param></function>',
156
+ "That cluster groups the local-first planners.",
157
+ ]
158
+ )
159
+
160
+ events = run_turn(runner, repository, f"what is in {label}?")
161
+
162
+ map_action = events_of(events, "tool_result")[0]["map_action"]
163
+ assert map_action == {"type": "filter_cluster", "label": label}
164
+
165
+
166
+ def test_show_cluster_map_action_carries_canonical_label_for_fuzzy_input() -> None:
167
+ """The UI resolves filter_cluster by exact label match, so the engine must
168
+ forward the repository's canonical label, never the user's fuzzy input."""
169
+ repository = analyzed_repository()
170
+ canonical = repository.list_clusters()["clusters"][0]["label"]
171
+ fuzzy = canonical.split()[0].lower()
172
+ assert fuzzy != canonical
173
+ runner = ScriptedRunner(
174
+ [
175
+ f'<function name="show_cluster"><param name="label">{fuzzy}</param></function>',
176
+ "Here is that cluster.",
177
+ ]
178
+ )
179
+
180
+ events = run_turn(runner, repository, f"what is in the {fuzzy} cluster?")
181
+
182
+ map_action = events_of(events, "tool_result")[0]["map_action"]
183
+ assert map_action == {"type": "filter_cluster", "label": canonical}
184
+
185
+
186
+ def test_plain_prose_routes_to_dedicated_smalltalk_pass() -> None:
187
+ runner = ScriptedRunner(
188
+ [
189
+ "Hello! Happy to help.",
190
+ "Hi! Ask me what everyone is building.",
191
+ ]
192
+ )
193
+
194
+ events = run_turn(runner, analyzed_repository(), "hello there")
195
+
196
+ assert events_of(events, "tool_call")[0]["status"] == "none"
197
+ assert events_of(events, "tool_result") == []
198
+ done = events_of(events, "done")[0]
199
+ assert done["tool"] == ""
200
+ assert done["response"] == "Hi! Ask me what everyone is building."
201
+ assert runner.calls[1]["tools"] is None
202
+
203
+
204
+ def test_unmatched_data_question_defaults_to_search_not_smalltalk() -> None:
205
+ """Regression: 'how many voice apps' once slipped into ungrounded small talk."""
206
+ runner = ScriptedRunner(
207
+ [
208
+ "I'm not sure, but I can provide more information if you'd like.",
209
+ "The closest matches are shown on the cards.",
210
+ ]
211
+ )
212
+
213
+ events = run_turn(runner, analyzed_repository(), "how many voice apps")
214
+
215
+ tool_call = events_of(events, "tool_call")[0]
216
+ assert tool_call["status"] == "defaulted"
217
+ assert tool_call["name"] == "search_projects"
218
+ assert tool_call["arguments"]["query"] == "how many voice apps"
219
+
220
+
221
+ def test_short_followup_stays_on_smalltalk_path() -> None:
222
+ runner = ScriptedRunner(
223
+ [
224
+ "Because it scored well.",
225
+ "I should look that up rather than guess - ask me about the quest leaderboard!",
226
+ ]
227
+ )
228
+
229
+ events = run_turn(runner, analyzed_repository(), "are you sure?")
230
+
231
+ assert events_of(events, "tool_call")[0]["status"] == "none"
232
+ assert events_of(events, "tool_result") == []
233
+
234
+
235
+ def test_show_project_streams_readme_and_highlights_the_dot() -> None:
236
+ runner = ScriptedRunner(
237
+ [
238
+ '<function name="show_project"><param name="project">Project 4</param></function>',
239
+ "Project 4 is an offline planner; its app file loads gradio.",
240
+ ]
241
+ )
242
+
243
+ events = run_turn(runner, analyzed_repository(), "how does Project 4 work?")
244
+
245
+ tool_result = events_of(events, "tool_result")[0]
246
+ assert tool_result["tool"] == "show_project"
247
+ assert "README evidence for project 4" in tool_result["data"]["readme_excerpt"]
248
+ assert tool_result["map_action"] == {
249
+ "type": "highlight_projects",
250
+ "ids": ["build-small-hackathon/project-4"],
251
+ }
252
+ digest = runner.calls[1]["messages"][-1]["content"]
253
+ assert "README evidence for project 4" in digest
254
+ assert "import gradio" in digest
255
+ assert events_of(events, "done")[0]["tool"] == "show_project"
256
+
257
+
258
+ def test_show_project_falls_back_to_search_for_unknown_names() -> None:
259
+ runner = ScriptedRunner(
260
+ [
261
+ '<function name="show_project"><param name="project">offline planner thing</param></function>',
262
+ "The closest matches are on the cards.",
263
+ ]
264
+ )
265
+
266
+ events = run_turn(runner, analyzed_repository(), "tell me about the offline planner thing")
267
+
268
+ tool_result = events_of(events, "tool_result")[0]
269
+ assert tool_result["tool"] == "search_projects"
270
+ assert tool_result["data"]["results"]
271
+ assert events_of(events, "done")[0]["tool"] == "search_projects"
272
+
273
+
274
+ def test_prose_answer_to_data_question_is_routed_by_intent() -> None:
275
+ runner = ScriptedRunner(
276
+ [
277
+ "I do not have access to quest completion data.",
278
+ "Project 9 leads with two quests.",
279
+ ]
280
+ )
281
+
282
+ events = run_turn(runner, analyzed_repository(), "who completed the most quests?")
283
+
284
+ tool_call = events_of(events, "tool_call")[0]
285
+ assert tool_call["status"] == "defaulted"
286
+ assert tool_call["name"] == "top_projects_by_quests"
287
+ assert events_of(events, "tool_result")[0]["data"]["rows"]
288
+ assert events_of(events, "done")[0]["response"] == "Project 9 leads with two quests."
289
+
290
+
291
+ def test_malformed_call_degrades_through_intent_router() -> None:
292
+ runner = ScriptedRunner(
293
+ [
294
+ '<function name="nonexistent_tool"></function>',
295
+ "Project 9 leads the quest board.",
296
+ ]
297
+ )
298
+
299
+ events = run_turn(runner, analyzed_repository(), "who completed the most quests")
300
+
301
+ tool_call = events_of(events, "tool_call")[0]
302
+ assert tool_call["status"] == "defaulted"
303
+ assert tool_call["name"] == "top_projects_by_quests"
304
+ assert tool_call["errors"]
305
+
306
+
307
+ def test_stray_function_block_is_cut_from_the_answer() -> None:
308
+ runner = ScriptedRunner(
309
+ [
310
+ '<function name="atlas_overview"></function>',
311
+ ["The field has ten projects. ", '<function name="x">', "</function> extra"],
312
+ ]
313
+ )
314
+
315
+ events = run_turn(runner, analyzed_repository(), "overview please")
316
+
317
+ done = events_of(events, "done")[0]
318
+ assert done["response"] == "The field has ten projects."
319
+ for token in events_of(events, "token"):
320
+ assert "<function" not in token["text"]
321
+
322
+
323
+ def test_thinking_trace_streams_separately_from_the_tool_call() -> None:
324
+ runner = ThinkingScriptedRunner(
325
+ [
326
+ [
327
+ "The user wants the leaderboard. I could emit ",
328
+ '<function name="x"> here but the right tool is top_projects_by_quests.',
329
+ "</th",
330
+ 'ink>\n\n<function name="top_projects_by_quests"></function>',
331
+ ],
332
+ ["Let me restate the digest.</think>\n\nProject 9 leads with two quests."],
333
+ ]
334
+ )
335
+
336
+ events = run_turn(runner, analyzed_repository(), "who completed the most quests?")
337
+
338
+ thinking_pass1 = [e for e in events if e["type"] == "thinking" and e["pass"] == 1]
339
+ assert "".join(e["text"] for e in thinking_pass1).startswith("The user wants the leaderboard")
340
+ # <function inside the REASONING must not be mistaken for the call itself.
341
+ tool_call = events_of(events, "tool_call")[0]
342
+ assert tool_call["status"] == "valid"
343
+ assert tool_call["name"] == "top_projects_by_quests"
344
+
345
+ thinking_pass2 = [e for e in events if e["type"] == "thinking" and e["pass"] == 2]
346
+ assert "".join(e["text"] for e in thinking_pass2) == "Let me restate the digest."
347
+ done = events_of(events, "done")[0]
348
+ assert done["response"] == "Project 9 leads with two quests."
349
+ for token in events_of(events, "token"):
350
+ assert "</think>" not in token["text"]
351
+ # Thinking never leaks into the durable history.
352
+ assert all("Let me restate" not in entry["content"] for entry in done["history"])
353
+
354
+
355
+ def test_thinking_marker_split_across_pieces_is_handled() -> None:
356
+ runner = ThinkingScriptedRunner(
357
+ [
358
+ ["step one ", "step two</t", "hink>", '\n\n<function name="list_quests"></function>'],
359
+ ["ok</think>\n\nThe quests are on the cards."],
360
+ ]
361
+ )
362
+
363
+ events = run_turn(runner, analyzed_repository(), "list the quests")
364
+
365
+ thinking = "".join(e["text"] for e in events if e["type"] == "thinking" and e["pass"] == 1)
366
+ assert thinking == "step one step two"
367
+ assert events_of(events, "tool_call")[0]["name"] == "list_quests"
368
+
369
+
370
+ def test_truncated_thinking_degrades_gracefully() -> None:
371
+ """A 4096-token cut inside <think> leaves no answer text; the turn must still
372
+ resolve (intent backstop on pass 1, templated sentence on pass 2)."""
373
+ runner = ThinkingScriptedRunner(
374
+ [
375
+ ["I am still reasoning about which tool to"], # no </think>, no call
376
+ ["and the digest says</think>\n\nProject 9 leads."],
377
+ ]
378
+ )
379
+
380
+ events = run_turn(runner, analyzed_repository(), "who completed the most quests?")
381
+
382
+ tool_call = events_of(events, "tool_call")[0]
383
+ assert tool_call["status"] == "defaulted"
384
+ assert tool_call["name"] == "top_projects_by_quests"
385
+ assert events_of(events, "done")[0]["response"] == "Project 9 leads."
386
+
387
+
388
+ def test_chat_generations_use_thinking_and_4096_budget() -> None:
389
+ runner = ScriptedRunner(
390
+ [
391
+ '<function name="atlas_overview"></function>',
392
+ "Ten projects in view.",
393
+ ]
394
+ )
395
+
396
+ events = run_turn(runner, analyzed_repository(), "overview")
397
+
398
+ for call in runner.calls:
399
+ assert call["enable_thinking"] is True
400
+ assert call["max_new_tokens"] >= 4096
401
+ for progress in events_of(events, "model_progress"):
402
+ assert progress["max_tokens"] >= 4096
403
+
404
+
405
+ def test_history_drops_repeated_assistant_answers() -> None:
406
+ """Regression: a greedy 1B echoes any sentence that appears twice in history."""
407
+ runner = ScriptedRunner(
408
+ [
409
+ '<function name="atlas_overview"></function>',
410
+ "Ten projects in view.",
411
+ ]
412
+ )
413
+ looping_history = [
414
+ {"role": "user", "content": "why"},
415
+ {"role": "assistant", "content": "I should look that up."},
416
+ {"role": "user", "content": "are you sure?"},
417
+ {"role": "assistant", "content": "I should look that up."},
418
+ {"role": "user", "content": "really?"},
419
+ {"role": "assistant", "content": "I should look that up."},
420
+ ]
421
+
422
+ run_turn(runner, analyzed_repository(), "overview", looping_history)
423
+
424
+ pass1_messages = runner.calls[0]["messages"]
425
+ repeated = [m for m in pass1_messages if m.get("content") == "I should look that up."]
426
+ assert len(repeated) == 1
427
+
428
+
429
+ def test_grounded_answer_pass_sees_no_history() -> None:
430
+ """Echoes regression: with history in the prompt, a greedy 1B repeats prior
431
+ answers instead of reading the digest. Facts come from the digest alone."""
432
+ runner = ScriptedRunner(
433
+ [
434
+ '<function name="atlas_overview"></function>',
435
+ "Ten projects in view.",
436
+ ]
437
+ )
438
+ long_history = []
439
+ for index in range(6):
440
+ long_history.append({"role": "user", "content": f"question {index}"})
441
+ long_history.append({"role": "assistant", "content": f"answer {index}"})
442
+
443
+ run_turn(runner, analyzed_repository(), "overview", long_history)
444
+
445
+ answer_messages = runner.calls[1]["messages"]
446
+ roles = [m["role"] for m in answer_messages]
447
+ assert roles == ["system", "user", "assistant", "tool"]
448
+
449
+
450
+ def test_overview_digest_leads_with_most_liked_projects() -> None:
451
+ runner = ScriptedRunner(
452
+ [
453
+ '<function name="atlas_overview"></function>',
454
+ "Project 9 is the most liked.",
455
+ ]
456
+ )
457
+
458
+ run_turn(runner, analyzed_repository(), "what is the coolest project?")
459
+
460
+ digest = runner.calls[1]["messages"][-1]["content"]
461
+ assert digest.startswith("most_liked_projects:")
462
+ assert "most_completed_quests:" in digest
463
+
464
+
465
+ def test_history_is_normalized_and_capped() -> None:
466
+ runner = ScriptedRunner(
467
+ [
468
+ '<function name="atlas_overview"></function>',
469
+ "Ten projects in view.",
470
+ ]
471
+ )
472
+ junk_history = [
473
+ {"role": "user", "content": "old question"},
474
+ {"role": "assistant", "content": "old answer"},
475
+ {"role": "tool", "content": "should be dropped"},
476
+ "not even a dict",
477
+ {"role": "user", "content": ""},
478
+ ]
479
+
480
+ events = run_turn(runner, analyzed_repository(), "overview", junk_history)
481
+
482
+ pass1_messages = runner.calls[0]["messages"]
483
+ roles = [message["role"] for message in pass1_messages]
484
+ assert roles == ["system", "user", "assistant", "user"]
485
+ done = events_of(events, "done")[0]
486
+ assert all(entry["role"] in ("user", "assistant") for entry in done["history"])
487
+ assert len(done["history"]) <= 12
tests/test_dashboard_chat_contracts.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from hackathon_advisor.dashboard_chat_contracts import (
4
+ CHAT_TOOL_SPECS,
5
+ chat_tool_schemas,
6
+ heuristic_chat_call,
7
+ parse_native_tool_call,
8
+ resolve_chat_tool_call,
9
+ strip_function_blocks,
10
+ )
11
+ from hackathon_advisor.tool_contracts import ToolContractError
12
+
13
+
14
+ def test_chat_tool_schemas_are_openai_style() -> None:
15
+ schemas = chat_tool_schemas()
16
+
17
+ assert len(schemas) == len(CHAT_TOOL_SPECS) == 9
18
+ for schema in schemas:
19
+ assert schema["type"] == "function"
20
+ assert schema["function"]["parameters"]["type"] == "object"
21
+
22
+
23
+ def test_parse_native_tool_call_reads_params() -> None:
24
+ call = parse_native_tool_call(
25
+ '<function name="search_projects"><param name="query">voice agents</param></function>'
26
+ )
27
+
28
+ assert call.name == "search_projects"
29
+ assert call.arguments == {"query": "voice agents"}
30
+
31
+
32
+ def test_parse_native_tool_call_handles_cdata_and_surrounding_prose() -> None:
33
+ call = parse_native_tool_call(
34
+ 'Let me look that up.\n<function name="show_cluster">'
35
+ "<param name=\"label\"><![CDATA[Voice / Chatbot & ASR]]></param></function> Done."
36
+ )
37
+
38
+ assert call.name == "show_cluster"
39
+ assert call.arguments == {"label": "Voice / Chatbot & ASR"}
40
+
41
+
42
+ def test_parse_native_tool_call_without_params() -> None:
43
+ call = parse_native_tool_call('<function name="list_quests"></function>')
44
+
45
+ assert call.name == "list_quests"
46
+ assert call.arguments == {}
47
+
48
+
49
+ def test_parse_native_tool_call_rejects_garbage() -> None:
50
+ with pytest.raises(ToolContractError):
51
+ parse_native_tool_call('<function name="x"><param name="q">unclosed</function>')
52
+ with pytest.raises(ToolContractError):
53
+ parse_native_tool_call("just prose, no call")
54
+ with pytest.raises(ToolContractError):
55
+ parse_native_tool_call('<function><param name="q">missing name</param></function>')
56
+
57
+
58
+ def test_resolve_marks_plain_prose_as_none() -> None:
59
+ resolution = resolve_chat_tool_call("Hello! Ask me about the atlas.", fallback_query="hello")
60
+
61
+ assert resolution.status == "none"
62
+ assert resolution.call is None
63
+ assert resolution.errors == ()
64
+
65
+
66
+ def test_resolve_accepts_valid_call() -> None:
67
+ resolution = resolve_chat_tool_call(
68
+ '<function name="show_quest"><param name="quest">Tiny Titan</param></function>',
69
+ fallback_query="tiny titan",
70
+ )
71
+
72
+ assert resolution.status == "valid"
73
+ assert resolution.call is not None
74
+ assert resolution.call.name == "show_quest"
75
+
76
+
77
+ def test_resolve_defaults_unknown_tool_through_intent_router() -> None:
78
+ resolution = resolve_chat_tool_call(
79
+ '<function name="made_up_tool"></function>',
80
+ fallback_query="who completed the most quests?",
81
+ )
82
+
83
+ assert resolution.status == "defaulted"
84
+ assert resolution.call is not None
85
+ assert resolution.call.name == "top_projects_by_quests"
86
+ assert resolution.errors
87
+
88
+
89
+ def test_resolve_defaults_malformed_xml_to_search() -> None:
90
+ resolution = resolve_chat_tool_call(
91
+ '<function name="search_projects"><param name="query">a < b</param></function>',
92
+ fallback_query="projects about voice",
93
+ )
94
+
95
+ assert resolution.status == "defaulted"
96
+ assert resolution.call is not None
97
+ assert resolution.call.name == "search_projects"
98
+ assert resolution.call.arguments["query"] == "projects about voice"
99
+
100
+
101
+ def test_resolve_rejects_missing_required_argument() -> None:
102
+ resolution = resolve_chat_tool_call(
103
+ '<function name="show_cluster"></function>',
104
+ fallback_query="show me the voice cluster",
105
+ )
106
+
107
+ assert resolution.status == "defaulted"
108
+ assert resolution.errors
109
+
110
+
111
+ def test_heuristic_chat_call_routes_intents() -> None:
112
+ assert heuristic_chat_call("who completed the most quests").name == "top_projects_by_quests"
113
+ assert heuristic_chat_call("what clusters exist").name == "list_clusters"
114
+ assert heuristic_chat_call("list the quests please").name == "list_quests"
115
+ assert heuristic_chat_call("what changed recently").name == "recent_activity"
116
+ assert heuristic_chat_call("what is everyone building").name == "atlas_overview"
117
+ assert heuristic_chat_call("knitting helpers").name == "search_projects"
118
+ assert heuristic_chat_call("").name == "atlas_overview"
119
+
120
+
121
+ def test_data_intent_call_separates_detail_from_listing() -> None:
122
+ from hackathon_advisor.dashboard_chat_contracts import data_intent_call
123
+
124
+ detail = data_intent_call("what is in the Dream / Oracle cluster?")
125
+ assert detail is not None and detail.name == "show_cluster"
126
+ assert "Dream / Oracle" in detail.arguments["label"]
127
+
128
+ quest_detail = data_intent_call("tell me about the Tiny Titan quest")
129
+ assert quest_detail is not None and quest_detail.name == "show_quest"
130
+
131
+ assert data_intent_call("what clusters exist").name == "list_clusters"
132
+ assert data_intent_call("find projects about voice").name == "search_projects"
133
+ assert data_intent_call("how many voice apps").name == "search_projects"
134
+ assert data_intent_call("what is the coolest project?").name == "atlas_overview"
135
+ assert data_intent_call("which project is most liked").name == "atlas_overview"
136
+ assert data_intent_call("hello there") is None
137
+ assert data_intent_call("thanks!") is None
138
+
139
+
140
+ def test_data_intent_call_routes_project_reading() -> None:
141
+ from hackathon_advisor.dashboard_chat_contracts import data_intent_call
142
+
143
+ readme = data_intent_call("read the readme of Jawbreaker")
144
+ assert readme is not None and readme.name == "show_project"
145
+
146
+ how = data_intent_call("how does Jawbreaker work?")
147
+ assert how is not None and how.name == "show_project"
148
+
149
+ about = data_intent_call("tell me about Jawbreaker")
150
+ assert about is not None and about.name == "show_project"
151
+ assert about.arguments["project"] == "tell me about Jawbreaker"
152
+
153
+ # cluster/quest detail intents keep their own tools
154
+ assert data_intent_call("tell me about the Tiny Titan quest").name == "show_quest"
155
+ assert data_intent_call("what is in the Dream / Oracle cluster?").name == "show_cluster"
156
+
157
+
158
+ def test_smalltalk_intent_only_matches_greetings_and_followups() -> None:
159
+ from hackathon_advisor.dashboard_chat_contracts import smalltalk_intent
160
+
161
+ assert smalltalk_intent("hello!")
162
+ assert smalltalk_intent("hey there")
163
+ assert smalltalk_intent("thanks")
164
+ assert smalltalk_intent("why")
165
+ assert smalltalk_intent("are you sure?")
166
+ assert smalltalk_intent("who are you")
167
+ assert smalltalk_intent("")
168
+
169
+ assert not smalltalk_intent("how many voice apps")
170
+ assert not smalltalk_intent("what is the coolest project?")
171
+ assert not smalltalk_intent("voice agents for elderly care")
172
+ assert not smalltalk_intent("knitting helpers")
173
+
174
+
175
+ def test_strip_function_blocks_removes_stray_calls() -> None:
176
+ text = 'Here you go. <function name="x"><param name="q">v</param></function> The map shows it.'
177
+
178
+ assert strip_function_blocks(text) == "Here you go. The map shows it.".strip()
179
+ assert strip_function_blocks("plain prose") == "plain prose"
tests/test_dashboard_repository.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from hackathon_advisor.dashboard import build_dashboard_payload
2
+ from hackathon_advisor.dashboard_repository import DashboardRepository
3
+ from hackathon_advisor.dashboard_search import DashboardSearchIndex
4
+ from hackathon_advisor.data import Project, ProjectIndex, build_index_payload
5
+
6
+
7
+ def test_overview_reports_counts_clusters_and_quests() -> None:
8
+ repository = analyzed_repository()
9
+
10
+ overview = repository.overview()
11
+
12
+ assert overview["project_count"] == 10
13
+ assert overview["cluster_count"] >= 1
14
+ assert overview["quest_status"] == "analyzed"
15
+ assert overview["top_clusters"][0]["project_count"] >= 1
16
+ assert overview["top_quests"][0]["project_count"] >= 1
17
+ assert overview["most_liked"][0]["title"] == "Project 9"
18
+
19
+
20
+ def test_overview_handles_not_analyzed_quests() -> None:
21
+ repository = not_analyzed_repository()
22
+
23
+ overview = repository.overview()
24
+
25
+ assert overview["quest_status"] == "not_analyzed"
26
+ assert overview["top_quests"] == []
27
+
28
+
29
+ def test_list_clusters_returns_labels_not_ids() -> None:
30
+ repository = analyzed_repository()
31
+
32
+ listing = repository.list_clusters()
33
+
34
+ assert listing["cluster_count"] == len(listing["clusters"])
35
+ for cluster in listing["clusters"]:
36
+ assert cluster["label"]
37
+ assert not cluster["label"].startswith("cluster-")
38
+ assert cluster["project_count"] >= 1
39
+
40
+
41
+ def test_cluster_detail_resolves_fuzzy_label_and_id() -> None:
42
+ repository = analyzed_repository()
43
+ first = repository.list_clusters()["clusters"][0]
44
+
45
+ by_label = repository.cluster_detail(first["label"])
46
+ by_lower = repository.cluster_detail(first["label"].lower())
47
+ by_id = repository.cluster_detail("cluster-1")
48
+
49
+ assert by_label is not None and by_label["label"] == first["label"]
50
+ assert by_lower is not None and by_lower["label"] == first["label"]
51
+ assert by_id is not None
52
+ assert by_label["examples"]
53
+ assert by_label["examples"][0]["id"].startswith("build-small-hackathon/")
54
+
55
+
56
+ def test_cluster_detail_returns_none_for_unknown_label() -> None:
57
+ repository = analyzed_repository()
58
+
59
+ assert repository.cluster_detail("totally unrelated nonsense xyz") is None
60
+ assert repository.cluster_detail("") is None
61
+
62
+
63
+ def test_list_quests_sorted_by_coverage() -> None:
64
+ repository = analyzed_repository()
65
+
66
+ listing = repository.list_quests()
67
+
68
+ counts = [quest["project_count"] for quest in listing["quests"]]
69
+ assert listing["status"] == "analyzed"
70
+ assert counts == sorted(counts, reverse=True)
71
+ assert counts[0] >= 1
72
+
73
+
74
+ def test_quest_detail_resolves_aliases() -> None:
75
+ repository = analyzed_repository()
76
+
77
+ detail = repository.quest_detail("local first")
78
+
79
+ assert detail is not None
80
+ assert detail["id"] == "Off the Grid"
81
+ assert detail["project_count"] >= 1
82
+ assert detail["examples"]
83
+
84
+
85
+ def test_quest_detail_rejects_unknown_quest() -> None:
86
+ repository = analyzed_repository()
87
+
88
+ assert repository.quest_detail("not a quest at all") is None
89
+
90
+
91
+ def test_quest_detail_spots_label_inside_a_question() -> None:
92
+ repository = analyzed_repository()
93
+
94
+ detail = repository.quest_detail("tell me about the Tiny Titan quest")
95
+
96
+ assert detail is not None
97
+ assert detail["id"] == "Tiny Titan"
98
+
99
+
100
+ def test_top_by_quests_ranks_projects_by_quest_count() -> None:
101
+ repository = analyzed_repository()
102
+
103
+ leaderboard = repository.top_by_quests(limit=5)
104
+
105
+ assert leaderboard["status"] == "analyzed"
106
+ assert leaderboard["rows"]
107
+ counts = [row["quest_count"] for row in leaderboard["rows"]]
108
+ assert counts == sorted(counts, reverse=True)
109
+ assert leaderboard["rows"][0]["quest_count"] == 2
110
+ assert leaderboard["rows"][0]["id"] == "build-small-hackathon/project-9"
111
+
112
+
113
+ def test_top_by_quests_empty_when_not_analyzed() -> None:
114
+ repository = not_analyzed_repository()
115
+
116
+ leaderboard = repository.top_by_quests()
117
+
118
+ assert leaderboard["status"] == "not_analyzed"
119
+ assert leaderboard["rows"] == []
120
+ assert leaderboard["projects_with_quests"] == 0
121
+
122
+
123
+ def test_search_delegates_to_bm25_index() -> None:
124
+ repository = analyzed_repository()
125
+
126
+ result = repository.search("project 4 summary", limit=3)
127
+
128
+ assert result["total"] >= 1
129
+ assert result["results"][0]["id"] == "build-small-hackathon/project-4"
130
+ assert result["results"][0]["url"]
131
+
132
+
133
+ def test_project_detail_returns_readme_and_app_excerpts() -> None:
134
+ repository = analyzed_repository()
135
+
136
+ by_title = repository.project_detail("Project 4")
137
+ by_slug = repository.project_detail("project-4")
138
+ embedded = repository.project_detail("how does Project 4 work?")
139
+
140
+ assert by_title is not None
141
+ assert by_title["id"] == "build-small-hackathon/project-4"
142
+ assert "README evidence for project 4" in by_title["readme_excerpt"]
143
+ assert "import gradio" in by_title["app_excerpt"]
144
+ assert by_title["app_file"] == "app.py"
145
+ assert by_title["cluster_label"]
146
+ assert by_slug is not None and by_slug["id"] == by_title["id"]
147
+ assert embedded is not None and embedded["id"] == by_title["id"]
148
+
149
+
150
+ def test_project_detail_returns_none_for_unknown_project() -> None:
151
+ repository = analyzed_repository()
152
+
153
+ assert repository.project_detail("totally unknown thing") is None
154
+ assert repository.project_detail("") is None
155
+
156
+
157
+ def test_repository_survives_a_sparse_payload() -> None:
158
+ """The docstring promises empty-but-well-formed results on degraded snapshots."""
159
+ project_index = fake_project_index()
160
+ full_payload = build_dashboard_payload(project_index, generated_at="2026-06-08T00:00:00+00:00")
161
+ search_index = DashboardSearchIndex(project_index.projects, full_payload)
162
+ repository = DashboardRepository({}, search_index)
163
+
164
+ assert repository.overview()["project_count"] == 0
165
+ assert repository.list_clusters()["clusters"] == []
166
+ assert repository.list_quests()["quests"] == []
167
+ assert repository.top_by_quests()["rows"] == []
168
+ assert repository.recent_activity()["projects"] == []
169
+ assert repository.cluster_detail("anything") is None
170
+ assert repository.quest_detail("Off the Grid") is not None
171
+ assert repository.search("planner")["results"]
172
+
173
+
174
+ def test_recent_activity_sorts_by_last_modified() -> None:
175
+ repository = analyzed_repository()
176
+
177
+ recent = repository.recent_activity(limit=3)
178
+
179
+ assert [project["id"] for project in recent["projects"]] == [
180
+ "build-small-hackathon/project-9",
181
+ "build-small-hackathon/project-8",
182
+ "build-small-hackathon/project-7",
183
+ ]
184
+ assert recent["projects"][0]["cluster_label"]
185
+
186
+
187
+ def analyzed_repository() -> DashboardRepository:
188
+ project_index = fake_project_index()
189
+ quest_matches = {project.id: [] for project in project_index.projects}
190
+ quest_matches["build-small-hackathon/project-9"] = [
191
+ {"quest": "Off the Grid", "confidence": 0.9, "evidence": "loads weights locally", "source": "readme"},
192
+ {"quest": "Tiny Titan", "confidence": 0.8, "evidence": "MiniCPM5-1B model", "source": "app_file"},
193
+ ]
194
+ quest_matches["build-small-hackathon/project-4"] = [
195
+ {"quest": "Off the Grid", "confidence": 0.7, "evidence": "local llama.cpp runtime", "source": "readme"},
196
+ ]
197
+ payload = build_dashboard_payload(
198
+ project_index,
199
+ quest_matches=quest_matches,
200
+ quest_source="test-quest-run",
201
+ generated_at="2026-06-08T00:00:00+00:00",
202
+ )
203
+ return DashboardRepository(payload, DashboardSearchIndex(project_index.projects, payload))
204
+
205
+
206
+ def not_analyzed_repository() -> DashboardRepository:
207
+ project_index = fake_project_index()
208
+ payload = build_dashboard_payload(project_index, generated_at="2026-06-08T00:00:00+00:00")
209
+ return DashboardRepository(payload, DashboardSearchIndex(project_index.projects, payload))
210
+
211
+
212
+ def fake_project_index() -> ProjectIndex:
213
+ projects = [
214
+ Project(
215
+ id=f"build-small-hackathon/project-{index}",
216
+ title=f"Project {index}",
217
+ summary=f"Offline project planner {index}",
218
+ tags=("gradio", "local-first"),
219
+ models=("tiny-model",),
220
+ datasets=(),
221
+ likes=index,
222
+ sdk="gradio",
223
+ license="mit",
224
+ created_at="2026-06-01T00:00:00+00:00",
225
+ last_modified=f"2026-06-{index + 1:02d}T00:00:00+00:00",
226
+ host=f"https://project-{index}.hf.space",
227
+ url=f"https://huggingface.co/spaces/build-small-hackathon/project-{index}",
228
+ app_file="app.py",
229
+ app_file_embedding_text=f"local inference gradio small model artifact project {index}",
230
+ app_file_source=f'import gradio as gr\n\ndemo = gr.Interface(fn=str) # project {index}\ndemo.launch()',
231
+ readme_body=f"README evidence for project {index}",
232
+ )
233
+ for index in range(10)
234
+ ]
235
+ embeddings = []
236
+ for index in range(10):
237
+ vector = [0.0] * 10
238
+ vector[index] = 1.0
239
+ embeddings.append(vector)
240
+ generated_at = "2026-06-08T00:00:00+00:00"
241
+ source = "https://example.test/spaces"
242
+ return ProjectIndex(
243
+ projects=projects,
244
+ generated_at=generated_at,
245
+ source=source,
246
+ index_payload=build_index_payload(projects, generated_at, source, embeddings),
247
+ )
tests/test_model_runtime.py CHANGED
@@ -1,11 +1,19 @@
 
 
 
1
  import pytest
2
 
 
3
  from hackathon_advisor.model_runtime import (
4
  DEFAULT_ADAPTER_ID,
5
  DEFAULT_ADAPTER_REVISION,
 
6
  MiniCPMTransformersPlanner,
 
7
  RuleBasedPlanner,
 
8
  create_tool_planner,
 
9
  render_context,
10
  runtime_status,
11
  system_prompt,
@@ -13,6 +21,7 @@ from hackathon_advisor.model_runtime import (
13
  _minicpm_generation_kwargs,
14
  _load_minicpm_causal_lm,
15
  _minicpm_chat_inputs,
 
16
  _normalize_xml_tool_output,
17
  _resolve_torch_device,
18
  _strip_unused_generation_inputs,
@@ -75,6 +84,276 @@ class FakeMiniCPMModel:
75
  return self
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  def test_rule_planner_emits_valid_search_call() -> None:
79
  planner = RuleBasedPlanner()
80
 
 
1
+ import sys
2
+ import types
3
+
4
  import pytest
5
 
6
+ from hackathon_advisor.dashboard_chat_contracts import parse_native_tool_call
7
  from hackathon_advisor.model_runtime import (
8
  DEFAULT_ADAPTER_ID,
9
  DEFAULT_ADAPTER_REVISION,
10
+ MiniCPMChatRunner,
11
  MiniCPMTransformersPlanner,
12
+ RuleBasedChatRunner,
13
  RuleBasedPlanner,
14
+ create_chat_runner,
15
  create_tool_planner,
16
+ generation_lock,
17
  render_context,
18
  runtime_status,
19
  system_prompt,
 
21
  _minicpm_generation_kwargs,
22
  _load_minicpm_causal_lm,
23
  _minicpm_chat_inputs,
24
+ _minicpm_chat_inputs_with_tools,
25
  _normalize_xml_tool_output,
26
  _resolve_torch_device,
27
  _strip_unused_generation_inputs,
 
84
  return self
85
 
86
 
87
+ class FakeToolsTokenizer(FakeTokenizer):
88
+ """FakeTokenizer that also records the native tools= template path."""
89
+
90
+ def apply_chat_template(
91
+ self, messages, *, tokenize, add_generation_prompt, enable_thinking, tools=None
92
+ ):
93
+ self.template_call = {
94
+ "messages": messages,
95
+ "tokenize": tokenize,
96
+ "add_generation_prompt": add_generation_prompt,
97
+ "enable_thinking": enable_thinking,
98
+ }
99
+ if tools is not None:
100
+ self.template_call["tools"] = tools
101
+ return "rendered prompt"
102
+
103
+
104
+ class FakeStreamer:
105
+ """Stands in for transformers.TextIteratorStreamer in the worker-thread flow."""
106
+
107
+ def __init__(self, tokenizer, *, skip_prompt, skip_special_tokens) -> None:
108
+ import queue
109
+
110
+ self._queue: queue.Queue = queue.Queue()
111
+
112
+ def put(self, piece) -> None:
113
+ self._queue.put(piece)
114
+
115
+ def end(self) -> None:
116
+ self._queue.put(None)
117
+
118
+ def __iter__(self):
119
+ while True:
120
+ piece = self._queue.get()
121
+ if piece is None:
122
+ return
123
+ yield piece
124
+
125
+
126
+ class FakeParameter:
127
+ device = "cpu"
128
+
129
+
130
+ class FakeAdapterContext:
131
+ def __init__(self, log: list[str]) -> None:
132
+ self._log = log
133
+
134
+ def __enter__(self):
135
+ self._log.append("adapter_disabled")
136
+ return self
137
+
138
+ def __exit__(self, *exc_info):
139
+ self._log.append("adapter_restored")
140
+ return False
141
+
142
+
143
+ class FakeChatModel:
144
+ def __init__(self, pieces: tuple[str, ...], adapter_log: list[str] | None = None) -> None:
145
+ self.pieces = pieces
146
+ self.adapter_log = adapter_log
147
+ self.generate_calls: list[dict] = []
148
+ self.lock_was_held: list[bool] = []
149
+
150
+ def parameters(self):
151
+ return iter([FakeParameter()])
152
+
153
+ def generate(self, **kwargs) -> None:
154
+ self.lock_was_held.append(generation_lock().locked())
155
+ self.generate_calls.append(kwargs)
156
+ streamer = kwargs["streamer"]
157
+ for piece in self.pieces:
158
+ streamer.put(piece)
159
+ streamer.end()
160
+
161
+
162
+ class FakeAdapterChatModel(FakeChatModel):
163
+ def disable_adapter(self):
164
+ assert self.adapter_log is not None
165
+ return FakeAdapterContext(self.adapter_log)
166
+
167
+
168
+ @pytest.fixture
169
+ def fake_transformers(monkeypatch: pytest.MonkeyPatch):
170
+ module = types.SimpleNamespace(TextIteratorStreamer=FakeStreamer)
171
+ monkeypatch.setitem(sys.modules, "transformers", module)
172
+ return module
173
+
174
+
175
+ def chat_runner_with(model: FakeChatModel) -> MiniCPMChatRunner:
176
+ planner = MiniCPMTransformersPlanner(
177
+ "openbmb/MiniCPM5-1B",
178
+ adapter_id="build-small-hackathon/some-lora" if hasattr(model, "disable_adapter") else "",
179
+ )
180
+ planner._model = model
181
+ planner._tokenizer = FakeToolsTokenizer()
182
+ return MiniCPMChatRunner(planner)
183
+
184
+
185
+ def test_chat_inputs_with_tools_passes_native_tools() -> None:
186
+ tokenizer = FakeToolsTokenizer()
187
+ tools = [{"type": "function", "function": {"name": "list_quests"}}]
188
+
189
+ inputs = _minicpm_chat_inputs_with_tools(
190
+ tokenizer,
191
+ [{"role": "user", "content": "hello"}],
192
+ tools=tools,
193
+ enable_thinking=False,
194
+ device="cpu",
195
+ )
196
+
197
+ assert tokenizer.template_call["tools"] == tools
198
+ assert tokenizer.template_call["enable_thinking"] is False
199
+ assert inputs == {"input_ids": [1], "attention_mask": [1], "device": "cpu"}
200
+
201
+
202
+ def test_chat_runner_streams_under_lock_with_adapter_disabled(fake_transformers) -> None:
203
+ adapter_log: list[str] = []
204
+ model = FakeAdapterChatModel(("<function ", 'name="list_quests">', "</function>"), adapter_log)
205
+ runner = chat_runner_with(model)
206
+
207
+ pieces = list(
208
+ runner.stream(
209
+ [{"role": "user", "content": "what quests exist"}],
210
+ tools=[{"type": "function", "function": {"name": "list_quests"}}],
211
+ max_new_tokens=96,
212
+ )
213
+ )
214
+
215
+ assert [piece for _count, piece in pieces] == [
216
+ "<function ",
217
+ 'name="list_quests">',
218
+ "</function>",
219
+ ]
220
+ assert [count for count, _piece in pieces] == [1, 2, 3]
221
+ assert adapter_log == ["adapter_disabled", "adapter_restored"]
222
+ assert model.lock_was_held == [True]
223
+ assert generation_lock().locked() is False
224
+ assert model.generate_calls[0]["max_new_tokens"] == 96
225
+ assert model.generate_calls[0]["do_sample"] is False
226
+ template_call = runner._planner._tokenizer.template_call
227
+ assert "tools" in template_call
228
+
229
+
230
+ def test_chat_runner_forwards_enable_thinking_to_the_template(fake_transformers) -> None:
231
+ model = FakeChatModel(("thoughts</think>\n\nanswer",))
232
+ runner = chat_runner_with(model)
233
+
234
+ list(
235
+ runner.stream(
236
+ [{"role": "user", "content": "hi"}],
237
+ tools=[{"type": "function"}],
238
+ max_new_tokens=4096,
239
+ enable_thinking=True,
240
+ )
241
+ )
242
+
243
+ template_call = runner._planner._tokenizer.template_call
244
+ assert template_call["enable_thinking"] is True
245
+ assert model.generate_calls[0]["max_new_tokens"] == 4096
246
+ assert MiniCPMChatRunner.supports_thinking is True
247
+ assert RuleBasedChatRunner.supports_thinking is False
248
+
249
+
250
+ def test_chat_runner_answer_pass_omits_tools_and_adapter_toggle(fake_transformers) -> None:
251
+ model = FakeChatModel(("The map ", "shows ten projects."))
252
+ runner = chat_runner_with(model)
253
+
254
+ pieces = list(
255
+ runner.stream(
256
+ [
257
+ {"role": "user", "content": "what is everyone building"},
258
+ {"role": "assistant", "content": "", "tool_calls": []},
259
+ {"role": "tool", "content": "{}"},
260
+ ],
261
+ max_new_tokens=200,
262
+ )
263
+ )
264
+
265
+ assert "".join(piece for _count, piece in pieces) == "The map shows ten projects."
266
+ assert model.lock_was_held == [True]
267
+ template_call = runner._planner._tokenizer.template_call
268
+ assert "tools" not in template_call
269
+
270
+
271
+ def test_chat_runner_surfaces_generation_errors(fake_transformers) -> None:
272
+ class ExplodingModel(FakeChatModel):
273
+ def generate(self, **kwargs) -> None:
274
+ kwargs["streamer"].end()
275
+ raise RuntimeError("boom")
276
+
277
+ runner = chat_runner_with(ExplodingModel(()))
278
+
279
+ with pytest.raises(RuntimeError, match="boom"):
280
+ list(runner.stream([{"role": "user", "content": "hi"}], max_new_tokens=10))
281
+ assert generation_lock().locked() is False
282
+
283
+
284
+ def test_early_close_releases_generation_lock(fake_transformers) -> None:
285
+ model = FakeChatModel(("tok1 ", "tok2 ", "tok3 ", "tok4 ", "tok5"))
286
+ runner = chat_runner_with(model)
287
+ stream = runner.stream([{"role": "user", "content": "hi"}], max_new_tokens=32)
288
+
289
+ next(stream) # consume one piece then abandon mid-stream
290
+ stream.close()
291
+
292
+ assert generation_lock().locked() is False
293
+
294
+
295
+ def test_rule_chat_runner_escapes_xml_special_characters() -> None:
296
+ runner = RuleBasedChatRunner()
297
+
298
+ output = "".join(
299
+ piece
300
+ for _count, piece in runner.stream(
301
+ [{"role": "user", "content": "find projects about A & B <robots>"}],
302
+ tools=[{"type": "function"}],
303
+ max_new_tokens=96,
304
+ )
305
+ )
306
+
307
+ call = parse_native_tool_call(output)
308
+ assert call.name == "search_projects"
309
+ assert call.arguments["query"] == "find projects about A & B <robots>"
310
+
311
+
312
+ def test_rule_chat_runner_routes_tools_pass_through_intents() -> None:
313
+ runner = RuleBasedChatRunner()
314
+
315
+ output = "".join(
316
+ piece
317
+ for _count, piece in runner.stream(
318
+ [{"role": "user", "content": "who completed the most quests"}],
319
+ tools=[{"type": "function"}],
320
+ max_new_tokens=96,
321
+ )
322
+ )
323
+
324
+ call = parse_native_tool_call(output)
325
+ assert call.name == "top_projects_by_quests"
326
+
327
+
328
+ def test_rule_chat_runner_answer_pass_is_deterministic() -> None:
329
+ runner = RuleBasedChatRunner()
330
+
331
+ output = "".join(
332
+ piece
333
+ for _count, piece in runner.stream(
334
+ [{"role": "user", "content": "hi"}, {"role": "tool", "content": "{}"}],
335
+ max_new_tokens=200,
336
+ )
337
+ )
338
+
339
+ assert "verified data" in output
340
+
341
+
342
+ def test_create_chat_runner_matches_advisor_backend() -> None:
343
+ minicpm = MiniCPMTransformersPlanner("openbmb/MiniCPM5-1B")
344
+
345
+ assert isinstance(create_chat_runner(minicpm), MiniCPMChatRunner)
346
+ assert isinstance(create_chat_runner(RuleBasedPlanner()), RuleBasedChatRunner)
347
+
348
+
349
+ def test_base_model_context_is_null_without_adapter() -> None:
350
+ planner = MiniCPMTransformersPlanner("openbmb/MiniCPM5-1B", adapter_id="")
351
+ planner._model = FakeChatModel(())
352
+
353
+ with planner.base_model_context():
354
+ pass # no adapter -> nullcontext, nothing to toggle
355
+
356
+
357
  def test_rule_planner_emits_valid_search_call() -> None:
358
  planner = RuleBasedPlanner()
359