case0 / scripts /build_tutorial_case.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
14 kB
"""Author the hand-crafted tutorial case and write cases/seeds/tutorial.json.
The tutorial doubles as: the onboarding case that teaches the loop, a deterministic
demo case, and a golden test fixture. It is fully solvable by construction; the
solvability checker is run before it is written.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))
from case_zero.constants import SEED_CASES_DIR # noqa: E402
from case_zero.persistence.case_store import save_case # noqa: E402
from case_zero.schemas import ( # noqa: E402
AlibiLie,
AlibiSegment,
AnchoredLie,
CaseFile,
Clue,
Culprit,
Difficulty,
DiscoveryMethod,
Fact,
GenerationKnobs,
Location,
Motive,
MotiveCategory,
PersonalityAxes,
PhysicalCapability,
Relationship,
Setting,
Solution,
StatedAlibi,
Suspect,
TimeWindow,
Victim,
VisualDescriptor,
Weapon,
WhereaboutsSegment,
)
from case_zero.schemas.enums import SubjectType # noqa: E402
def m(hour: int, minute: int) -> int:
return hour * 60 + minute
WINDOW = TimeWindow(start_min=m(21, 0), end_min=m(22, 0))
def _locations() -> tuple[Location, ...]:
return (
Location(loc_id="L1", name="The Lounge", description="The main club floor.",
adjacent_to=("L2", "L3", "L5")),
Location(loc_id="L2", name="The Kitchen", adjacent_to=("L1",)),
Location(loc_id="L3", name="The Terrace", description="The open rooftop stage.",
adjacent_to=("L1",)),
Location(loc_id="L4", name="The Office", description="Vane's private office.",
adjacent_to=("L5",)),
Location(loc_id="L5", name="The Cloakroom", adjacent_to=("L1", "L4")),
)
def _facts() -> tuple[Fact, ...]:
return (
Fact(fact_id="F1", statement="Margot was in the office during the murder window.",
true_value=True, loc_id="L4", at_min=m(21, 30)),
Fact(fact_id="F2", statement="Margot smokes Gauloises cigarettes.", true_value=True),
Fact(fact_id="F3", statement="Eddie has been skimming from the till.", true_value=True),
Fact(fact_id="F4", statement="Lillian was secretly in love with Cornelius.", true_value=True),
Fact(fact_id="F5", statement="Boone falsified the club's ledgers.", true_value=True),
)
def _clues() -> tuple[Clue, ...]:
return (
Clue(clue_id="C1", name="Cloakroom ticket (9:48 PM)",
reveal_text="A cloakroom claim ticket stamped 9:48 PM lies near the body - someone "
"fetched a wrap mid-evening and ended up here.",
discoverable_at_loc_id="L4", discovery_method=DiscoveryMethod.FORENSIC,
supports_fact_id="F1", points_to_sus_id="S1", contradicts_alibi_of="S1", weight=1.0),
Clue(clue_id="C2", name="Gauloises ash",
reveal_text="Grey ash from a Gauloises cigarette on the office rug - an unusual brand.",
discoverable_at_loc_id="L4", discovery_method=DiscoveryMethod.FORENSIC,
supports_fact_id="F2", points_to_sus_id="S1", contradicts_alibi_of="S1", weight=0.7),
Clue(clue_id="C3", name="Doctored bar tab",
reveal_text="A bar ledger showing Eddie quietly forgiving his own tabs.",
discoverable_at_loc_id="L1", discovery_method=DiscoveryMethod.DOCUMENT,
supports_fact_id="F3", points_to_sus_id="S2", is_red_herring=True, weight=0.3),
Clue(clue_id="C4", name="Torn love letter",
reveal_text="A torn letter in Lillian's hand to Cornelius - equal parts love and fury.",
discoverable_at_loc_id="L5", discovery_method=DiscoveryMethod.SEARCH,
supports_fact_id="F4", points_to_sus_id="S3", is_red_herring=True, weight=0.3),
Clue(clue_id="C5", name="Altered ledger page",
reveal_text="A ledger page with figures scratched out and rewritten in Boone's hand.",
discoverable_at_loc_id="L4", discovery_method=DiscoveryMethod.DOCUMENT,
supports_fact_id="F5", points_to_sus_id="S4", is_red_herring=True, weight=0.3),
)
def _visual(age: str, attire: str, mood: str, accent: str, gender: str) -> VisualDescriptor:
return VisualDescriptor(subject_type=SubjectType.SUSPECT, palette="noir", age_band=age,
attire=attire, mood=mood, accent_color=accent, gender=gender)
def _suspects() -> tuple[Suspect, ...]:
margot = Suspect(
sus_id="S1", name="Margot Vane", role="the widow and co-owner",
persona_summary="Poised and imperious in mourning black; clipped, controlled speech, and "
"she resents every question.",
is_culprit=True,
physical_capability=PhysicalCapability(strength=True, mobility=True),
personality=PersonalityAxes(composure=0.8, aggression=0.5, evasiveness=0.7),
tells=("a too-steady voice", "smoothing her gloves"),
knows_facts=("F1", "F2"),
secrets=("Cornelius was about to cut you out of the club; his death secures everything.",),
true_whereabouts=(
WhereaboutsSegment(window=TimeWindow(start_min=m(21, 0), end_min=m(21, 20)),
loc_id="L1", activity="greeting patrons in the lounge",
co_present_sus_ids=("S2",)),
WhereaboutsSegment(window=TimeWindow(start_min=m(21, 20), end_min=m(21, 50)),
loc_id="L4", activity="confronting Cornelius in the office"),
WhereaboutsSegment(window=TimeWindow(start_min=m(21, 50), end_min=m(22, 0)),
loc_id="L5", activity="composing herself in the cloakroom"),
),
stated_alibi=StatedAlibi(
claim_text="I was in the lounge the entire evening, in plain sight of a dozen guests.",
claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L1", witness_sus_ids=("S2",)),),
),
must_lie_about=("F1",),
anchored_lies=(
AnchoredLie(
lie_id="LIE_alibi", topic="where you were during the murder",
claimed="I never once left the lounge; ask any of the patrons.",
truth_ref="F1", breaks_on=("C1", "C2"),
fallback="Fine - I stepped out to the cloakroom for my wrap, that is all. I never "
"went near the office.",
),
),
visual=_visual("50s", "a black beaded evening gown", "imperious", "#b8860b", "female"),
)
eddie = Suspect(
sus_id="S2", name="Eddie Marsh", role="the bartender",
persona_summary="Quick and affable, sweats easily, and keeps glancing at the till.",
personality=PersonalityAxes(composure=0.3, aggression=0.2, evasiveness=0.6),
tells=("wiping a bar that is already clean",),
knows_facts=("F3",),
secrets=("You have been skimming from the till for months.",),
true_whereabouts=(
WhereaboutsSegment(window=WINDOW, loc_id="L1", activity="tending the bar",
co_present_sus_ids=("S1", "S4")),
),
stated_alibi=StatedAlibi(
claim_text="I never left the bar; somebody always wanted a drink.",
claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L1", witness_sus_ids=("S1", "S4")),),
),
must_lie_about=("F3",),
anchored_lies=(
AnchoredLie(lie_id="LIE_till", topic="the till and the money",
claimed="The till always comes up square; I would never touch it.",
truth_ref="F3", breaks_on=("C3",),
fallback="All right, I have borrowed against tips. It has nothing to do "
"with Cornelius."),
),
visual=_visual("30s", "shirtsleeves and a bar apron", "nervous", "#3a6ea5", "male"),
)
lillian = Suspect(
sus_id="S3", name="Lillian Frost", role="the headline singer",
persona_summary="Theatrical and guarded about her private life, mourning beneath the glamour.",
personality=PersonalityAxes(composure=0.6, aggression=0.4, evasiveness=0.5),
tells=("a brittle laugh",),
knows_facts=("F4",),
secrets=("You were secretly in love with Cornelius and quarreled with him.",),
true_whereabouts=(
WhereaboutsSegment(window=WINDOW, loc_id="L3", activity="performing two sets on the terrace"),
),
stated_alibi=StatedAlibi(
claim_text="I was on the terrace, singing, the whole hour. Two hundred people saw me.",
claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L3"),),
),
must_lie_about=("F4",),
anchored_lies=(
AnchoredLie(lie_id="LIE_affair", topic="your relationship with Cornelius",
claimed="Cornelius and I were strictly professional.",
truth_ref="F4", breaks_on=("C4",),
fallback="We were close. I loved him. But I would never hurt him."),
),
visual=_visual("20s", "a silver sequined stage dress", "guarded", "#9a9aa0", "female"),
)
boone = Suspect(
sus_id="S4", name="Septimus Boone", role="the club's accountant",
persona_summary="Meticulous and nervous, speaks in numbers, and is terrified of an audit.",
personality=PersonalityAxes(composure=0.4, aggression=0.2, evasiveness=0.7),
tells=("polishing his spectacles",),
knows_facts=("F5",),
secrets=("You falsified the ledgers to hide your own embezzlement.",),
true_whereabouts=(
WhereaboutsSegment(window=WINDOW, loc_id="L1", activity="going over receipts at a corner table",
co_present_sus_ids=("S2",)),
),
stated_alibi=StatedAlibi(
claim_text="I sat at my usual corner table in the lounge with my books all night.",
claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L1", witness_sus_ids=("S2",)),),
),
must_lie_about=("F5",),
anchored_lies=(
AnchoredLie(lie_id="LIE_ledger", topic="the ledgers and the books",
claimed="The books are in perfect order, I assure you.",
truth_ref="F5", breaks_on=("C5",),
fallback="There may be irregularities - mine, not murder. I was nowhere "
"near him."),
),
visual=_visual("40s", "a rumpled three-piece suit", "anxious", "#6b8f71", "male"),
)
return (margot, eddie, lillian, boone)
def build_tutorial_case() -> CaseFile:
return CaseFile(
case_id="tutorial-gilded-aerie",
seed=1989,
title="The Gilded Aerie",
briefing=(
"1924. On the rooftop of the Gilded Aerie supper club, owner Cornelius Vane is found "
"dead in his office, struck down with his own brass award. The club was full; everyone "
"had a reason to resent him. Four people could have slipped away during the murder hour. "
"Question them, search the rooms, and present what you find. One of them is lying about "
"where they were - catch that lie, and you have your killer."
),
knobs=GenerationKnobs(setting_hint="rooftop supper club", era_hint="1924",
tone_hint="jazz-age noir", n_suspects=4, n_red_herrings=3,
difficulty=Difficulty.GENTLE),
setting=Setting(name="The Gilded Aerie", description="A rooftop supper club above the city.",
locations=_locations(), murder_window=WINDOW),
victim=Victim(vic_id="V1", name="Cornelius Vane", role="club owner", found_at_loc_id="L4",
found_at_min=m(22, 5), cause_of_death="blunt force from a brass statuette",
time_of_death=TimeWindow(start_min=m(21, 20), end_min=m(21, 50))),
weapon=Weapon(weapon_id="W1", name="brass award statuette", kind="blunt object",
origin_loc_id="L4", requires_strength=False, leaves_trace="blood and a dented base"),
suspects=_suspects(),
culprit=Culprit(
sus_id="S1",
true_motive=Motive(motive_id="M1", category=MotiveCategory.GREED,
summary="Cornelius was about to cut Margot out of the club; his death "
"secures her inheritance."),
method_narrative="You followed Cornelius to the office, argued about the will, and "
"struck him with the brass statuette.",
alibi_lie=AlibiLie(claimed_loc_id="L1", actual_loc_id="L4",
contradicted_by_clue_ids=("C1", "C2")),
),
relationships=(
Relationship(from_sus_id="S1", to_sus_id="S2", kind="employer of", sentiment=0.1),
Relationship(from_sus_id="S4", to_sus_id="S2", kind="friendly with", sentiment=0.3),
),
facts=_facts(),
clues=_clues(),
solution=Solution(
culprit_sus_id="S1", weapon_id="W1", motive_id="M1", minimal_clue_set=("C1",),
deduction_chain=(
"Cornelius was killed in the office between 9:20 and 9:50.",
"Every suspect but Margot has a witness for that window.",
"Margot swears she never left the lounge - yet her cloakroom ticket, stamped 9:48, "
"was found beside the body.",
"Her alibi is a lie; she was in the office. Margot Vane is the killer.",
),
),
)
def main() -> None:
case = build_tutorial_case()
out = SEED_CASES_DIR / "tutorial.json"
save_case(case, out)
print(f"wrote {out} ({len(case.suspects)} suspects, {len(case.clues)} clues)")
if __name__ == "__main__":
main()