case0 / src /case_zero /generator /assemble.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
"""Deterministic assembly: stage outputs + structural scaffold -> a solvable CaseFile.
Python owns only the structure (who is guilty, who was where, which clue breaks the
alibi); all names, prose, secrets, motives, and reveals come from the model's stage
outputs. Built this way, the result passes the solver by construction.
"""
from __future__ import annotations
import re
from ..constants import DAY_MINUTES
from ..schemas.case import (
AlibiLie,
CaseFile,
Culprit,
GenerationKnobs,
Motive,
Setting,
Solution,
Victim,
Weapon,
)
from ..schemas.clue import Clue, Fact
from ..schemas.enums import DiscoveryMethod, SubjectType
from ..schemas.suspect import (
AnchoredLie,
PersonalityAxes,
Suspect,
)
from ..schemas.timeline import (
AlibiSegment,
Location,
StatedAlibi,
TimeWindow,
WhereaboutsSegment,
)
from ..schemas.visual import VisualDescriptor
from .stages import MysteryOut, WorldCastOut
_FALLBACK_ALIBI = "All right - I stepped out for a moment, but I had nothing to do with this."
# Evidence sanitiser. A small model often writes a confession ("I killed him") or names the
# culprit/victim in a clue, which spoils the mystery. Such clues are detected and replaced
# with a natural physical trace, so evidence always reads like a real case. Good model
# output passes through untouched.
_CONFESSION_RE = re.compile(
r"\b(i|i'?m|i'?ve|i'?d|my|me|we|us|murder|murdered|killed|kill|killing|slain|stabbed|"
r"planned|revenge|confess|culprit|victim|the weapon|the murder)\b",
re.IGNORECASE,
)
# Scene traces that quietly place the culprit at the crime - never naming anyone.
_TRACE_NAMES = (
"Partial fingerprint", "Smudged tumbler", "Snagged thread", "Damp footprints",
"Dropped cufflink", "Scuffed floorboard", "Stubbed cigarette", "Stopped clock",
)
_TRACE_REVEALS = (
"A fresh partial fingerprint on the {weapon}, unaccounted for among the guests.",
"A tumbler left where the body fell, its rim marked with a recent lip-print.",
"A torn thread of dark cloth snagged on the {room} doorframe.",
"Half-dried footprints crossing the {room}, left around the time of death.",
"A monogrammed cufflink wedged under the rug, dropped in haste.",
"A freshly scuffed floorboard where someone braced against a struggle.",
"A cigarette stubbed out mid-smoke - and not the victim's brand.",
"A mantel clock knocked still at the very minute of death.",
)
# Personal items that hint at an innocent's hidden secret - never about the murder.
_HERRING_NAMES = (
"Crumpled letter", "Hidden receipt", "Old photograph", "Pawn ticket", "Ticket stub",
)
_HERRING_REVEALS = (
"A crumpled letter hinting at a debt they never mentioned.",
"A receipt for something they swore they never bought.",
"A worn photograph they would rather no one had seen.",
"A pawn ticket for a family heirloom, recently surrendered.",
"A ticket stub placing them somewhere they had denied being.",
)
def _unnatural(text: str, banned: tuple[str, ...]) -> bool:
if not text or not text.strip() or _CONFESSION_RE.search(text):
return True
low = text.lower()
return any(b and b.lower() in low for b in banned)
def _natural_clue(name: str, reveal: str, *, herring: bool, seed: int, weapon: str,
room: str, banned: tuple[str, ...]) -> tuple[str, str]:
"""Keep the model's (name, reveal) when it reads like real physical evidence; otherwise
return a seeded natural fallback so no confession or name reaches the player."""
if not _unnatural(reveal, banned) and not _unnatural(name, banned):
return name.strip(), reveal.strip()
names = _HERRING_NAMES if herring else _TRACE_NAMES
reveals = _HERRING_REVEALS if herring else _TRACE_REVEALS
idx = seed % len(reveals)
return names[idx], reveals[idx].format(weapon=(weapon or "weapon").lower(),
room=(room or "room").lower())
def _clamp_index(index: int, count: int) -> int:
return max(0, min(index, count - 1))
def _locations(world: WorldCastOut) -> tuple[Location, ...]:
locs = []
for i, loc in enumerate(world.locations):
loc_id = f"L{i + 1}"
# Hub-and-spoke connectivity off the first room keeps the map connected.
adjacent = ("L1",) if i != 0 else tuple(f"L{j + 1}" for j in range(1, len(world.locations)))
locs.append(Location(loc_id=loc_id, name=loc, description="", adjacent_to=adjacent))
return tuple(locs)
# Distinct coat accents so generated suspects look different from each other.
_ACCENTS: tuple[str, ...] = ("#b8860b", "#3a6ea5", "#9a9aa0", "#6b8f71", "#a4533a", "#7a6ca8")
# Distinct temperaments so each case has a confident one, a frightened one, a hostile
# one, etc. - guaranteed variety even when the small model rates everyone the same. Each
# is (composure, aggression, evasiveness, demeanour, nervous tell). Consecutive suspects
# get consecutive (distinct) entries; a per-case offset rotates them for variety.
_TEMPERAMENTS: tuple[tuple[float, float, float, str, str], ...] = (
(0.88, 0.30, 0.35, "cool and self-assured, almost amused by the questioning", "a faint knowing smile"),
(0.20, 0.30, 0.70, "visibly frightened and on edge, dreading every question", "trembling hands"),
(0.55, 0.88, 0.40, "hostile and defensive, bristling at any hint of suspicion", "a clenched jaw"),
(0.72, 0.40, 0.30, "composed and cooperative on the surface, carefully measured", "a too-steady voice"),
(0.32, 0.55, 0.68, "rattled and evasive, voice tightening under pressure", "darting eyes"),
(0.60, 0.25, 0.55, "guarded and weary, giving away as little as possible", "long, careful pauses"),
)
def _temperament(seed: int, index: int) -> tuple[float, float, float, str, str]:
return _TEMPERAMENTS[(seed + index) % len(_TEMPERAMENTS)]
# Small models lean hard on "Whispers/Shadows/Midnight..." titles. When the model produces
# one (or an empty title), swap in a deterministic, case-specific title so no two cases
# share the same cliche.
_TITLE_BANNED = re.compile(r"whisper|shadow|midnight|\bdark|secret|silen|echo|veil|\bnight\b", re.IGNORECASE)
def _fresh_title(raw: str, seed: int, setting: str, victim: str, room: str) -> str:
raw = (raw or "").strip()
if raw and not _TITLE_BANNED.search(raw):
return raw
last = victim.split()[-1] if victim else "the Victim"
templates = (
f"A Death in the {room}",
f"The {room} Affair",
f"Murder at {setting}",
f"The {last} File",
f"Blood in the {room}",
f"Last Call at {setting}",
f"The {setting} Killing",
f"The {room} Verdict",
)
return templates[seed % len(templates)]
def _visual(gen, index: int) -> VisualDescriptor: # type: ignore[no-untyped-def]
mood = "guarded" if gen.evasiveness >= 0.5 else "tense"
look = ", ".join(p for p in (gen.appearance, gen.attire) if p)
gender = "female" if (gen.gender or "").lower().startswith("f") else "male"
return VisualDescriptor(
subject_type=SubjectType.SUSPECT, palette="noir", mood=mood, gender=gender,
age_band=gen.age_band or None, attire=gen.attire or None,
accent_color=_ACCENTS[index % len(_ACCENTS)], prompt_hint=look,
)
def assemble_case(
*,
case_id: str,
seed: int,
knobs: GenerationKnobs,
world: WorldCastOut,
mystery: MysteryOut,
window: TimeWindow,
tod: TimeWindow,
culprit_idx: int,
crime_idx: int,
claimed_idx: int,
) -> CaseFile:
n = len(world.suspects)
n_loc = len(world.locations)
culprit_idx = _clamp_index(culprit_idx, n)
crime_idx = _clamp_index(crime_idx, n_loc)
claimed_idx = _clamp_index(claimed_idx, n_loc)
if claimed_idx == crime_idx:
claimed_idx = (crime_idx + 1) % n_loc
crime_loc = f"L{crime_idx + 1}"
claimed_loc = f"L{claimed_idx + 1}"
locations = _locations(world)
culprit_name = world.suspects[culprit_idx].name
# Spread evidence so EACH room yields its own clue(s): the key forensic breaker
# stays at the scene, every other clue is round-robined to a distinct other room.
non_crime_locs = [i for i in range(n_loc) if i != crime_idx] or [crime_idx]
_spread = {"i": 0}
def _next_room() -> str:
idx = non_crime_locs[_spread["i"] % len(non_crime_locs)]
_spread["i"] += 1
return f"L{idx + 1}"
facts: list[Fact] = [
Fact(fact_id="F_scene", statement=f"{culprit_name} was in {locations[crime_idx].name} "
f"during the murder.", true_value=True, loc_id=crime_loc, at_min=tod.start_min),
]
banned = (culprit_name, world.victim_name)
weapon_name, crime_room_name = world.weapon_name, locations[crime_idx].name
b1_name, b1_reveal = _natural_clue(mystery.breaker_one_name, mystery.breaker_one_reveal,
herring=False, seed=seed, weapon=weapon_name,
room=crime_room_name, banned=banned)
b2_name, b2_reveal = _natural_clue(mystery.breaker_two_name, mystery.breaker_two_reveal,
herring=False, seed=(seed >> 3) + 1, weapon=weapon_name,
room=crime_room_name, banned=banned)
clues: list[Clue] = [
Clue(clue_id="C_b1", name=b1_name, reveal_text=b1_reveal,
discoverable_at_loc_id=crime_loc, discovery_method=DiscoveryMethod.FORENSIC,
supports_fact_id="F_scene", points_to_sus_id=f"S{culprit_idx + 1}",
contradicts_alibi_of=f"S{culprit_idx + 1}", weight=1.0),
Clue(clue_id="C_b2", name=b2_name, reveal_text=b2_reveal,
discoverable_at_loc_id=_next_room(), discovery_method=DiscoveryMethod.FORENSIC,
supports_fact_id="F_scene", points_to_sus_id=f"S{culprit_idx + 1}",
contradicts_alibi_of=f"S{culprit_idx + 1}", weight=0.7),
]
suspects: list[Suspect] = []
for i, gen in enumerate(world.suspects):
sus_id = f"S{i + 1}"
is_culprit = i == culprit_idx
secret_fact = f"F_sec{i + 1}"
facts.append(Fact(fact_id=secret_fact, statement=gen.secret, true_value=True))
if is_culprit:
whereabouts = (
WhereaboutsSegment(window=TimeWindow(start_min=window.start_min, end_min=tod.start_min),
loc_id=claimed_loc, activity="mingling in plain sight"),
WhereaboutsSegment(window=tod, loc_id=crime_loc,
activity="alone with the victim"),
)
alibi = StatedAlibi(claim_text=mystery.alibi_claim,
claimed_segments=(AlibiSegment(window=window, loc_id=claimed_loc),))
lies = (AnchoredLie(lie_id="LIE_alibi", topic="where you were during the murder",
claimed=mystery.alibi_claim, truth_ref="F_scene",
breaks_on=("C_b1", "C_b2"), fallback=_FALLBACK_ALIBI),)
must_lie = ("F_scene",)
else:
loc_idx = non_crime_locs[i % len(non_crime_locs)]
loc_id = f"L{loc_idx + 1}"
whereabouts = (WhereaboutsSegment(window=window, loc_id=loc_id,
activity="going about the evening"),)
alibi = StatedAlibi(claim_text=f"I was in {locations[loc_idx].name} the whole time.",
claimed_segments=(AlibiSegment(window=window, loc_id=loc_id),))
# Each innocent's exposing clue lives in its own room (round-robin), so the
# player gathers evidence room by room rather than all at once.
h_name, h_reveal = _natural_clue(gen.evidence_name, gen.evidence_reveal, herring=True,
seed=seed + i + 1, weapon=weapon_name,
room=locations[loc_idx].name, banned=banned)
clues.append(Clue(clue_id=f"C_h{i + 1}", name=h_name,
reveal_text=h_reveal, discoverable_at_loc_id=_next_room(),
discovery_method=DiscoveryMethod.SEARCH, supports_fact_id=secret_fact,
points_to_sus_id=sus_id, is_red_herring=True, weight=0.3))
lies = (AnchoredLie(lie_id=f"LIE_sec{i + 1}", topic=gen.secret[:48], claimed=gen.cover_story,
truth_ref=secret_fact, breaks_on=(f"C_h{i + 1}",),
fallback="Fine, that part is true - but it has nothing to do with the murder."),)
must_lie = (secret_fact,)
comp, aggr, evas, demeanour, temp_tell = _temperament(seed, i)
suspects.append(Suspect(
sus_id=sus_id, name=gen.name, role=gen.role,
persona_summary=gen.persona_summary, # player-facing: stays clean (no temperament)
demeanour=demeanour, # prompt-only; never shown to the player
is_culprit=is_culprit,
personality=PersonalityAxes(composure=comp, aggression=aggr, evasiveness=evas),
tells=(gen.tell or temp_tell,),
knows_facts=("F_scene", secret_fact) if is_culprit else (secret_fact,),
secrets=(gen.secret,),
true_whereabouts=whereabouts, stated_alibi=alibi, must_lie_about=must_lie,
anchored_lies=lies, visual=_visual(gen, i),
))
motive = Motive(motive_id="M1", category=mystery.motive_category, summary=mystery.motive_summary)
found_at_min = min(window.end_min + 5, DAY_MINUTES)
title = _fresh_title(world.title, seed, world.setting_name, world.victim_name,
locations[crime_idx].name)
return CaseFile(
case_id=case_id, seed=seed, title=title, briefing=world.briefing, knobs=knobs,
setting=Setting(name=world.setting_name, description=world.setting_description,
locations=locations, murder_window=window),
victim=Victim(vic_id="V1", name=world.victim_name, role=world.victim_role,
found_at_loc_id=crime_loc, found_at_min=found_at_min,
cause_of_death=world.cause_of_death, time_of_death=tod),
weapon=Weapon(weapon_id="W1", name=world.weapon_name, kind=world.weapon_kind,
origin_loc_id=crime_loc),
suspects=tuple(suspects),
culprit=Culprit(sus_id=f"S{culprit_idx + 1}", true_motive=motive,
method_narrative=mystery.method_narrative,
alibi_lie=AlibiLie(claimed_loc_id=claimed_loc, actual_loc_id=crime_loc,
contradicted_by_clue_ids=("C_b1", "C_b2"))),
facts=tuple(facts), clues=tuple(clues),
solution=Solution(culprit_sus_id=f"S{culprit_idx + 1}", weapon_id="W1", motive_id="M1",
minimal_clue_set=("C_b1",), deduction_chain=tuple(mystery.deduction_chain)),
)