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>
This commit is contained in:
2026-08-15 03:33:08 -05:00
co-authored by Claude Opus 5
parent 9071ed36d3
commit b85e1c6ea7
15 changed files with 856 additions and 21 deletions
+72
View File
@@ -0,0 +1,72 @@
"""Regression guard: a show once ran with session.caller_backgrounds empty because
populate_backgrounds() was reachable only via POST /api/session/reset. Every caller
got a hollow prompt and collapsed into generic relationship filler.
"""
import asyncio
import pytest
import backend.main as m
@pytest.fixture
def quiet_call(monkeypatch):
monkeypatch.setattr(m.audio_service, "stop_caller_audio", lambda *a, **k: None)
monkeypatch.setattr(m, "_maybe_generate_callback", lambda: None)
monkeypatch.setattr(m, "_enrich_background_async", lambda key: asyncio.sleep(0))
monkeypatch.setattr(m.session, "intern_monitoring", False)
m.session.current_caller_key = None
yield
# asyncio.run() closes the loop it creates and clears the thread's current
# loop. tests/test_caller_service.py still calls asyncio.get_event_loop(),
# which then raises — and this module sorts ahead of it. Leave a usable loop.
asyncio.set_event_loop(asyncio.new_event_loop())
def test_start_call_populates_empty_lineup(monkeypatch, quiet_call):
key = next(iter(m.CALLER_BASES))
m.session.caller_backgrounds = {}
called = []
async def fake_populate():
called.append(True)
m.session.caller_backgrounds = {
key: {"name": "Silas", "voice": "Sebastian", "identity": "commune founder",
"situation": "the convoy stopped", "specific_details": []}
}
monkeypatch.setattr(m.session, "populate_backgrounds", fake_populate)
asyncio.run(m.start_call(key))
assert called, "start_call must populate backgrounds when the lineup is empty"
assert m.session.caller_backgrounds, "lineup should be populated after the call starts"
def test_start_call_does_not_repopulate_existing_lineup(monkeypatch, quiet_call):
key = next(iter(m.CALLER_BASES))
m.session.caller_backgrounds = {
key: {"name": "Silas", "voice": "Sebastian", "identity": "commune founder",
"situation": "the convoy stopped", "specific_details": []}
}
called = []
async def fake_populate():
called.append(True)
monkeypatch.setattr(m.session, "populate_backgrounds", fake_populate)
asyncio.run(m.start_call(key))
assert not called, "an existing lineup must not be regenerated mid-show"
def test_prompt_from_populated_background_is_not_hollow():
prompt = m.get_caller_prompt({
"name": "Silas", "identity": "founder of The Wellspring",
"situation": "the convoy stopped on the shoulder of 118",
"reason_calling": "Priscilla will not come out of the trailer",
"secret_want": "to hear he is not the reason she broke",
"specific_details": ["livestock trailer", "nine years in"],
})
assert "Silas" in prompt
assert "Wellspring" in prompt
assert "You are . " not in prompt
+22 -1
View File
@@ -1,4 +1,25 @@
from backend.main import get_caller_prompt
import pytest
from backend.main import EmptyCallerBackgroundError, get_caller_prompt
def test_empty_background_raises_instead_of_hollow_prompt():
"""A hollow prompt still generates dialog — the model invents generic
relationship filler and regulars lose their lore. Fail loudly instead."""
with pytest.raises(EmptyCallerBackgroundError):
get_caller_prompt({})
def test_background_missing_every_identity_field_raises():
caller = {"voice": "Sebastian", "age": 52, "location": "Terlingua"}
with pytest.raises(EmptyCallerBackgroundError):
get_caller_prompt(caller)
def test_partial_background_still_builds():
"""A name alone is enough to reach the regulars lookup, so don't block it."""
prompt = get_caller_prompt({"name": "Silas"})
assert "Silas" in prompt
def test_prompt_includes_identity_and_situation():
+61
View File
@@ -0,0 +1,61 @@
"""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(" ")
+31
View File
@@ -130,3 +130,34 @@ def test_session_conversation_summary_three_party():
summary = s.get_conversation_summary()
assert "Dave" in summary
assert "Tony" in summary
def test_recent_summaries_uses_wider_dedup_window(monkeypatch):
"""Phase 5B deleted cross-episode topic dedup, leaving only a 2-show window.
The batch generator should now see LINEUP_DEDUP_SHOWS worth of history."""
import backend.main as m
history = [
{"lineup": [{"name": f"Caller{i}", "situation": f"situation number {i}"}]}
for i in range(m.LINEUP_DEDUP_SHOWS + 5)
]
monkeypatch.setattr(m, "_load_lineup_history", lambda: history)
summaries = Session()._get_recent_summaries()
assert len(summaries) == m.LINEUP_DEDUP_SHOWS
assert m.LINEUP_DEDUP_SHOWS > 2
# Keeps the most recent shows, drops the oldest
assert "situation number 4" not in " ".join(summaries)
assert f"situation number {m.LINEUP_DEDUP_SHOWS + 4}" in " ".join(summaries)
def test_lineup_history_retains_at_least_the_dedup_window():
"""Truncating the file below the dedup window would silently shrink it."""
import backend.main as m
assert m.LINEUP_HISTORY_MAX >= m.LINEUP_DEDUP_SHOWS
def test_fresh_session_reports_no_lineup():
s = Session()
assert s.caller_backgrounds == {}
assert bool(s.caller_backgrounds) is False
+246
View File
@@ -0,0 +1,246 @@
"""Faction grouping for regulars.
Eight Wellspring characters share one lore world. Without a group cap they would
crowd out every other regular and turn the show into the cult hour.
"""
import random
import pytest
from backend.main import (
GROUP_TWO_UP_CHANCE,
MAX_REGULARS_PER_SHOW,
_select_regulars_for_tonight,
)
from backend.services.regulars_v2 import Regular, load_regular
def make_regular(name, group="", group_lead=False, explicitness="low", register=""):
return Regular(
name=name, voice="Grant", age=47, arc_state="", lore_body="lore",
file_path=None, group=group, group_lead=group_lead,
register=register, explicitness=explicitness,
)
def wellspring_roster():
return [
make_regular("Silas", group="wellspring", group_lead=True),
make_regular("Doyle", group="wellspring"),
make_regular("Renata", group="wellspring"),
make_regular("Alvin", group="wellspring"),
make_regular("Junie", group="wellspring"),
make_regular("Ford", group="wellspring"),
make_regular("Marguerite", group="wellspring"),
make_regular("Cyrus", group="wellspring"),
]
ALWAYS = lambda name: 999 # never appeared -> probability 1.0, always a candidate
def test_group_never_exceeds_two():
for seed in range(300):
picked = _select_regulars_for_tonight(
wellspring_roster(), ALWAYS, rng=random.Random(seed)
)
ws = [r for r, _, _ in picked if r.group == "wellspring"]
assert len(ws) <= 2, f"seed {seed} produced {len(ws)} Wellspring callers"
def test_two_up_rate_is_near_configured_chance():
two_ups = 0
trials = 2000
for seed in range(trials):
picked = _select_regulars_for_tonight(
wellspring_roster(), ALWAYS, rng=random.Random(seed)
)
ws = [r for r, _, _ in picked if r.group == "wellspring"]
if len(ws) == 2:
two_ups += 1
rate = two_ups / trials
assert abs(rate - GROUP_TWO_UP_CHANCE) < 0.05, f"two-up rate was {rate:.3f}"
def test_two_up_pairs_the_lead_with_one_other():
"""'Both sides tonight' means Silas plus a member, never two pool members."""
seen_pair = False
for seed in range(500):
picked = _select_regulars_for_tonight(
wellspring_roster(), ALWAYS, rng=random.Random(seed)
)
ws = [r for r, _, _ in picked if r.group == "wellspring"]
if len(ws) == 2:
seen_pair = True
names = {r.name for r in ws}
assert "Silas" in names, f"two-up without the lead: {names}"
assert len(names) == 2
assert seen_pair, "no two-up occurred in 500 seeds — test is not exercising the path"
def test_ungrouped_regulars_are_not_capped():
roster = [make_regular(f"Solo{i}") for i in range(5)]
picked = _select_regulars_for_tonight(roster, ALWAYS, rng=random.Random(1))
assert len(picked) == MAX_REGULARS_PER_SHOW
def test_group_does_not_crowd_out_other_regulars():
"""The whole point: an ungrouped regular still gets slots on most nights."""
roster = wellspring_roster() + [make_regular("Nadine"), make_regular("Boyd")]
appearances = 0
trials = 200
for seed in range(trials):
picked = _select_regulars_for_tonight(roster, ALWAYS, rng=random.Random(seed))
if any(r.name in ("Nadine", "Boyd") for r, _, _ in picked):
appearances += 1
assert appearances / trials > 0.9
def test_never_exceeds_global_regular_cap():
roster = wellspring_roster() + [make_regular(f"Solo{i}") for i in range(6)]
for seed in range(200):
picked = _select_regulars_for_tonight(roster, ALWAYS, rng=random.Random(seed))
assert len(picked) <= MAX_REGULARS_PER_SHOW
def test_low_probability_regulars_can_be_skipped():
"""A regular seen last show should not be guaranteed."""
roster = [make_regular("Silas", group="wellspring", group_lead=True)]
skipped = any(
not _select_regulars_for_tonight(roster, lambda n: 0, rng=random.Random(s))
for s in range(50)
)
assert skipped
# --- frontmatter parsing ---
FRONTMATTER = """---
name: Doyle
voice: Grant
age: 47
group: wellspring
register: dry, understated, factual
explicitness: low
arc_state: Eleven months of telling himself he could leave tomorrow
---
# Doyle
He runs the soap kettles.
"""
def test_load_regular_parses_new_fields(tmp_path):
path = tmp_path / "doyle.md"
path.write_text(FRONTMATTER)
r = load_regular(path)
assert r.group == "wellspring"
assert r.group_lead is False
assert r.register == "dry, understated, factual"
assert r.explicitness == "low"
def test_load_regular_defaults_when_fields_absent(tmp_path):
"""Existing regular files predate these fields and must keep loading."""
path = tmp_path / "old.md"
path.write_text("---\nname: Silas\nvoice: Sebastian\nage: 52\n---\n\n# Silas\n\nlore\n")
r = load_regular(path)
assert r.group == ""
assert r.group_lead is False
assert r.register == ""
assert r.explicitness == "low"
def test_group_lead_parses_truthy(tmp_path):
path = tmp_path / "silas.md"
path.write_text(
"---\nname: Silas\nvoice: Sebastian\nage: 52\ngroup: wellspring\n"
"group_lead: true\nexplicitness: medium\n---\n\n# Silas\n\nlore\n"
)
r = load_regular(path)
assert r.group_lead is True
assert r.explicitness == "medium"
def test_invalid_explicitness_falls_back_to_low(tmp_path):
path = tmp_path / "bad.md"
path.write_text(
"---\nname: X\nvoice: Grant\nage: 30\nexplicitness: extremely\n---\n\n# X\n\nlore\n"
)
assert load_regular(path).explicitness == "low"
# --- anchor weighting ---
def weighted_roster():
return [
make_regular("Silas", group="wellspring", group_lead=True),
make_regular("Doyle", group="wellspring"),
make_regular("Renata", group="wellspring"),
make_regular("Alvin", group="wellspring"),
]
def test_group_weight_defaults_to_one(tmp_path):
path = tmp_path / "x.md"
path.write_text("---\nname: X\nvoice: Grant\nage: 30\n---\n\n# X\n\nlore\n")
assert load_regular(path).group_weight == 1.0
def test_group_weight_parses(tmp_path):
path = tmp_path / "doyle.md"
path.write_text(
"---\nname: Doyle\nvoice: Grant\nage: 47\ngroup: wellspring\n"
"group_weight: 6\n---\n\n# Doyle\n\nlore\n"
)
assert load_regular(path).group_weight == 6.0
def test_invalid_group_weight_falls_back_to_one(tmp_path):
for bad in ("heavy", "-3", "0"):
path = tmp_path / f"bad-{bad}.md"
path.write_text(
f"---\nname: B\nvoice: Grant\nage: 30\ngroup_weight: {bad}\n---\n\n# B\n\nlore\n"
)
assert load_regular(path).group_weight == 1.0
def test_weighted_anchor_appears_far_more_than_pool_members():
"""The anchor carries an arc; without weighting it lands 1 show in 8 and the
arc takes 30+ episodes to resolve."""
import collections
roster = weighted_roster()
for r in roster:
r.group_weight = 6.0 if r.name == "Doyle" else 1.0
counts = collections.Counter()
trials = 3000
for seed in range(trials):
for r, _, _ in _select_regulars_for_tonight(roster, ALWAYS, rng=random.Random(seed)):
counts[r.name] += 1
assert counts["Doyle"] > 4 * counts["Renata"]
assert counts["Doyle"] > 4 * counts["Alvin"]
def test_weighting_does_not_break_the_group_cap():
roster = wellspring_roster()
for r in roster:
r.group_weight = 6.0 if r.name in ("Doyle", "Silas") else 1.0
for seed in range(300):
picked = _select_regulars_for_tonight(roster, ALWAYS, rng=random.Random(seed))
ws = [r for r, _, _ in picked if r.group == "wellspring"]
assert len(ws) <= 2
def test_every_pool_member_remains_reachable():
"""Weighting must not starve a pool member to zero — they are the variety."""
import collections
roster = wellspring_roster()
for r in roster:
r.group_weight = 6.0 if r.name in ("Doyle", "Silas") else 1.0
counts = collections.Counter()
for seed in range(4000):
for r, _, _ in _select_regulars_for_tonight(roster, ALWAYS, rng=random.Random(seed)):
counts[r.name] += 1
for r in roster:
assert counts[r.name] > 0, f"{r.name} never appeared"