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
+2
View File
@@ -75,6 +75,8 @@ social_posts/
# Generated analytics artifacts (rebuilt from cost_tracker) # Generated analytics artifacts (rebuilt from cost_tracker)
data/costs.db data/costs.db
data/costs.db-shm
data/costs.db-wal
data/cost_reports/ data/cost_reports/
# Generated caller avatars # Generated caller avatars
+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: def get_caller_prompt(caller: dict, theme: str = "") -> str:
"""Caller system prompt. Identity carries the weight.""" """Caller system prompt. Identity carries the weight."""
name = caller.get("name", "") name = caller.get("name", "")
@@ -547,6 +556,12 @@ def get_caller_prompt(caller: dict, theme: str = "") -> str:
situation = caller.get("situation", "") situation = caller.get("situation", "")
reason = caller.get("reason_calling", "") reason = caller.get("reason_calling", "")
want = caller.get("secret_want", "") 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", "") opening = caller.get("opening_line", "")
details = caller.get("specific_details", []) or [] details = caller.get("specific_details", []) or []
detail_str = " | ".join(f"- {d}" for d in details) detail_str = " | ".join(f"- {d}" for d in details)
@@ -952,17 +967,14 @@ class Session:
return i return i
return 999 # never appeared return 999 # never appeared
regulars_for_tonight: list[dict] = [] selected = _select_regulars_for_tonight(active_regulars, _shows_since_last)
for r in active_regulars: regulars_for_tonight: list[dict] = [
misses = _shows_since_last(r.name) {"name": r.name, "voice": r.voice, "age": r.age,
prob = min(1.0, 0.4 + 0.3 * misses) "lore": r.lore_body, "arc_state": r.arc_state,
if random.random() < prob: "register": r.register, "explicitness": r.explicitness,
regulars_for_tonight.append( "_misses": misses, "_prob": prob}
{"name": r.name, "voice": r.voice, "age": r.age, for r, misses, prob in selected
"lore": r.lore_body, "arc_state": r.arc_state, ]
"_misses": misses, "_prob": prob}
)
regulars_for_tonight = regulars_for_tonight[:3]
if regulars_for_tonight: if regulars_for_tonight:
names = ", ".join(f"{r['name']}(miss={r['_misses']},p={r['_prob']:.2f})" names = ", ".join(f"{r['name']}(miss={r['_misses']},p={r['_prob']:.2f})"
for r in regulars_for_tonight) for r in regulars_for_tonight)
@@ -1152,10 +1164,10 @@ class Session:
self.start_prewarm() self.start_prewarm()
def _get_recent_summaries(self) -> list[str]: def _get_recent_summaries(self) -> list[str]:
"""Return caller name+situation strings from the last 2 lineups, so Sonnet """Return caller name+situation strings from the last LINEUP_DEDUP_SHOWS
can avoid repeating archetypes. Empty list on first run.""" lineups, so Sonnet can avoid repeating archetypes. Empty list on first run."""
history = _load_lineup_history() history = _load_lineup_history()
recent = history[-2:] recent = history[-LINEUP_DEDUP_SHOWS:]
summaries: list[str] = [] summaries: list[str] = []
for record in recent: for record in recent:
for caller in record.get("lineup", []): 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 (for anti-repeat context across sessions) ---
LINEUP_HISTORY_FILE = Path(__file__).parent.parent / "data" / "caller_lineups.json" 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]: def _load_lineup_history() -> list[dict]:
@@ -2573,7 +2655,9 @@ async def get_callers():
return { return {
"callers": callers, "callers": callers,
"current": session.current_caller_key, "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: if caller_key not in CALLER_BASES:
raise HTTPException(404, "Caller not found") 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 # Guard against double-click or rapid switching
if session.current_caller_key == caller_key: if session.current_caller_key == caller_key:
return {"status": "already_on_call", "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.ident_channel: int = 15 # Channel for idents (stereo: ch 15+16)
self.monitor_device: Optional[int] = 14 # Babyface Pro (headphone monitoring) self.monitor_device: Optional[int] = 14 # Babyface Pro (headphone monitoring)
self.monitor_channel: int = 1 # Channel for mic monitoring on monitor device 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 # Ad playback state
self._ad_stream: Optional[sd.OutputStream] = None 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) 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. 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: 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. 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. 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.""" 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(f"### {r['name']}")
lines.append(r["lore"]) lines.append(r["lore"])
lines.append(f"Current arc state: {r['arc_state']}") 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("")
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"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.") 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']}", f"### {regular['name']}",
regular["lore"], regular["lore"],
f"Current arc state: {regular['arc_state']}", 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.", 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 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 # Available OpenRouter models
OPENROUTER_MODELS = [ OPENROUTER_MODELS = [
# Primary # Primary
@@ -382,8 +436,24 @@ class LLMService:
latency_ms=latency_ms, latency_ms=latency_ms,
caller_name=caller_name, 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 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 return content
print(f"[LLM] {model} returned empty response") print(f"[LLM] {model} returned empty response")
return None return None
+20
View File
@@ -24,6 +24,12 @@ class Regular:
arc_state: str arc_state: str
lore_body: str lore_body: str
file_path: Path 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: def load_regular(path: Path) -> Regular:
@@ -37,6 +43,15 @@ def load_regular(path: Path) -> Regular:
if ":" in line: if ":" in line:
k, v = line.split(":", 1) k, v = line.split(":", 1)
fm[k.strip()] = v.strip() 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( return Regular(
name=fm["name"], name=fm["name"],
voice=fm["voice"], voice=fm["voice"],
@@ -44,6 +59,11 @@ def load_regular(path: Path) -> Regular:
arc_state=fm.get("arc_state", ""), arc_state=fm.get("arc_state", ""),
lore_body=body, lore_body=body,
file_path=path, 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,
) )
@@ -0,0 +1,172 @@
# The Wellspring Callers — Design
**Date:** 2026-08-15
**Status:** Approved, not yet implemented
## Problem
Silas is the show's strongest recurring character, but he is the only window into
The Wellspring — and he controls the framing entirely. He describes a warm
commune of seekers. Nobody contradicts him. The bit is funny but static, and the
cult has no weight because there are no stakes the audience can see.
## Goal
Add callers from inside The Wellspring who offer perspectives Silas does not
control — including frightened ones — so the commune becomes a place with real
consequences rather than a running gag. Keep Silas playable as comedy.
## Structure
An **anchor** character with a progressing arc, plus a **pool** of rotating
members for fresh angles.
- The anchor carries continuity and escalating dread.
- The pool supplies variety and prevents the counter-perspective from
flattening into one note.
- Silas remains the leader's version of events.
## The anchor: Doyle
```yaml
name: Doyle
voice: Grant # plain, tight — deliberate contrast with Silas's Sebastian
age: 47
group: wellspring
register: dry, understated, factual
explicitness: low
```
Two years in. Joined eight months after his divorce, when a member sold him soap
at a farmers market in Las Cruces and asked him a question nobody had asked him
in a year. He runs the soap kettles — twelve-hour days, physical, unglamorous.
The soap room is the engine of the character: **it is where the money is, and
nobody spiritual pays attention to it.** He sees shipping manifests, deposit
slips, whose names are on which accounts. He is not a seeker. He is the guy who
fixes the pump.
**His fear is specific.** He is not afraid of being hurt. He is afraid of being
right. He has a truck, a CDL, and nowhere to be — he could leave tomorrow, and
has been telling himself that for eleven months. What frightens him is that he
keeps not leaving and can no longer tell whether that is his choice.
**Calling mechanic.** No signal at the Terlingua property. Doyle calls from town
on supply runs — a gas station lot in Study Butte, engine running, twenty
minutes before someone notices the truck has been gone too long. This caps call
length naturally, explains why he cannot talk long, and gives the host a ticking
clock. An abrupt hangup is in character, not a failure.
**Register.** He describes appalling things in the flat voice of a man reading a
parts list. The comedy and the dread come from the same place: he does not
editorialize.
## The pool
Six members, each an ordinary regular file, drawn one at a time.
| Member | Voice | Age | Register | Explicitness |
|---|---|---|---|---|
| Renata — true believer | Celeste | 34 | Radiantly happy, evangelical, funny | high |
| Alvin — bookkeeper | Conrad | 61 | Dry, precise, funny-then-cold | low |
| Junie — grew up there | Pippa | 19 | Guileless, no frame of reference | low |
| Ford — fourteen days in | Levi | 28 | Love-bombed, evangelical, too fast | medium |
| Marguerite — wants out | Marlene | 58 | Exhausted, practical, sad | medium |
| Cyrus — inner circle | Damon | 41 | Charming, then not | medium |
**Junie is a fixed constraint.** Someone who grew up inside the commune is the
show's darkest available note, and she works precisely because nothing explicit
ever attaches to her. Her horror is that she believes all of it is normal. Her
`explicitness: low` is not a default — it does not change.
## Tone
Mostly unsettling-but-comic, with the capacity to go genuinely dark. The
Wellspring should carry real weight without every call being heavy. Register is
assigned per character rather than globally, so the true believer plays funny in
the same season Doyle plays frightening.
**Adult content.** The show is adult and the Wellspring's sexuality is already
load-bearing in canon. Characters may talk frankly about sex and kink. This is
controlled per character via `explicitness: low | medium | high` rather than a
global switch, so explicitness varies by who called and the show does not
flatten into wall-to-wall smut.
**The one hard separation: consensual weirdness and coercion stay in different
lanes.** An openly horny commune with elaborate rituals is the comedy. Silas
pressuring a member into participating is the horror — that is where the weight
comes from. Blurring them casually makes the show mean rather than dark and
makes Silas irredeemable, which costs the character. Existing canon already
handles this correctly: his 2026-03-15 reckoning treats the coercion as real
harm.
## Selection mechanics
Current behavior (`backend/main.py`, `_build_backgrounds`): every active regular
draws independently at `min(1.0, 0.4 + 0.3 × shows_missed)`, then the list is
truncated to 3.
Adding eight Wellspring files to that would let the commune eat every regular
slot. So:
1. Partition regulars by the new `group` frontmatter field.
2. Ungrouped regulars behave exactly as today.
3. Within `group: wellspring`, members draw on their existing per-member
probability, then the group is **capped**:
- **1** Wellspring caller per episode normally
- **2** on a 15% roll — a "both sides tonight" episode
- On a two-night, prefer Silas plus one non-Silas member
4. Group cap applies before the global 3-regular truncation.
## Data flow
```
~/code/dotfiles/regulars/*.md (+ silas/silas.md)
↓ regulars_v2.load_all_active_regulars()
↓ Regular gains: group, register, explicitness
↓ _build_backgrounds() — group partition + cap
↓ regulars_included
↓ caller_gen.build_batch_prompt() — renders register + explicitness
↓ Sonnet 4.6 batch
↓ session.caller_backgrounds
↓ get_caller_prompt() — live dialog
```
Touch points:
- `regulars_v2.py:29` `load_regular()` — parse four new frontmatter fields
(`group`, `group_lead`, `register`, `explicitness`), defaulting so existing
files keep working
- `regulars_v2.py:19` `Regular` dataclass — four new fields
- `main.py` `_build_backgrounds()` — group partition and cap
- `caller_gen.py:132-138` — render `register` and `explicitness` into the batch
prompt (currently renders only name, lore, arc_state)
- Eight new markdown files; one frontmatter line added to `silas.md`
## Failure mode to guard
Model refusals do not look like errors. `llm.py:385-395` handles exceptions and
empty completions by returning `None` with a printed message, but a content
refusal typically arrives as a **200 with refusal text**, which flows through as
ordinary dialog and breaks character on air — or as an empty completion that
reads as dead air.
Add explicit detection so a refusal is visibly distinct from a caller who simply
had nothing to say, surfaced in the host's log rather than only stdout. Without
this, tuning `explicitness: high` upward has no feedback signal.
## Testing
- `load_regular()` parses the new fields; files lacking them still load
- Group cap holds: 8 Wellspring regulars never yield more than 2 in a lineup
- Two-night rate approximates 15% over many seeded draws
- On a two-night, Silas is preferred plus exactly one non-Silas member
- Ungrouped regulars are unaffected by group logic
- Batch prompt contains `register` and `explicitness` for each included regular
- Junie's `explicitness` is `low` (guards against a careless edit)
## Out of scope
- Arc progression automation — arc_state stays hand-edited, as today
- Promotion of pool members to anchors
- Any change to how walk-in callers are generated
+19
View File
@@ -246,6 +246,25 @@ header button:hover, .header-link-btn:hover {
font-weight: normal; font-weight: normal;
} }
.lineup-status {
font-size: 0.7rem;
font-weight: 600;
margin-left: 0.5rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
vertical-align: middle;
}
.lineup-status.ready {
color: #4ade80;
background: rgba(74, 222, 128, 0.12);
}
.lineup-status.empty {
color: #f87171;
background: rgba(248, 113, 113, 0.15);
}
details.caller-background { details.caller-background {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted); color: var(--text-muted);
+1 -1
View File
@@ -43,7 +43,7 @@
<main> <main>
<!-- Callers --> <!-- Callers -->
<section class="callers-section"> <section class="callers-section">
<h2>Callers <span id="session-id" class="session-id"></span></h2> <h2>Callers <span id="session-id" class="session-id"></span><span id="lineup-status" class="lineup-status"></span></h2>
<div id="callers" class="caller-grid"></div> <div id="callers" class="caller-grid"></div>
<!-- Active Call Indicator --> <!-- Active Call Indicator -->
<div id="active-call" class="active-call hidden"> <div id="active-call" class="active-call hidden">
+16 -1
View File
@@ -621,7 +621,22 @@ async function loadCallers() {
sessionEl.textContent = `(${data.session_id})`; sessionEl.textContent = `(${data.session_id})`;
} }
console.log('Loaded', data.callers.length, 'callers, session:', data.session_id); // Lineup readiness — an empty lineup means callers have no identity and
// every call collapses into generic filler, so make it visible pre-show.
const lineupEl = document.getElementById('lineup-status');
if (lineupEl) {
if (data.lineup_ready) {
lineupEl.textContent = `${data.lineup_count} ready`;
lineupEl.className = 'lineup-status ready';
lineupEl.title = 'Caller identities are loaded';
} else {
lineupEl.textContent = 'no lineup';
lineupEl.className = 'lineup-status empty';
lineupEl.title = 'No caller identities loaded — hit Reset Session before going live';
}
}
console.log('Loaded', data.callers.length, 'callers, session:', data.session_id, 'lineup:', data.lineup_count);
} catch (err) { } catch (err) {
console.error('loadCallers error:', err); console.error('loadCallers error:', err);
} }
+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(): 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() summary = s.get_conversation_summary()
assert "Dave" in summary assert "Dave" in summary
assert "Tony" 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"