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 | """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)), | |
| ) | |