Spaces:
Running
Running
File size: 15,014 Bytes
414dc55 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 | """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)),
)
|