"""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 @router.post("/{run_id}/interrogate/{sus_id}", response_model=InterrogateResult) 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) @router.get("/{run_id}/hint") 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" @router.post("/{run_id}/accuse") 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) @router.post("/{run_id}/tts/{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")