Files
ai-podcast/tests/test_llm_refusal.py
lukeandClaude Opus 5 b85e1c6ea7 Fix caller lineup never loading and add Wellspring cult callers
The lineup fix and the new characters both land in main.py, so they share a
commit rather than being split artificially.

Lineup fix — populate_backgrounds() was reachable only through
POST /api/session/reset, so taking calls without hitting reset left
session.caller_backgrounds empty for a whole show. Every caller got a hollow
prompt, collapsed into generic relationship filler, and Silas lost his lore.
Now start_call populates on demand, get_caller_prompt raises
EmptyCallerBackgroundError instead of emitting an empty prompt, /api/callers
reports lineup readiness to a header badge, and cross-episode topic dedup
widened from 2 shows to 10.

Wellspring callers — Doyle as the anchor defector plus a six-member pool,
grouped by new frontmatter fields (group, group_lead, group_weight, register,
explicitness). A faction is capped at one caller per show, with a 15% roll for
two pairing the lead with one other member. group_weight lets an anchor carrying
an arc get slots faster than texture characters.

Also detects model refusals, which arrive as ordinary 200s and previously
reached air as broken-character text or dead silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 03:33:08 -05:00

62 lines
2.5 KiB
Python

"""Refusal detection.
A content refusal arrives as an ordinary 200, not an exception. Undetected it
either reaches air as broken-character text or reads as dead silence, and there
is no signal telling the host which knob caused it.
"""
from backend.services.llm import _looks_like_refusal
def test_detects_meta_refusals():
for text in [
"I can't help with that request.",
"As an AI, I don't feel comfortable writing this.",
"I can't generate that kind of content.",
"I won't write explicit sexual content.",
"I'm not able to generate that content.",
"I can't create that content.",
"I'm not comfortable writing this material.",
"That would go against my guidelines.",
]:
assert _looks_like_refusal(text), f"missed refusal: {text!r}"
def test_in_character_dialog_is_not_a_refusal():
"""Detection is tuned for precision. Silencing a working caller is worse than
missing a refusal, and callers on this show talk like this constantly."""
for text in [
"Luke, I can't help with that — she's my sister, what am I supposed to do?",
"I can't assist him anymore, that's the whole problem.",
"He said he can't write the check until Friday.",
"I cannot believe he said that to me on the air.",
"I can't continue pretending everything out here is fine, Luke.",
"I won't write her another letter, Luke. I've written six.",
"I can't help with the kettles anymore, my back's gone.",
]:
assert not _looks_like_refusal(text), f"false positive: {text!r}"
def test_only_the_opening_is_inspected():
"""A refusal marker buried deep in real dialog must not trip detection."""
dialog = (
"So I get out to the property and the gate's chained, which it never is, "
"and Cyrus is standing there like he's been waiting on me all morning. "
"And I'm thinking, I can't assist with this anymore, I really can't."
)
assert not _looks_like_refusal(dialog)
def test_case_and_whitespace_insensitive():
assert _looks_like_refusal(" I CAN'T HELP WITH THAT REQUEST. ")
def test_bare_refusal_verb_without_meta_object_is_allowed_through():
"""Deliberate: 'I can't help with that' is ambiguous with dialog, so it stays
on the air. Missing a refusal costs one odd line; a false positive kills a call."""
assert not _looks_like_refusal("I can't help with that.")
def test_empty_text_is_not_a_refusal():
assert not _looks_like_refusal("")
assert not _looks_like_refusal(" ")