Files
ai-podcast/tests/test_wellspring_groups.py
T
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

247 lines
8.2 KiB
Python

"""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"