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