case0 / src /case_zero /api /routes_run.py
HusseinEid's picture
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
@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")