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
+107 -16
View File
@@ -540,6 +540,15 @@ def detect_host_mood(messages: list[dict], wrapping_up: bool = False) -> str:
class EmptyCallerBackgroundError(RuntimeError):
"""Raised when a caller prompt is built from a background with no identity.
A hollow prompt still generates dialog — the model invents the most generic
late-night radio call it can (relationship trouble, every time) and a regular
like Silas loses all his lore. That failure is silent and costs a whole show,
so refuse to build the prompt instead."""
def get_caller_prompt(caller: dict, theme: str = "") -> str:
"""Caller system prompt. Identity carries the weight."""
name = caller.get("name", "")
@@ -547,6 +556,12 @@ def get_caller_prompt(caller: dict, theme: str = "") -> str:
situation = caller.get("situation", "")
reason = caller.get("reason_calling", "")
want = caller.get("secret_want", "")
if not (name or identity or situation):
raise EmptyCallerBackgroundError(
"Refusing to build a caller prompt with no name, identity, or situation — "
"session.caller_backgrounds was never populated. Call populate_backgrounds() "
"(POST /api/session/reset) before taking calls."
)
opening = caller.get("opening_line", "")
details = caller.get("specific_details", []) or []
detail_str = " | ".join(f"- {d}" for d in details)
@@ -952,17 +967,14 @@ class Session:
return i
return 999 # never appeared
regulars_for_tonight: list[dict] = []
for r in active_regulars:
misses = _shows_since_last(r.name)
prob = min(1.0, 0.4 + 0.3 * misses)
if random.random() < prob:
regulars_for_tonight.append(
{"name": r.name, "voice": r.voice, "age": r.age,
"lore": r.lore_body, "arc_state": r.arc_state,
"_misses": misses, "_prob": prob}
)
regulars_for_tonight = regulars_for_tonight[:3]
selected = _select_regulars_for_tonight(active_regulars, _shows_since_last)
regulars_for_tonight: list[dict] = [
{"name": r.name, "voice": r.voice, "age": r.age,
"lore": r.lore_body, "arc_state": r.arc_state,
"register": r.register, "explicitness": r.explicitness,
"_misses": misses, "_prob": prob}
for r, misses, prob in selected
]
if regulars_for_tonight:
names = ", ".join(f"{r['name']}(miss={r['_misses']},p={r['_prob']:.2f})"
for r in regulars_for_tonight)
@@ -1152,10 +1164,10 @@ class Session:
self.start_prewarm()
def _get_recent_summaries(self) -> list[str]:
"""Return caller name+situation strings from the last 2 lineups, so Sonnet
can avoid repeating archetypes. Empty list on first run."""
"""Return caller name+situation strings from the last LINEUP_DEDUP_SHOWS
lineups, so Sonnet can avoid repeating archetypes. Empty list on first run."""
history = _load_lineup_history()
recent = history[-2:]
recent = history[-LINEUP_DEDUP_SHOWS:]
summaries: list[str] = []
for record in recent:
for caller in record.get("lineup", []):
@@ -1257,7 +1269,77 @@ CHECKPOINT_LINEUP_MAX_AGE = 3600 # 1 hour
# --- Lineup History (for anti-repeat context across sessions) ---
LINEUP_HISTORY_FILE = Path(__file__).parent.parent / "data" / "caller_lineups.json"
LINEUP_HISTORY_MAX = 10
LINEUP_HISTORY_MAX = 20
# How many past lineups to show the batch generator as "don't repeat these".
# Replaces the cross-episode topic dedup deleted in Phase 5B (commit 83c7f44),
# which left only a 2-show window and let archetypes recycle.
LINEUP_DEDUP_SHOWS = 10
MAX_REGULARS_PER_SHOW = 3
GROUP_TWO_UP_CHANCE = 0.15 # Odds a faction gets two slots — "both sides tonight"
def _weighted_sample(entries, k, rng):
"""Sample k of [(regular, misses, prob)] without replacement, weighted by
group_weight. An anchor carrying an arc needs the group's slot more often
than a texture character does, or the arc takes 30 episodes to resolve."""
pool = list(entries)
picked = []
while pool and len(picked) < k:
weights = [max(float(getattr(e[0], "group_weight", 1.0) or 1.0), 0.0) for e in pool]
total = sum(weights)
if total <= 0:
picked.append(pool.pop(rng.randrange(len(pool))))
continue
target = rng.random() * total
upto = 0.0
for i, w in enumerate(weights):
upto += w
if target <= upto:
picked.append(pool.pop(i))
break
else:
picked.append(pool.pop())
return picked
def _select_regulars_for_tonight(active_regulars, shows_since_last, rng=random):
"""Pick tonight's regulars, collapsing factions so one group can't crowd out
everyone else. Returns [(regular, misses, prob)].
A group normally gets a single slot. On a GROUP_TWO_UP_CHANCE roll it gets
two, preferring the group lead plus one other member — the leader's version
of events and someone else's, in the same episode."""
candidates = []
for r in active_regulars:
misses = shows_since_last(r.name)
prob = min(1.0, 0.4 + 0.3 * misses)
if rng.random() < prob:
candidates.append((r, misses, prob))
grouped: dict[str, list] = {}
selected: list = []
for entry in candidates:
group = getattr(entry[0], "group", "")
if group:
grouped.setdefault(group, []).append(entry)
else:
selected.append(entry)
for members in grouped.values():
cap = 2 if rng.random() < GROUP_TWO_UP_CHANCE else 1
if len(members) <= cap:
selected.extend(members)
continue
leads = [m for m in members if getattr(m[0], "group_lead", False)]
rest = [m for m in members if not getattr(m[0], "group_lead", False)]
if cap >= 2 and leads and rest:
selected.extend([leads[0]] + _weighted_sample(rest, 1, rng))
else:
selected.extend(_weighted_sample(members, cap, rng))
rng.shuffle(selected)
return selected[:MAX_REGULARS_PER_SHOW]
def _load_lineup_history() -> list[dict]:
@@ -2573,7 +2655,9 @@ async def get_callers():
return {
"callers": callers,
"current": session.current_caller_key,
"session_id": session.id
"session_id": session.id,
"lineup_ready": bool(session.caller_backgrounds),
"lineup_count": len(session.caller_backgrounds),
}
@@ -2630,6 +2714,13 @@ async def start_call(caller_key: str):
if caller_key not in CALLER_BASES:
raise HTTPException(404, "Caller not found")
# Backgrounds only land in the session via populate_backgrounds(), which used
# to be reachable only through POST /api/session/reset. Taking calls without
# hitting reset left caller_backgrounds empty for the whole show.
if not session.caller_backgrounds:
print("[Session] No caller lineup loaded — populating before first call")
await session.populate_backgrounds()
# Guard against double-click or rapid switching
if session.current_caller_key == caller_key:
return {"status": "already_on_call", "caller_key": caller_key}
+1 -1
View File
@@ -72,7 +72,7 @@ class AudioService:
self.ident_channel: int = 15 # Channel for idents (stereo: ch 15+16)
self.monitor_device: Optional[int] = 14 # Babyface Pro (headphone monitoring)
self.monitor_channel: int = 1 # Channel for mic monitoring on monitor device
self.phone_filter: bool = False # Phone filter on caller voices
self.phone_filter: bool = False # Phone filter on caller voices — applied in post, not baked in
# Ad playback state
self._ad_stream: Optional[sd.OutputStream] = None
+15
View File
@@ -66,6 +66,14 @@ def resolve_voice(suggestion: str, roster: list[str]) -> str:
return random.choice(roster)
# Per-character dial rather than a global switch, so explicitness varies by who
# called instead of the whole show moving together.
_EXPLICITNESS_GUIDANCE = {
"low": "alludes to sex obliquely at most, deflects or changes the subject when it comes up — their discomfort is part of the characterization",
"medium": "references sex bluntly and matter-of-factly when it's relevant, but doesn't dwell or go into detail",
"high": "talks about sex and kink frankly, graphically, and without embarrassment — it is simply how this person talks",
}
BATCH_SYSTEM_PROMPT = """You are writing a roster of callers for Luke's late-night radio show, broadcast out of Alpine, Texas, in the Big Bend country of far West Texas.
WHERE THESE CALLERS LIVE: The show's world is the Big Bend region of far West Texas — high desert, remote, dark skies, ranching country up against the Mexico border. Most callers are from or know this area. Ground them in REAL places and facts only — never invent businesses or landmarks that don't exist. The towns:
@@ -92,6 +100,8 @@ ANTI-COLLISION RULE — THIS IS NON-NEGOTIABLE: All callers in this roster must
Do not default to sitcom plots. Real humans are specific and strange. Give each caller details that could only belong to them.
ADULT SHOW: This is a late-night show for adults. Callers are adults and may talk about sex, relationships, and kink frankly and without euphemism when it fits who they are this is not a family program and you should not sanitize a caller whose story is genuinely about sex. But do NOT make the roster horny: at most 1-2 of the 10 callers should have sex as their central subject, or the show flattens into one note. Everyone involved in any sexual material is a consenting adult. Where a situation involves pressure, manipulation, or coercion, write it with real weight that is a harm, not a punchline, and the host should be able to react to it as one.
OPENING LINE RULES: Each caller's opening_line must be unique and specific to their situation. NEVER write "I've been listening for X years" or "long-time listener, first-time caller" or any variant. The caller should jump into their story or problem immediately — nervous, excited, angry, whatever fits. The opening line is the hook that makes the audience lean in.
You will output strict JSON with a "callers" array. Each caller has exactly these fields: name, age, voice_suggestion, location, identity, situation, reason_calling, opening_line, secret_want, specific_details (array of 2-3 strings), emotional_register."""
@@ -133,6 +143,9 @@ def build_batch_prompt(ctx: dict) -> str:
lines.append(f"### {r['name']}")
lines.append(r["lore"])
lines.append(f"Current arc state: {r['arc_state']}")
if r.get("register"):
lines.append(f"How {r['name']} plays: {r['register']}")
lines.append(f"Explicitness for {r['name']}: {_EXPLICITNESS_GUIDANCE[r.get('explicitness', 'low')]}")
lines.append("")
lines.append(f"For {r['name']}: invent a fresh reason he is calling tonight — a new development, grievance, or specific recent event. DO NOT alter his voice, personality, or core traits. Write a new scene for an existing character.")
lines.append(f"CRITICAL — this caller MUST appear in the output JSON with EXACTLY these fields locked: name=\"{r['name']}\", voice_suggestion=\"{r['voice']}\", age={r['age']}. Do NOT rename this caller. Do NOT give him a different voice. Do NOT change his age. Only invent: location, identity, situation, reason_calling, opening_line, secret_want, specific_details, emotional_register.")
@@ -191,6 +204,8 @@ async def generate_regular_situation(regular: dict, ctx: dict) -> dict:
f"### {regular['name']}",
regular["lore"],
f"Current arc state: {regular['arc_state']}",
*([f"How {regular['name']} plays: {regular['register']}"] if regular.get("register") else []),
f"Explicitness for {regular['name']}: {_EXPLICITNESS_GUIDANCE[regular.get('explicitness', 'low')]}",
"",
f"Invent a fresh reason {regular['name']} is calling tonight — a new development, grievance, or specific recent event. DO NOT alter his voice, personality, or core traits.",
"",
+71 -1
View File
@@ -8,6 +8,60 @@ from ..config import settings
from .cost_tracker import cost_tracker
# Refusals come back as ordinary 200s, so a text heuristic is the backstop when
# the API's own refusal signals are absent.
#
# Tuned for PRECISION, not recall. Callers on this show say "Luke, I can't help
# with that" and "I can't continue pretending everything's fine" constantly — a
# false positive silences a working call, which is worse than missing a refusal.
# So bare refusal verbs don't count; only meta-language a caller would never use.
_REFUSAL_META = (
"as an ai",
"as a language model",
"i'm an ai",
"i am an ai",
"i'm claude",
"against my guidelines",
"content policy",
"usage policies",
)
# A refusal needs BOTH a refusal verb and a meta object. "I won't write her
# another letter" has the verb but no meta object, so it stays on the air.
_REFUSAL_VERBS = (
"i can't",
"i cannot",
"i won't",
"i will not",
"i'm not going to",
"i am not going to",
"i'm not able to",
"i'm not comfortable",
"i am not comfortable",
)
_REFUSAL_OBJECTS = (
"request",
"content",
"guidelines",
"policy",
"policies",
"this material",
"that material",
"this prompt",
)
def _looks_like_refusal(text: str) -> bool:
head = text.strip().lower()[:200]
if not head:
return False
if any(m in head for m in _REFUSAL_META):
return True
return (any(v in head for v in _REFUSAL_VERBS)
and any(o in head for o in _REFUSAL_OBJECTS))
# Available OpenRouter models
OPENROUTER_MODELS = [
# Primary
@@ -382,8 +436,24 @@ class LLMService:
latency_ms=latency_ms,
caller_name=caller_name,
)
content = data["choices"][0]["message"]["content"]
choice = data["choices"][0]
message = choice.get("message", {})
content = message.get("content")
# A content refusal is not an exception — it arrives as a normal 200.
# Left undetected it either reaches air as broken-character text or
# reads as dead silence, with no signal saying which knob caused it.
refusal = message.get("refusal")
if refusal:
print(f"[LLM-REFUSAL] {model} refused ({category}, caller={caller_name or 'n/a'}): {str(refusal)[:200]}")
return None
if choice.get("finish_reason") == "content_filter":
print(f"[LLM-REFUSAL] {model} content-filtered ({category}, caller={caller_name or 'n/a'})")
return None
if content and content.strip():
if _looks_like_refusal(content):
print(f"[LLM-REFUSAL] {model} returned a refusal-shaped reply ({category}, caller={caller_name or 'n/a'}): {content.strip()[:160]}")
return None
return content
print(f"[LLM] {model} returned empty response")
return None
+20
View File
@@ -24,6 +24,12 @@ class Regular:
arc_state: str
lore_body: str
file_path: Path
group: str = "" # Faction key. Members of a group are capped per show.
group_lead: bool = False # On a two-up night, the lead is preferred + one other
group_weight: float = 1.0 # Relative odds of taking the group's slot. Anchors
# carrying an arc need slots faster than texture does.
register: str = "" # How this character plays: dry, evangelical, exhausted...
explicitness: str = "low" # low | medium | high — how frankly they discuss sex
def load_regular(path: Path) -> Regular:
@@ -37,6 +43,15 @@ def load_regular(path: Path) -> Regular:
if ":" in line:
k, v = line.split(":", 1)
fm[k.strip()] = v.strip()
explicitness = fm.get("explicitness", "low").strip().lower()
if explicitness not in ("low", "medium", "high"):
explicitness = "low"
try:
group_weight = float(fm.get("group_weight", 1.0))
except (TypeError, ValueError):
group_weight = 1.0
if group_weight <= 0:
group_weight = 1.0
return Regular(
name=fm["name"],
voice=fm["voice"],
@@ -44,6 +59,11 @@ def load_regular(path: Path) -> Regular:
arc_state=fm.get("arc_state", ""),
lore_body=body,
file_path=path,
group=fm.get("group", "").strip(),
group_lead=fm.get("group_lead", "").strip().lower() in ("true", "yes", "1"),
group_weight=group_weight,
register=fm.get("register", "").strip(),
explicitness=explicitness,
)