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:
@@ -75,6 +75,8 @@ social_posts/
|
||||
|
||||
# Generated analytics artifacts (rebuilt from cost_tracker)
|
||||
data/costs.db
|
||||
data/costs.db-shm
|
||||
data/costs.db-wal
|
||||
data/cost_reports/
|
||||
|
||||
# Generated caller avatars
|
||||
|
||||
+104
-13
@@ -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(
|
||||
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}
|
||||
)
|
||||
regulars_for_tonight = regulars_for_tonight[:3]
|
||||
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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -246,6 +246,25 @@ header button:hover, .header-link-btn:hover {
|
||||
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 {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@
|
||||
<main>
|
||||
<!-- Callers -->
|
||||
<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>
|
||||
<!-- Active Call Indicator -->
|
||||
<div id="active-call" class="active-call hidden">
|
||||
|
||||
+16
-1
@@ -621,7 +621,22 @@ async function loadCallers() {
|
||||
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) {
|
||||
console.error('loadCallers error:', err);
|
||||
}
|
||||
|
||||
@@ -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