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:
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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(" ")
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user