Spaces:
Running
Running
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55 | """Per-run routes: interrogate (scripted golden engine), hint, and accuse (verdict). | |
| The sealed solution is read for the FIRST time at /accuse - never before. Suspicion is | |
| computed and held server-side; the client only displays it. | |
| """ | |
| from __future__ import annotations | |
| from fastapi import APIRouter, HTTPException | |
| from fastapi.responses import FileResponse | |
| from pydantic import BaseModel, ConfigDict, Field | |
| from ..persistence.golden import load_golden | |
| from ..persistence.run_store import get_run | |
| from ..schemas.suspect import VoiceAssignment | |
| from ..tts.assignment import assign_voice | |
| from .hints import hint_for | |
| from .questions import question_text | |
| from .runtime import RUNTIME | |
| from .scripted import CORNERED_DELTA, RATTLED_DELTA, scripted_turn | |
| from .tts_service import TTS, voice_seed | |
| router = APIRouter(prefix="/api/run", tags=["run"]) | |
| class _Camel(BaseModel): | |
| model_config = ConfigDict(populate_by_name=True) | |
| class InterrogateBody(_Camel): | |
| question_id: str | None = Field(default=None, alias="questionId") | |
| free_text: str | None = Field(default=None, alias="freeText") | |
| present_evidence_id: str | None = Field(default=None, alias="presentEvidenceId") | |
| class Flags(_Camel): | |
| rattled: bool = False | |
| contradiction_exposed: bool = Field(default=False, alias="contradictionExposed") | |
| cornered: bool = False | |
| class InterrogateResult(_Camel): | |
| reply: str | |
| suspicion_delta: int = Field(alias="suspicionDelta") | |
| suspicion: int | |
| flags: Flags | |
| def interrogate(run_id: str, sus_id: str, body: InterrogateBody) -> InterrogateResult: | |
| # Live (generated) run -> the in-process LLM engine. | |
| live = RUNTIME.get(run_id) | |
| if live is not None: | |
| if not any(s.sus_id == sus_id for s in live.case.suspects): | |
| raise HTTPException(status_code=404, detail="suspect not found") | |
| if body.present_evidence_id is not None: | |
| question = body.free_text or "Explain this to me." | |
| clue_id = body.present_evidence_id | |
| elif body.question_id is not None: | |
| question = question_text(body.question_id) or "Tell me what happened." | |
| clue_id = None | |
| else: | |
| question = body.free_text or "Tell me what happened." | |
| clue_id = None | |
| out = RUNTIME.interrogate_live(live, sus_id, question, clue_id) | |
| return InterrogateResult( | |
| reply=out["reply"], | |
| suspicion_delta=out["suspicionDelta"], | |
| suspicion=out["suspicion"], | |
| flags=Flags(**out["flags"]), | |
| ) | |
| # Scripted (golden) run. | |
| run = get_run(run_id) | |
| if run is None: | |
| raise HTTPException(status_code=404, detail="run not found") | |
| golden = load_golden(run.case_id) | |
| try: | |
| reply, delta = scripted_turn( | |
| golden, | |
| sus_id, | |
| question_id=body.question_id, | |
| present_evidence_id=body.present_evidence_id, | |
| free_text=body.free_text, | |
| ) | |
| except KeyError as exc: | |
| raise HTTPException(status_code=404, detail="suspect not found") from exc | |
| cur = run.suspicion.get(sus_id, 0) | |
| new_value = max(0, min(100, cur + delta)) | |
| run.suspicion[sus_id] = new_value | |
| flags = Flags( | |
| rattled=delta >= RATTLED_DELTA, | |
| contradiction_exposed=delta >= RATTLED_DELTA, | |
| cornered=delta >= CORNERED_DELTA, | |
| ) | |
| return InterrogateResult(reply=reply, suspicion_delta=delta, suspicion=new_value, flags=flags) | |
| def hint(run_id: str, screen: str = "") -> dict[str, str]: | |
| live = RUNTIME.get(run_id) | |
| if live is not None: | |
| suspicion = {s.id: RUNTIME._suspicion(live, s.id) for s in live.public.suspects} | |
| return {"hint": hint_for(screen, suspicion)} | |
| run = get_run(run_id) | |
| if run is None: | |
| raise HTTPException(status_code=404, detail="run not found") | |
| return {"hint": hint_for(screen, run.suspicion)} | |
| class AccuseBody(_Camel): | |
| suspect_id: str = Field(alias="suspectId") | |
| motive_id: str = Field(alias="motiveId") | |
| evidence_ids: list[str] = Field(default_factory=list, alias="evidenceIds") | |
| def _suspect_name(golden: dict, sus_id: str) -> str: | |
| for s in golden["suspects"]: | |
| if s["id"] == sus_id: | |
| return s["name"] | |
| return "the accused" | |
| def accuse(run_id: str, body: AccuseBody) -> dict: | |
| live = RUNTIME.get(run_id) | |
| if live is not None: | |
| return RUNTIME.accuse_live(live, body.suspect_id, body.motive_id, body.evidence_ids) | |
| run = get_run(run_id) | |
| if run is None: | |
| raise HTTPException(status_code=404, detail="run not found") | |
| golden = load_golden(run.case_id) | |
| sealed = golden["sealed"] | |
| killer_id = sealed["killer"] | |
| killer_name = _suspect_name(golden, killer_id) | |
| killer_correct = body.suspect_id == killer_id | |
| motive_correct = body.motive_id == sealed["correctMotive"] | |
| key = set(sealed.get("keyEvidence", [])) | |
| hits = len(key.intersection(body.evidence_ids)) | |
| # Graded: killer is the bulk; motive + a complete evidence chain round it out. | |
| points = (60 if killer_correct else 0) | |
| points += 20 if motive_correct else 0 | |
| points += round((hits / len(key)) * 20) if key else 0 | |
| if killer_correct: | |
| truth = sealed.get("truth") or f"It was {killer_name}. The evidence held." | |
| else: | |
| accused = _suspect_name(golden, body.suspect_id) | |
| truth = ( | |
| f"You charged {accused}. The case held for a night - but the keycard, the camera, " | |
| f"and the voicemail all led past {accused} to {killer_name}, who walked out into the rain." | |
| ) | |
| return { | |
| "correct": killer_correct, | |
| "verdict": { | |
| "stamp": "CASE CLOSED" if killer_correct else "MISTRIAL", | |
| "killerId": killer_id, | |
| "killerName": killer_name, | |
| "truth": truth, | |
| }, | |
| "score": { | |
| "points": points, | |
| "max": 100, | |
| "killerCorrect": killer_correct, | |
| "motiveCorrect": motive_correct, | |
| "evidenceHits": hits, | |
| }, | |
| "stats": [], | |
| } | |
| class TtsBody(_Camel): | |
| text: str = "" | |
| def _voice_for(run_id: str, sus_id: str) -> VoiceAssignment: | |
| live = RUNTIME.get(run_id) | |
| if live is not None: | |
| for s in live.case.suspects: | |
| if s.sus_id == sus_id: | |
| return assign_voice(s) | |
| run = get_run(run_id) | |
| if run is not None: | |
| golden = load_golden(run.case_id) | |
| s = next((x for x in golden["suspects"] if x["id"] == sus_id), None) | |
| female = bool(s and (s.get("gender", "") or "").lower().startswith("f")) | |
| return voice_seed(sus_id, female=female) | |
| return voice_seed(sus_id) | |
| def tts(run_id: str, sus_id: str, body: TtsBody) -> FileResponse: | |
| """Synthesize the suspect's spoken reply with their on-device Supertonic voice.""" | |
| path = TTS.synth(body.text, _voice_for(run_id, sus_id)) | |
| if path is None: | |
| raise HTTPException(status_code=503, detail="voice unavailable") | |
| return FileResponse(path, media_type="audio/wav") | |