devmandan Claude Fable 5 commited on
Commit
e90e18a
·
1 Parent(s): 34de46c

Vagueness-reactive detectives: branched question banks, prompt steering, stonewall rule

Browse files

The 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

Files changed (2) hide show
  1. app.py +202 -32
  2. tests/drive_mock.py +41 -1
app.py CHANGED
@@ -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
- "fallback_questions": [
182
- "{C}. Say it again, but slower. Slower than that.",
183
- "Who else was there when {C} happened? Names. First names are fine. Actually, all the names.",
184
- "What day of the week was {C}? Everyone remembers the day. Liars pick Tuesday.",
185
- "Tell me {C} again, but look at the mirror while you do it. No reason.",
186
- "{C} what happened right after? Liars never plan the after.",
187
- "One to ten, how proud are you of {C}? There are wrong answers.",
188
- "I'm circling {C} right now. On paper. Does that worry you? Why not?",
189
- "If {C} went to court, who's your witness? Pets count. Do pets count? Pets count.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "fallback_questions": [
245
- "Say the part about {C} again. Slower.",
246
- "What did {C} cost you?",
247
- "The part of {C} you rehearsed. Say the other part.",
248
- "{C}. Tell it backwards.",
249
- "Describe the weather during {C}. The weather is rarely wrong.",
250
- "What happened the day after {C}? A true thing continues.",
251
- "Tell me {C} once more. I will not be listening to the words.",
252
- "If I visited the place in {C} tomorrow, what would I find?",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")} for e in st["qa"]],
 
 
 
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 15s+."""
 
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
- bank = [q for q in char["fallback_questions"] if q not in st["fallback_used"]]
 
 
 
1313
  if not bank:
1314
  st["fallback_used"] = []
1315
- bank = char["fallback_questions"][:]
1316
  q = random.choice(bank)
1317
  st["fallback_used"].append(q)
1318
  target = _least_questioned_claim(st["qa"], st["claims"])
1319
- return q.format(C=_claim_snippet(st["claims"][target]))
 
 
 
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 not _accuse_allowed(n_answered, conf, profile):
 
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
tests/drive_mock.py CHANGED
@@ -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 carries claim words, no {C}", "{C}" not in fq and "" 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
  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