Vagueness-reactive detectives: branched question banks, prompt steering, stonewall rule
Browse filesThe precinct now has procedures for fog. A deterministic vagueness read on each
answer (hedges, length, checkable-detail heuristics) drives three layers:
1. Branched fallback banks per detective — fq_specific (drilling questions,
{C}-quoting), fq_vague (the fog itself becomes the subject, {A} quotes the
player's own vague answer back at them), fq_streak (2+ vague in a row:
Gimlet goes off the laminate, the Inspector goes glacial). Plus vague_waits
decks (the room reacting to fog) and per-character one-word callouts.
2. Prompt steering — the model is told whether the last answer was vague,
stonewalling (streak), or detailed, and how to play each.
3. A gameplay rule with noir logic: stonewall him 3+ times and the accuse
gate loosens — he stops waiting for evidence and goes with his gut.
Vague flags recorded per turn + behavior stats in the dataset record.
Writers-room material (4 lenses + showrunner): Form 12-B, "{A} is not an
answer, it's weather", "The rain has more content", Bellbird recurrences.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md6C7wMyiQuBeiDR1M9x25
- app.py +202 -32
- tests/drive_mock.py +41 -1
|
@@ -178,15 +178,51 @@ CHARACTERS = {
|
|
| 178 |
"He sits, stands, moves the chair one inch closer, sits again. Ready.",
|
| 179 |
"\"Right.\" He looks at your three claims like they owe him money.",
|
| 180 |
],
|
| 181 |
-
"
|
| 182 |
-
"{C}.
|
| 183 |
-
"
|
| 184 |
-
"What
|
| 185 |
-
"
|
| 186 |
-
"{C}
|
| 187 |
-
"
|
| 188 |
-
"
|
| 189 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
],
|
| 191 |
},
|
| 192 |
"marlowe": {
|
|
@@ -241,15 +277,51 @@ CHARACTERS = {
|
|
| 241 |
"The sound of a great weight consenting, slowly, to be vertical.",
|
| 242 |
"The door opens. He enters with the file already open, as if he was never asleep.",
|
| 243 |
],
|
| 244 |
-
"
|
| 245 |
-
"
|
| 246 |
-
"
|
| 247 |
-
"
|
| 248 |
-
"{C}.
|
| 249 |
-
"
|
| 250 |
-
"
|
| 251 |
-
"
|
| 252 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
],
|
| 254 |
},
|
| 255 |
}
|
|
@@ -789,12 +861,28 @@ def _detective_prompt(char_key, claims, qa, observer_json, force_accuse):
|
|
| 789 |
pacing = "The clock is ticking. If the picture is clear, accuse."
|
| 790 |
task = (f"You have asked {n} of {MAX_QUESTIONS} questions. {pacing}\n\n"
|
| 791 |
+ DETECTIVE_JSON_SPEC)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 792 |
observer = ""
|
| 793 |
if observer_json:
|
| 794 |
observer = ("\n\nCONFIDENTIAL OBSERVER NOTES from the observation room "
|
| 795 |
"(the suspect cannot hear this):\n" + observer_json)
|
| 796 |
system = (
|
| 797 |
-
f"{c['voice']}\n\n"
|
| 798 |
"You are playing Two Truths and a Lie. The suspect filed three claims about "
|
| 799 |
"themselves; EXACTLY ONE is false. Interrogate with short, concrete questions "
|
| 800 |
"(under 25 words) about places, times, people, what happened right before or "
|
|
@@ -987,6 +1075,51 @@ def _norm_letter(v, fallback="B"):
|
|
| 987 |
return _scan_letter(v) or fallback
|
| 988 |
|
| 989 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 990 |
# ── claims are spoken as WORDS, never letters ────────────────────────────────
|
| 991 |
# Players don't remember which claim was "B". The letters exist only as JSON
|
| 992 |
# indices and small visual tags; every spoken reference quotes the claim itself.
|
|
@@ -1101,15 +1234,20 @@ def parse_analyst(raw):
|
|
| 1101 |
return out
|
| 1102 |
|
| 1103 |
|
| 1104 |
-
def _accuse_allowed(n_answered, det_conf, analyst_profile):
|
| 1105 |
-
"""Spec pacing: threshold decay + snap-accuse guard.
|
|
|
|
|
|
|
| 1106 |
if n_answered < MIN_Q_BEFORE_ACCUSE:
|
| 1107 |
return False
|
| 1108 |
pct = CONF_PCT[det_conf]
|
| 1109 |
thresh = ACCUSE_THRESHOLD.get(n_answered, ACCUSE_FLOOR)
|
|
|
|
|
|
|
|
|
|
| 1110 |
if pct < thresh:
|
| 1111 |
return False
|
| 1112 |
-
if n_answered < 8:
|
| 1113 |
susp = max(SUSP_PCT[analyst_profile.get(f"suspicion_{k}", "low")] for k in "ABC") \
|
| 1114 |
if analyst_profile else 0
|
| 1115 |
if susp < SNAP_GUARD_MIN_ANALYST:
|
|
@@ -1176,7 +1314,10 @@ def build_record(st):
|
|
| 1176 |
"claims": claims,
|
| 1177 |
"labels": {"lie": st["lie_label"], "truths": truths},
|
| 1178 |
"conversation": [{"turn": e["turn"], "remark": e.get("remark", ""),
|
| 1179 |
-
"question": e["q"], "answer": e.get("a")
|
|
|
|
|
|
|
|
|
|
| 1180 |
"hidden_analysis": st["hidden_analysis"],
|
| 1181 |
"guess": {"guesser": "model",
|
| 1182 |
"selected_lie": acc.get("lie"),
|
|
@@ -1267,11 +1408,19 @@ def _stamp_html(n):
|
|
| 1267 |
f'<div class="track"><i style="width:{pct}%"></i></div></div>')
|
| 1268 |
|
| 1269 |
|
| 1270 |
-
def _next_wait_line(st, escalated):
|
| 1271 |
-
"""No-repeat wait-line deck per character per session; escalation lines gated
|
|
|
|
| 1272 |
key = st["model_key"]
|
| 1273 |
char = CHARACTERS[key]
|
| 1274 |
used = st["session"]["used_waits"][key]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1275 |
deck = char["waits_escalation"] if escalated else char["waits"]
|
| 1276 |
fresh = [w for w in deck if w not in used]
|
| 1277 |
if not fresh:
|
|
@@ -1307,16 +1456,32 @@ def _is_repeat_question(q, qa):
|
|
| 1307 |
return False
|
| 1308 |
|
| 1309 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1310 |
def _fallback_question(st):
|
| 1311 |
char = CHARACTERS[st["model_key"]]
|
| 1312 |
-
|
|
|
|
|
|
|
|
|
|
| 1313 |
if not bank:
|
| 1314 |
st["fallback_used"] = []
|
| 1315 |
-
bank = char[
|
| 1316 |
q = random.choice(bank)
|
| 1317 |
st["fallback_used"].append(q)
|
| 1318 |
target = _least_questioned_claim(st["qa"], st["claims"])
|
| 1319 |
-
|
|
|
|
|
|
|
|
|
|
| 1320 |
|
| 1321 |
|
| 1322 |
def _chat_from_state(st, trailing=None):
|
|
@@ -1411,6 +1576,7 @@ def add_answer(answer, state, chat):
|
|
| 1411 |
if not a:
|
| 1412 |
raise gr.Error("The room waits. Say something — even liars say something.")
|
| 1413 |
st["qa"][-1]["a"] = a[:600]
|
|
|
|
| 1414 |
if len(a) < 3:
|
| 1415 |
st["one_word_count"] += 1
|
| 1416 |
return "", st, _chat_from_state(st)
|
|
@@ -1448,11 +1614,13 @@ def detective_turn(state, chat):
|
|
| 1448 |
if first_wait:
|
| 1449 |
st["session"]["ritual_done"][st["model_key"]] = True
|
| 1450 |
|
|
|
|
| 1451 |
wait_start = time.time()
|
| 1452 |
-
shown = ritual.pop(0) if ritual else _next_wait_line(st, escalated=False
|
|
|
|
| 1453 |
if st["one_word_count"] >= 2 and not st["one_word_called"]:
|
| 1454 |
st["one_word_called"] = True
|
| 1455 |
-
shown = ONE_WORD_CALLOUT
|
| 1456 |
outs.chat = _chat_from_state(st, trailing=f"*{shown}*")
|
| 1457 |
yield outs.tuple()
|
| 1458 |
next_line_at = wait_start + random.uniform(4, 6)
|
|
@@ -1469,7 +1637,8 @@ def detective_turn(state, chat):
|
|
| 1469 |
if ritual:
|
| 1470 |
shown = ritual.pop(0)
|
| 1471 |
else:
|
| 1472 |
-
shown = _next_wait_line(st, escalated=(now - wait_start) > 15
|
|
|
|
| 1473 |
outs.chat = _chat_from_state(st, trailing=f"*{shown}*")
|
| 1474 |
next_line_at = now + random.uniform(4, 6)
|
| 1475 |
yield outs.tuple()
|
|
@@ -1500,7 +1669,8 @@ def detective_turn(state, chat):
|
|
| 1500 |
question = _humanize_letters(question, st["claims"])
|
| 1501 |
why = _humanize_letters(why, st["claims"])
|
| 1502 |
|
| 1503 |
-
if action == "accuse" and not force and
|
|
|
|
| 1504 |
action = "ask" # blocked: too early / not confident enough — keep pressing
|
| 1505 |
question = ""
|
| 1506 |
fb = 3
|
|
|
|
| 178 |
"He sits, stands, moves the chair one inch closer, sits again. Ready.",
|
| 179 |
"\"Right.\" He looks at your three claims like they owe him money.",
|
| 180 |
],
|
| 181 |
+
"fq_specific": [
|
| 182 |
+
"You said {C}. What day of the week? Don't look up. People look up when they're building.",
|
| 183 |
+
"{C} — okay, what time did it end? Not start. Everyone rehearses the start.",
|
| 184 |
+
"{C}. What's the cross street? Everywhere has a cross street. Except lies.",
|
| 185 |
+
"Say {C} again, word for word. If it comes out identical, that's worse, actually.",
|
| 186 |
+
"{C}. What did it smell like? Liars forget smells. It's step six. Or seven. It's laminated.",
|
| 187 |
+
"You said {C}. Which hand? People always know which hand. Take your time. Not too much time.",
|
| 188 |
+
"How much did that cost? Round numbers make me nervous. Give me the cents.",
|
| 189 |
+
"Who can confirm that? A name. Not 'a mate'. Mates have names. That's what makes them mates.",
|
| 190 |
+
"Name one person who saw it. I'll wait. I'm fast, but I'll wait.",
|
| 191 |
+
"Was there a receipt? There's always a receipt. The receipt is the truth's shadow.",
|
| 192 |
+
],
|
| 193 |
+
"fq_vague": [
|
| 194 |
+
"You said {A}. I wrote it down, and now my notes are worse than before you spoke.",
|
| 195 |
+
"{A} is not an answer, it's weather. Give me furniture. A doorway. Something with edges.",
|
| 196 |
+
"I've underlined {A} three times and it means less each time.",
|
| 197 |
+
"{A}. My pencil got all the way to the paper and had nowhere to go. Do you see what you're doing to us.",
|
| 198 |
+
"Form 12-B has a section called Particulars. It's blank. It's looking at me. Fix it.",
|
| 199 |
+
"One fact. A name, a time, a number. Any of the three. Pick your favourite.",
|
| 200 |
+
"Who. Just who. Give me a who and I'll do the what and the when myself.",
|
| 201 |
+
"Was it before lunch or after lunch? Everything in this world is before lunch or after lunch.",
|
| 202 |
+
"How many? You shrugged. Numbers don't shrug. How many?",
|
| 203 |
+
"Can't remember, or won't remember? There are different boxes for those. Neither is the good box.",
|
| 204 |
+
],
|
| 205 |
+
"fq_streak": [
|
| 206 |
+
"{A}. And before that, also fog. One more fog and I'm starting this interview over. From my name.",
|
| 207 |
+
"{A}. Again. Do you know what I've written this whole session? A boat. I drew a boat.",
|
| 208 |
+
"Sixty-one days on the job and nobody has ever put {A} in a report. I won't be first.",
|
| 209 |
+
"Form 12-B has nine boxes. I have filled in the date. The date was me. I did the date.",
|
| 210 |
+
"Is this a technique? Are you doing a technique on me? Because it's working and I hate it.",
|
| 211 |
+
"The Nine Steps don't cover this. I've checked twice. I'm off the card. I'm off the laminate.",
|
| 212 |
+
],
|
| 213 |
+
"vague_waits": [
|
| 214 |
+
"He whispers 'gotcha,' then, quieter, 'no.'",
|
| 215 |
+
"Somewhere behind him, a pencil snaps. He is not holding a pencil.",
|
| 216 |
+
"He draws the boat. He notices he is drawing the boat. He stops. He finishes the boat.",
|
| 217 |
+
"He pours a very small coffee from a very large thermos and does not drink it.",
|
| 218 |
+
"He mouths 'okay' four times. The fourth one doesn't make it.",
|
| 219 |
+
"Behind the glass, Sgt. Wattle writes one word. It is not a long word.",
|
| 220 |
+
"Gimlet holds his pencil very still, the way you hold something you love.",
|
| 221 |
+
"He turns the notepad sideways, in case the answer has a landscape orientation.",
|
| 222 |
+
],
|
| 223 |
+
"one_word": [
|
| 224 |
+
"That's two one-word answers. Hang on, there's a form for this. There is no form for this.",
|
| 225 |
+
"Fine. I'll ask questions of one word too. Where. When. Who. See? It's awful. Nobody wins.",
|
| 226 |
],
|
| 227 |
},
|
| 228 |
"marlowe": {
|
|
|
|
| 277 |
"The sound of a great weight consenting, slowly, to be vertical.",
|
| 278 |
"The door opens. He enters with the file already open, as if he was never asleep.",
|
| 279 |
],
|
| 280 |
+
"fq_specific": [
|
| 281 |
+
"{C}. Mm. And the second time you told that story — what changed?",
|
| 282 |
+
"You said {C}. Tell me the part you always leave out.",
|
| 283 |
+
"{C}. What was the second thing you noticed? Everyone rehearses the first.",
|
| 284 |
+
"{C}. Which detail are you proudest of? That is the one I will be checking.",
|
| 285 |
+
"{C}. What did you almost do instead?",
|
| 286 |
+
"How long did {C} take? People invent events. They rarely invent durations.",
|
| 287 |
+
"What happened an hour before {C}? Lies begin abruptly. Lives do not.",
|
| 288 |
+
"Tell it backwards, from the end. The truth survives that. Little else does.",
|
| 289 |
+
"Name one person who was there. I won't contact them. Knowing I could is enough.",
|
| 290 |
+
"My watch stopped years ago. Still right twice a day. Your story — once, so far.",
|
| 291 |
+
],
|
| 292 |
+
"fq_vague": [
|
| 293 |
+
"{A}. Mm. A man told me that in 1991. He was protecting his brother.",
|
| 294 |
+
"{A}. That isn't memory failing. Memory fails specific. That was built.",
|
| 295 |
+
"{A}. Say it again, slower. Listen to yourself say it.",
|
| 296 |
+
"Fog is a choice. Choose a name instead.",
|
| 297 |
+
"A time. Just a time. The innocent always know roughly when.",
|
| 298 |
+
"I don't mind fog. I've sat in it since 1987. It clears. Usually around the third question.",
|
| 299 |
+
"People remember what happened to them. They forget what they invented. Which is this?",
|
| 300 |
+
"Tell me the time of day. If you cannot, tell me the light. People remember the light.",
|
| 301 |
+
"One detail. Choose it carefully. I will treat it as though you did.",
|
| 302 |
+
"A number. Any number that touches your story. Even the wrong number tells me where you keep them.",
|
| 303 |
+
],
|
| 304 |
+
"fq_streak": [
|
| 305 |
+
"Twice now. Mm. The second fog is never an accident.",
|
| 306 |
+
"{A}. And before that, the same weather. Mm. I have stopped writing. Notice that I have stopped.",
|
| 307 |
+
"{A}. Say one true thing. Small. I will know it by the relief.",
|
| 308 |
+
"{A}. Again. Mm. The rain has more content.",
|
| 309 |
+
"You've said nothing twice, and beautifully. The BELLBIRD file began this way.",
|
| 310 |
+
"We can continue this way. I sleep twenty hours a day. I am rested. Are you?",
|
| 311 |
+
],
|
| 312 |
+
"vague_waits": [
|
| 313 |
+
"The Inspector says nothing. It is a specific nothing. It is aimed.",
|
| 314 |
+
"Rain on the window. He lets it do the talking for a while.",
|
| 315 |
+
"He looks at the stopped pocket watch. It is correct.",
|
| 316 |
+
"He touches the cold tea. He does not drink it. He has never drunk it.",
|
| 317 |
+
"He turns one page of the slow file. He was not reading it. The gesture lands anyway.",
|
| 318 |
+
"His eyes almost close. The room holds its breath. They open. Something has been decided.",
|
| 319 |
+
"He breathes out through his nose. In some precincts this counts as shouting.",
|
| 320 |
+
"The BELLBIRD file sits under his hand. It does not come up. Its presence comes up.",
|
| 321 |
+
],
|
| 322 |
+
"one_word": [
|
| 323 |
+
"Two answers. Two words. Mm. Economy. I respect it. I don't believe it.",
|
| 324 |
+
"The truth is often short. It is never this short twice.",
|
| 325 |
],
|
| 326 |
},
|
| 327 |
}
|
|
|
|
| 861 |
pacing = "The clock is ticking. If the picture is clear, accuse."
|
| 862 |
task = (f"You have asked {n} of {MAX_QUESTIONS} questions. {pacing}\n\n"
|
| 863 |
+ DETECTIVE_JSON_SPEC)
|
| 864 |
+
# vagueness steering — how the detective plays depends on what he's being given
|
| 865 |
+
last_vague, streak, vcount = _vague_stats(qa)
|
| 866 |
+
if streak >= 2:
|
| 867 |
+
vag = (f"\n\nTHE SUSPECT IS STONEWALLING: {streak} vague answers in a row "
|
| 868 |
+
f"({vcount} total). Make the vagueness itself the subject. Demand ONE "
|
| 869 |
+
"concrete fact — a name, a time, a number, a place — and do not accept fog.")
|
| 870 |
+
elif last_vague:
|
| 871 |
+
vag = ("\n\nThe suspect's last answer was VAGUE — no checkable detail. Call it "
|
| 872 |
+
"out in your own dry way, then ask ONE question that pins down a single "
|
| 873 |
+
"concrete fact: a name, a time, a number, a place.")
|
| 874 |
+
elif [e for e in qa if e.get("a")]:
|
| 875 |
+
vag = ("\n\nTheir last answer contained real detail. Test it: cross-reference "
|
| 876 |
+
"it against the claims and their earlier answers, and ask for the detail "
|
| 877 |
+
"a liar would not have prepared.")
|
| 878 |
+
else:
|
| 879 |
+
vag = ""
|
| 880 |
observer = ""
|
| 881 |
if observer_json:
|
| 882 |
observer = ("\n\nCONFIDENTIAL OBSERVER NOTES from the observation room "
|
| 883 |
"(the suspect cannot hear this):\n" + observer_json)
|
| 884 |
system = (
|
| 885 |
+
f"{c['voice']}{vag}\n\n"
|
| 886 |
"You are playing Two Truths and a Lie. The suspect filed three claims about "
|
| 887 |
"themselves; EXACTLY ONE is false. Interrogate with short, concrete questions "
|
| 888 |
"(under 25 words) about places, times, people, what happened right before or "
|
|
|
|
| 1075 |
return _scan_letter(v) or fallback
|
| 1076 |
|
| 1077 |
|
| 1078 |
+
# ── vagueness: the precinct has procedures for fog ───────────────────────────
|
| 1079 |
+
# Deterministic read on each answer. Vague answers change how the detective
|
| 1080 |
+
# plays: different question banks, prompt steering, and — after a real
|
| 1081 |
+
# stonewall — he stops waiting for evidence and goes with his gut.
|
| 1082 |
+
|
| 1083 |
+
_HEDGE_WORDS = {"maybe", "probably", "perhaps", "possibly", "dunno", "whatever",
|
| 1084 |
+
"stuff", "things", "somewhere", "someone", "something", "sometime",
|
| 1085 |
+
"somehow", "kinda", "sorta", "ish"}
|
| 1086 |
+
_HEDGE_PHRASES = ("i think", "i guess", "kind of", "sort of", "not sure", "no idea",
|
| 1087 |
+
"don't remember", "dont remember", "can't remember", "cant remember",
|
| 1088 |
+
"don't know", "dont know", "hard to say", "it depends", "a while ago",
|
| 1089 |
+
"at some point", "here and there", "now and then", "or something",
|
| 1090 |
+
"can't recall", "cant recall", "who knows")
|
| 1091 |
+
|
| 1092 |
+
|
| 1093 |
+
def _is_vague_answer(text):
|
| 1094 |
+
t = " ".join((text or "").lower().split())
|
| 1095 |
+
words = re.findall(r"[a-z']+", t)
|
| 1096 |
+
if len(words) <= 3:
|
| 1097 |
+
return True
|
| 1098 |
+
hedges = sum(w in _HEDGE_WORDS for w in words) + sum(p in t for p in _HEDGE_PHRASES)
|
| 1099 |
+
has_number = bool(re.search(r"\d", t))
|
| 1100 |
+
content = [w for w in words if w not in _QSTOP and w not in _HEDGE_WORDS and len(w) > 3]
|
| 1101 |
+
if hedges >= 2:
|
| 1102 |
+
return True
|
| 1103 |
+
if hedges >= 1 and len(content) < 4 and not has_number:
|
| 1104 |
+
return True
|
| 1105 |
+
if len(content) < 2 and not has_number:
|
| 1106 |
+
return True
|
| 1107 |
+
return False
|
| 1108 |
+
|
| 1109 |
+
|
| 1110 |
+
def _vague_stats(qa):
|
| 1111 |
+
"""(last_answer_was_vague, current_streak, total_count) over answered entries."""
|
| 1112 |
+
answered = [e for e in qa if e.get("a")]
|
| 1113 |
+
streak = 0
|
| 1114 |
+
for e in reversed(answered):
|
| 1115 |
+
if e.get("vague"):
|
| 1116 |
+
streak += 1
|
| 1117 |
+
else:
|
| 1118 |
+
break
|
| 1119 |
+
total = sum(1 for e in answered if e.get("vague"))
|
| 1120 |
+
return (bool(answered and answered[-1].get("vague")), streak, total)
|
| 1121 |
+
|
| 1122 |
+
|
| 1123 |
# ── claims are spoken as WORDS, never letters ────────────────────────────────
|
| 1124 |
# Players don't remember which claim was "B". The letters exist only as JSON
|
| 1125 |
# indices and small visual tags; every spoken reference quotes the claim itself.
|
|
|
|
| 1234 |
return out
|
| 1235 |
|
| 1236 |
|
| 1237 |
+
def _accuse_allowed(n_answered, det_conf, analyst_profile, vague_streak=0):
|
| 1238 |
+
"""Spec pacing: threshold decay + snap-accuse guard. A real stonewall (3+
|
| 1239 |
+
vague answers in a row) loosens the gate — if the suspect gives him nothing,
|
| 1240 |
+
the detective stops waiting for evidence and goes with his gut."""
|
| 1241 |
if n_answered < MIN_Q_BEFORE_ACCUSE:
|
| 1242 |
return False
|
| 1243 |
pct = CONF_PCT[det_conf]
|
| 1244 |
thresh = ACCUSE_THRESHOLD.get(n_answered, ACCUSE_FLOOR)
|
| 1245 |
+
stonewalled = vague_streak >= 3
|
| 1246 |
+
if stonewalled:
|
| 1247 |
+
thresh = max(ACCUSE_FLOOR, thresh - 10)
|
| 1248 |
if pct < thresh:
|
| 1249 |
return False
|
| 1250 |
+
if n_answered < 8 and not stonewalled:
|
| 1251 |
susp = max(SUSP_PCT[analyst_profile.get(f"suspicion_{k}", "low")] for k in "ABC") \
|
| 1252 |
if analyst_profile else 0
|
| 1253 |
if susp < SNAP_GUARD_MIN_ANALYST:
|
|
|
|
| 1314 |
"claims": claims,
|
| 1315 |
"labels": {"lie": st["lie_label"], "truths": truths},
|
| 1316 |
"conversation": [{"turn": e["turn"], "remark": e.get("remark", ""),
|
| 1317 |
+
"question": e["q"], "answer": e.get("a"),
|
| 1318 |
+
"vague": bool(e.get("vague"))} for e in st["qa"]],
|
| 1319 |
+
"behavior": {"vague_answers": sum(1 for e in st["qa"] if e.get("vague")),
|
| 1320 |
+
"final_vague_streak": _vague_stats(st["qa"])[1]},
|
| 1321 |
"hidden_analysis": st["hidden_analysis"],
|
| 1322 |
"guess": {"guesser": "model",
|
| 1323 |
"selected_lie": acc.get("lie"),
|
|
|
|
| 1408 |
f'<div class="track"><i style="width:{pct}%"></i></div></div>')
|
| 1409 |
|
| 1410 |
|
| 1411 |
+
def _next_wait_line(st, escalated, vague=False):
|
| 1412 |
+
"""No-repeat wait-line deck per character per session; escalation lines gated
|
| 1413 |
+
15s+. After a vague answer, the room reacts to the fog first."""
|
| 1414 |
key = st["model_key"]
|
| 1415 |
char = CHARACTERS[key]
|
| 1416 |
used = st["session"]["used_waits"][key]
|
| 1417 |
+
if vague and not escalated:
|
| 1418 |
+
vdeck = char.get("vague_waits", [])
|
| 1419 |
+
fresh = [w for w in vdeck if w not in used]
|
| 1420 |
+
if fresh:
|
| 1421 |
+
line = random.choice(fresh)
|
| 1422 |
+
used.append(line)
|
| 1423 |
+
return line
|
| 1424 |
deck = char["waits_escalation"] if escalated else char["waits"]
|
| 1425 |
fresh = [w for w in deck if w not in used]
|
| 1426 |
if not fresh:
|
|
|
|
| 1456 |
return False
|
| 1457 |
|
| 1458 |
|
| 1459 |
+
def _fallback_bank_key(st):
|
| 1460 |
+
"""Pick the question bank by how forthcoming the suspect is being."""
|
| 1461 |
+
_, streak, _ = _vague_stats(st["qa"])
|
| 1462 |
+
if streak >= 2:
|
| 1463 |
+
return "fq_streak"
|
| 1464 |
+
if streak >= 1:
|
| 1465 |
+
return "fq_vague"
|
| 1466 |
+
return "fq_specific"
|
| 1467 |
+
|
| 1468 |
+
|
| 1469 |
def _fallback_question(st):
|
| 1470 |
char = CHARACTERS[st["model_key"]]
|
| 1471 |
+
key = _fallback_bank_key(st)
|
| 1472 |
+
bank = [q for q in char[key] if q not in st["fallback_used"]]
|
| 1473 |
+
if not bank: # this bank exhausted this session — borrow, then reset
|
| 1474 |
+
bank = [q for q in char["fq_specific"] if q not in st["fallback_used"]]
|
| 1475 |
if not bank:
|
| 1476 |
st["fallback_used"] = []
|
| 1477 |
+
bank = char[key][:]
|
| 1478 |
q = random.choice(bank)
|
| 1479 |
st["fallback_used"].append(q)
|
| 1480 |
target = _least_questioned_claim(st["qa"], st["claims"])
|
| 1481 |
+
answered = [e for e in st["qa"] if e.get("a")]
|
| 1482 |
+
last_ans = answered[-1]["a"] if answered else ""
|
| 1483 |
+
return q.format(C=_claim_snippet(st["claims"][target]),
|
| 1484 |
+
A=_claim_snippet(last_ans, limit=36))
|
| 1485 |
|
| 1486 |
|
| 1487 |
def _chat_from_state(st, trailing=None):
|
|
|
|
| 1576 |
if not a:
|
| 1577 |
raise gr.Error("The room waits. Say something — even liars say something.")
|
| 1578 |
st["qa"][-1]["a"] = a[:600]
|
| 1579 |
+
st["qa"][-1]["vague"] = _is_vague_answer(a)
|
| 1580 |
if len(a) < 3:
|
| 1581 |
st["one_word_count"] += 1
|
| 1582 |
return "", st, _chat_from_state(st)
|
|
|
|
| 1614 |
if first_wait:
|
| 1615 |
st["session"]["ritual_done"][st["model_key"]] = True
|
| 1616 |
|
| 1617 |
+
last_vague, vstreak, _ = _vague_stats(st["qa"])
|
| 1618 |
wait_start = time.time()
|
| 1619 |
+
shown = ritual.pop(0) if ritual else _next_wait_line(st, escalated=False,
|
| 1620 |
+
vague=last_vague)
|
| 1621 |
if st["one_word_count"] >= 2 and not st["one_word_called"]:
|
| 1622 |
st["one_word_called"] = True
|
| 1623 |
+
shown = random.choice(char.get("one_word", [ONE_WORD_CALLOUT]))
|
| 1624 |
outs.chat = _chat_from_state(st, trailing=f"*{shown}*")
|
| 1625 |
yield outs.tuple()
|
| 1626 |
next_line_at = wait_start + random.uniform(4, 6)
|
|
|
|
| 1637 |
if ritual:
|
| 1638 |
shown = ritual.pop(0)
|
| 1639 |
else:
|
| 1640 |
+
shown = _next_wait_line(st, escalated=(now - wait_start) > 15,
|
| 1641 |
+
vague=last_vague)
|
| 1642 |
outs.chat = _chat_from_state(st, trailing=f"*{shown}*")
|
| 1643 |
next_line_at = now + random.uniform(4, 6)
|
| 1644 |
yield outs.tuple()
|
|
|
|
| 1669 |
question = _humanize_letters(question, st["claims"])
|
| 1670 |
why = _humanize_letters(why, st["claims"])
|
| 1671 |
|
| 1672 |
+
if action == "accuse" and not force and \
|
| 1673 |
+
not _accuse_allowed(n_answered, conf, profile, vague_streak=vstreak):
|
| 1674 |
action = "ask" # blocked: too early / not confident enough — keep pressing
|
| 1675 |
question = ""
|
| 1676 |
fb = 3
|
|
@@ -203,13 +203,53 @@ def extra_checks():
|
|
| 203 |
st["model_key"] = "marlowe"
|
| 204 |
st["qa"] = [{"turn": 1, "q": "Who else was there when the dog sighed?", "a": "x"}]
|
| 205 |
fq = app._fallback_question(st)
|
| 206 |
-
check("fallback
|
| 207 |
check("least-questioned avoids the dog claim",
|
| 208 |
app._least_questioned_claim(st["qa"], claims) in ("B", "C"))
|
| 209 |
|
| 210 |
check("docket lists all claims (escaped)",
|
| 211 |
all(app.html_lib.escape(claims[k]) in app._docket_html(claims) for k in "ABC"))
|
| 212 |
check("empty docket has empty state", "No claims on file" in app._docket_html(None))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
return fails
|
| 214 |
|
| 215 |
|
|
|
|
| 203 |
st["model_key"] = "marlowe"
|
| 204 |
st["qa"] = [{"turn": 1, "q": "Who else was there when the dog sighed?", "a": "x"}]
|
| 205 |
fq = app._fallback_question(st)
|
| 206 |
+
check("fallback has no unformatted slots", "{C}" not in fq and "{A}" not in fq, fq)
|
| 207 |
check("least-questioned avoids the dog claim",
|
| 208 |
app._least_questioned_claim(st["qa"], claims) in ("B", "C"))
|
| 209 |
|
| 210 |
check("docket lists all claims (escaped)",
|
| 211 |
all(app.html_lib.escape(claims[k]) in app._docket_html(claims) for k in "ABC"))
|
| 212 |
check("empty docket has empty state", "No claims on file" in app._docket_html(None))
|
| 213 |
+
|
| 214 |
+
print("== vagueness detection ==")
|
| 215 |
+
check("'kind of' is vague", app._is_vague_answer("kind of"))
|
| 216 |
+
check("'I guess it was somewhere in town' is vague",
|
| 217 |
+
app._is_vague_answer("I guess it was somewhere in town"))
|
| 218 |
+
check("'can't remember, a while ago' is vague",
|
| 219 |
+
app._is_vague_answer("can't remember, it was a while ago"))
|
| 220 |
+
check("specific answer with detail is not vague",
|
| 221 |
+
not app._is_vague_answer("At the takeout place on Fifth Street, a Tuesday in March"))
|
| 222 |
+
check("specific with number is not vague",
|
| 223 |
+
not app._is_vague_answer("It took 3 weeks and my friend Sam helped"))
|
| 224 |
+
|
| 225 |
+
print("== vagueness-reactive play ==")
|
| 226 |
+
qa = [{"turn": 1, "q": "q1", "a": "somewhere I think", "vague": True},
|
| 227 |
+
{"turn": 2, "q": "q2", "a": "kind of", "vague": True}]
|
| 228 |
+
lv, streak, total = app._vague_stats(qa)
|
| 229 |
+
check("streak counted", lv and streak == 2 and total == 2, f"{lv},{streak},{total}")
|
| 230 |
+
stv = app.new_state()
|
| 231 |
+
stv["claims"] = claims
|
| 232 |
+
stv["model_key"] = "pip"
|
| 233 |
+
stv["qa"] = qa
|
| 234 |
+
check("streak bank selected", app._fallback_bank_key(stv) == "fq_streak")
|
| 235 |
+
stv["qa"] = qa[:1]
|
| 236 |
+
check("vague bank selected", app._fallback_bank_key(stv) == "fq_vague")
|
| 237 |
+
stv["qa"] = [{"turn": 1, "q": "q1", "a": "Fifth Street on a Tuesday", "vague": False}]
|
| 238 |
+
check("specific bank selected", app._fallback_bank_key(stv) == "fq_specific")
|
| 239 |
+
stv["qa"] = qa
|
| 240 |
+
fq2 = app._fallback_question(stv)
|
| 241 |
+
check("streak fallback formats {A}", "{A}" not in fq2 and "{C}" not in fq2, fq2)
|
| 242 |
+
|
| 243 |
+
prof_lo = {f"suspicion_{k}": "low" for k in "ABC"}
|
| 244 |
+
check("stonewall loosens gate (medium at Q6)",
|
| 245 |
+
app._accuse_allowed(6, "medium", prof_lo, vague_streak=3))
|
| 246 |
+
check("no stonewall keeps gate (medium at Q6)",
|
| 247 |
+
not app._accuse_allowed(6, "medium", prof_lo, vague_streak=2))
|
| 248 |
+
|
| 249 |
+
msgs = app._detective_prompt("pip", claims, qa, None, False)
|
| 250 |
+
check("prompt carries stonewall steering", "STONEWALLING" in msgs[0]["content"])
|
| 251 |
+
check("vague wait deck exists for both",
|
| 252 |
+
all(len(app.CHARACTERS[k]["vague_waits"]) >= 8 for k in ("pip", "marlowe")))
|
| 253 |
return fails
|
| 254 |
|
| 255 |
|