Spaces:
Running on Zero
Running on Zero
deploy: sync GitHub main 07450c9
Browse filesDeploys 07450c98611aacaa07df903a778fada118aa3c7f.
Co-authored-by: Codex <noreply@openai.com>
- AGENTS.md +11 -0
- README.md +5 -0
- app.py +120 -1
- hackathon_advisor/dashboard_chat.py +643 -0
- hackathon_advisor/dashboard_chat_contracts.py +299 -0
- hackathon_advisor/dashboard_repository.py +337 -0
- hackathon_advisor/model_runtime.py +245 -31
- static/app.js +624 -0
- static/index.html +55 -0
- static/styles.css +406 -0
- tests/test_app.py +118 -0
- tests/test_dashboard_chat.py +487 -0
- tests/test_dashboard_chat_contracts.py +179 -0
- tests/test_dashboard_repository.py +247 -0
- tests/test_model_runtime.py +279 -0
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 |
-
|
| 244 |
-
self.
|
| 245 |
-
|
| 246 |
-
generation_kwargs = _minicpm_generation_kwargs(
|
| 247 |
inputs,
|
| 248 |
max_new_tokens=MAX_TOOL_CALL_TOKENS,
|
| 249 |
temperature=0.0,
|
| 250 |
-
|
| 251 |
)
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 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 |
|