case0 / src /case_zero /api /case_adapter.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
raw
history blame
9.08 kB
"""Project a generated ``CaseFile`` into the PUBLIC wire view (the same contract the
golden case uses). Deterministic: it synthesizes the display surface from the case's
own fields. Nothing from the sealed solution (culprit, true motive id, breaker chain)
is exposed; the true motive's *text* appears only as one option among the decoys.
Generated evidence renders as paper exhibits (reveal_text); richer per-type payloads
(threads, keycard tables, voicemail) and authored tags/quotes are a later enhancement.
"""
from __future__ import annotations
from ..schemas.case import CaseFile
from ..schemas.clue import Clue
from .public_view import (
FlashbackAccount,
PublicCase,
PublicEvidence,
PublicFlashback,
PublicMotive,
PublicSuspect,
PublicVictim,
StoryBeat,
SuggestedQuestion,
TimelineBeat,
)
from .questions import FIXED_QUESTIONS
_SCENES = ("skyline", "atrium", "interro", "seawall", "mezzanine", "desk")
_WEATHER = (
"Rain, and a wind off the water.", "Fog thick enough to lean on.",
"A dry cold that gets into the joints.", "Sleet against every window.",
)
_GREETS = (
"Detective. Let's get this over with.",
"I already told the others everything I know.",
"Ask what you came to ask.",
"I have nothing to hide, if that's what you think.",
)
_APPARENT = (
"Was close enough to the victim to have a reason - and a chance.",
"Had more to lose tonight than they're letting on.",
"Their story has a seam in it, if you pull the right thread.",
"Stood to gain from a death no one saw coming.",
)
_DECOY_MOTIVES = (
"To bury a debt that was about to surface.",
"Jealousy that had festered for years.",
"To keep a buried secret buried.",
"To settle an old score, finally.",
"Fear of being exposed and ruined.",
)
_ICONS = ("photoEv", "receipt", "keycard", "compass", "phone", "cctv")
# Noir city names so CITY reads as an actual city, not the venue (the venue goes in SCENE).
_CITIES = (
"Graymoor", "Blackport", "Ashmoor", "Harrowgate", "Duskwater", "Coldhaven",
"Ravenreach", "Mortlake", "Greyharbor", "Thornehaven", "Mistport", "Hollowmere",
"Saltmarsh", "Ironhaven", "Blackwater", "Fellgate",
)
def _hash(s: str) -> int:
h = 0
for ch in s:
h = (h * 31 + ord(ch)) & 0x7FFFFFFF
return h
def _clock(minute: int) -> str:
h = (minute // 60) % 24
m = minute % 60
suffix = "AM" if h < 12 else "PM"
h12 = h % 12 or 12
return f"{h12}:{m:02d} {suffix}"
def _loc_name(case: CaseFile, loc_id: str) -> str:
for loc in case.setting.locations:
if loc.loc_id == loc_id:
return loc.name
return loc_id
def _suspect_public(case: CaseFile, idx: int) -> PublicSuspect:
s = case.suspects[idx]
seed = _hash(s.sus_id)
role_word = (s.role.split()[-1] if s.role else "").strip(".,").upper()
tag = f"THE {role_word}" if role_word else "PERSON OF INTEREST"
quote = (s.persona_summary.split(".")[0] + ".") if s.persona_summary else "I've nothing to hide."
gender = "female" if ((s.visual.gender if s.visual else "") or "").lower().startswith("f") else "male"
return PublicSuspect(
id=s.sus_id,
name=s.name,
role=s.role,
age=30 + (seed % 35),
sprite=s.sus_id,
gender=gender,
tag=tag[:22],
baseline_suspicion=25 + (seed % 16),
motive=_APPARENT[idx % len(_APPARENT)],
alibi=s.stated_alibi.claim_text,
quote=quote,
greet=_GREETS[idx % len(_GREETS)],
suggested_questions=tuple(SuggestedQuestion(id=q, q=text) for q, text in FIXED_QUESTIONS),
)
def _evidence_public(case: CaseFile, clue: Clue, idx: int) -> PublicEvidence:
at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
time = _clock(at) if at is not None else _clock(case.setting.murder_window.start_min + 7 * (idx + 1))
return PublicEvidence(
id=clue.clue_id,
name=clue.name.upper(),
type=clue.discovery_method.value.upper(),
icon=_ICONS[idx % len(_ICONS)],
time=time,
found=f"Recovered from {_loc_name(case, clue.discoverable_at_loc_id)}.",
summary=clue.reveal_text,
detail=clue.reveal_text,
)
def _timeline(case: CaseFile) -> tuple[TimelineBeat, ...]:
beats: list[TimelineBeat] = []
w = case.setting.murder_window
beats.append(TimelineBeat(time=_clock(w.start_min), label="The evening is under way; everyone is in the house.", locked=True))
culprit = case.culprit.sus_id
for i, clue in enumerate(case.clues):
at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
t = at if at is not None else w.start_min + 7 * (i + 1)
conflict = clue.contradicts_alibi_of == culprit
beats.append(TimelineBeat(time=_clock(t), label=clue.reveal_text[:80], ev=clue.clue_id, conflict=conflict))
beats.append(TimelineBeat(time=_clock(case.victim.time_of_death.start_min), label=f"{case.victim.name} is killed. Time of death.", locked=True))
beats.sort(key=lambda b: b.time)
return tuple(beats)
def _flashback(case: CaseFile) -> PublicFlashback:
culprit = next((s for s in case.suspects if s.sus_id == case.culprit.sus_id), case.suspects[0])
claimed = _loc_name(case, case.culprit.alibi_lie.claimed_loc_id)
crime = _loc_name(case, case.victim.found_at_loc_id)
breakers = [c for c in case.clues if c.contradicts_alibi_of == culprit.sus_id][:3]
return PublicFlashback(
title="TWO ACCOUNTS",
a=FlashbackAccount(
who=f"{culprit.name} SAYS",
scene=claimed,
lines=(culprit.stated_alibi.claim_text, f"I was in {claimed} the whole time.", "I never went near it."),
flags=(),
),
b=FlashbackAccount(
who="THE EVIDENCE SAYS",
scene=crime,
lines=tuple(c.reveal_text[:90] for c in breakers) or ("The evidence places someone at the scene.",),
flags=tuple(range(len(breakers))),
),
)
def _motives(case: CaseFile, seed: int) -> tuple[PublicMotive, ...]:
true_text = case.culprit.true_motive.summary
decoys = [d for d in _DECOY_MOTIVES if d.lower() != true_text.lower()]
chosen = [PublicMotive(id="M1", text=true_text)]
for i in range(3):
chosen.append(PublicMotive(id=f"MD{i + 1}", text=decoys[(seed + i) % len(decoys)]))
# stable shuffle by seed so the true option isn't always first
rot = seed % len(chosen)
return tuple(chosen[rot:] + chosen[:rot])
def _story_beats(case: CaseFile) -> tuple[StoryBeat, ...]:
v = case.victim
return (
StoryBeat(scene="skyline", kicker=case.setting.name.upper(), title="The call", text=case.briefing),
StoryBeat(scene="atrium", kicker="THE VICTIM", title=v.name, text=f"{v.name}, {v.role}. {v.cause_of_death}"),
StoryBeat(scene="interro", kicker="THOSE WHO STAYED", title="Persons of interest",
text="Each of them had a reason to be here tonight, and a story you'll need to take apart."),
StoryBeat(scene="seawall", kicker="YOUR CASE NOW", title="Detective",
text="One of them is lying to your face. Find the crack in the account and follow it down."),
)
def casefile_to_public(case: CaseFile) -> PublicCase:
seed = case.seed
tod = case.victim.time_of_death.start_min
building = case.setting.name
room = _loc_name(case, case.victim.found_at_loc_id)
scene = f"{building}{room}" # building + room; the painter keys off the room
city = _CITIES[seed % len(_CITIES)]
return PublicCase(
id=case.case_id,
city=city,
district=building,
title=case.title,
tagline="Detective, your presence is required.",
weather=_WEATHER[seed % len(_WEATHER)],
victim=PublicVictim(name=case.victim.name, role=case.victim.role, age=40 + (seed % 30), sprite="victim", bio=case.briefing),
scene=scene,
tod=_clock(tod),
found=f"Found in {scene} at {_clock(case.victim.found_at_min)}.",
cause=case.victim.cause_of_death,
facts=(
("CITY", city),
("VICTIM", f"{case.victim.name}"),
("SCENE", scene),
("TIME OF DEATH", _clock(tod)),
("CAUSE", case.victim.cause_of_death),
("VERDICT", "Homicide"),
),
boot_lines=(
"The phone drags you up out of half a sleep.",
f"{case.setting.name}. A death no one saw coming.",
f"{case.victim.name} - {case.victim.role}.",
"They're holding the scene. It's yours now, detective.",
),
story_beats=_story_beats(case),
suspects=tuple(_suspect_public(case, i) for i in range(len(case.suspects))),
evidence=tuple(_evidence_public(case, c, i) for i, c in enumerate(case.clues)),
timeline=_timeline(case),
flashback=_flashback(case),
motives=_motives(case, seed),
)