diff --git a/.gitignore b/.gitignore index 98bc672..a70882d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/backend/main.py b/backend/main.py index 19a715f..8fca9bb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -540,6 +540,15 @@ def detect_host_mood(messages: list[dict], wrapping_up: bool = False) -> str: +class EmptyCallerBackgroundError(RuntimeError): + """Raised when a caller prompt is built from a background with no identity. + + A hollow prompt still generates dialog — the model invents the most generic + late-night radio call it can (relationship trouble, every time) and a regular + like Silas loses all his lore. That failure is silent and costs a whole show, + so refuse to build the prompt instead.""" + + def get_caller_prompt(caller: dict, theme: str = "") -> str: """Caller system prompt. Identity carries the weight.""" name = caller.get("name", "") @@ -547,6 +556,12 @@ def get_caller_prompt(caller: dict, theme: str = "") -> str: situation = caller.get("situation", "") reason = caller.get("reason_calling", "") want = caller.get("secret_want", "") + if not (name or identity or situation): + raise EmptyCallerBackgroundError( + "Refusing to build a caller prompt with no name, identity, or situation — " + "session.caller_backgrounds was never populated. Call populate_backgrounds() " + "(POST /api/session/reset) before taking calls." + ) opening = caller.get("opening_line", "") details = caller.get("specific_details", []) or [] detail_str = " | ".join(f"- {d}" for d in details) @@ -952,17 +967,14 @@ class Session: return i return 999 # never appeared - regulars_for_tonight: list[dict] = [] - for r in active_regulars: - misses = _shows_since_last(r.name) - prob = min(1.0, 0.4 + 0.3 * misses) - if random.random() < prob: - regulars_for_tonight.append( - {"name": r.name, "voice": r.voice, "age": r.age, - "lore": r.lore_body, "arc_state": r.arc_state, - "_misses": misses, "_prob": prob} - ) - regulars_for_tonight = regulars_for_tonight[:3] + selected = _select_regulars_for_tonight(active_regulars, _shows_since_last) + regulars_for_tonight: list[dict] = [ + {"name": r.name, "voice": r.voice, "age": r.age, + "lore": r.lore_body, "arc_state": r.arc_state, + "register": r.register, "explicitness": r.explicitness, + "_misses": misses, "_prob": prob} + for r, misses, prob in selected + ] if regulars_for_tonight: names = ", ".join(f"{r['name']}(miss={r['_misses']},p={r['_prob']:.2f})" for r in regulars_for_tonight) @@ -1152,10 +1164,10 @@ class Session: self.start_prewarm() def _get_recent_summaries(self) -> list[str]: - """Return caller name+situation strings from the last 2 lineups, so Sonnet - can avoid repeating archetypes. Empty list on first run.""" + """Return caller name+situation strings from the last LINEUP_DEDUP_SHOWS + lineups, so Sonnet can avoid repeating archetypes. Empty list on first run.""" history = _load_lineup_history() - recent = history[-2:] + recent = history[-LINEUP_DEDUP_SHOWS:] summaries: list[str] = [] for record in recent: for caller in record.get("lineup", []): @@ -1257,7 +1269,77 @@ CHECKPOINT_LINEUP_MAX_AGE = 3600 # 1 hour # --- Lineup History (for anti-repeat context across sessions) --- LINEUP_HISTORY_FILE = Path(__file__).parent.parent / "data" / "caller_lineups.json" -LINEUP_HISTORY_MAX = 10 +LINEUP_HISTORY_MAX = 20 +# How many past lineups to show the batch generator as "don't repeat these". +# Replaces the cross-episode topic dedup deleted in Phase 5B (commit 83c7f44), +# which left only a 2-show window and let archetypes recycle. +LINEUP_DEDUP_SHOWS = 10 + +MAX_REGULARS_PER_SHOW = 3 +GROUP_TWO_UP_CHANCE = 0.15 # Odds a faction gets two slots — "both sides tonight" + + +def _weighted_sample(entries, k, rng): + """Sample k of [(regular, misses, prob)] without replacement, weighted by + group_weight. An anchor carrying an arc needs the group's slot more often + than a texture character does, or the arc takes 30 episodes to resolve.""" + pool = list(entries) + picked = [] + while pool and len(picked) < k: + weights = [max(float(getattr(e[0], "group_weight", 1.0) or 1.0), 0.0) for e in pool] + total = sum(weights) + if total <= 0: + picked.append(pool.pop(rng.randrange(len(pool)))) + continue + target = rng.random() * total + upto = 0.0 + for i, w in enumerate(weights): + upto += w + if target <= upto: + picked.append(pool.pop(i)) + break + else: + picked.append(pool.pop()) + return picked + + +def _select_regulars_for_tonight(active_regulars, shows_since_last, rng=random): + """Pick tonight's regulars, collapsing factions so one group can't crowd out + everyone else. Returns [(regular, misses, prob)]. + + A group normally gets a single slot. On a GROUP_TWO_UP_CHANCE roll it gets + two, preferring the group lead plus one other member — the leader's version + of events and someone else's, in the same episode.""" + candidates = [] + for r in active_regulars: + misses = shows_since_last(r.name) + prob = min(1.0, 0.4 + 0.3 * misses) + if rng.random() < prob: + candidates.append((r, misses, prob)) + + grouped: dict[str, list] = {} + selected: list = [] + for entry in candidates: + group = getattr(entry[0], "group", "") + if group: + grouped.setdefault(group, []).append(entry) + else: + selected.append(entry) + + for members in grouped.values(): + cap = 2 if rng.random() < GROUP_TWO_UP_CHANCE else 1 + if len(members) <= cap: + selected.extend(members) + continue + leads = [m for m in members if getattr(m[0], "group_lead", False)] + rest = [m for m in members if not getattr(m[0], "group_lead", False)] + if cap >= 2 and leads and rest: + selected.extend([leads[0]] + _weighted_sample(rest, 1, rng)) + else: + selected.extend(_weighted_sample(members, cap, rng)) + + rng.shuffle(selected) + return selected[:MAX_REGULARS_PER_SHOW] def _load_lineup_history() -> list[dict]: @@ -2573,7 +2655,9 @@ async def get_callers(): return { "callers": callers, "current": session.current_caller_key, - "session_id": session.id + "session_id": session.id, + "lineup_ready": bool(session.caller_backgrounds), + "lineup_count": len(session.caller_backgrounds), } @@ -2630,6 +2714,13 @@ async def start_call(caller_key: str): if caller_key not in CALLER_BASES: raise HTTPException(404, "Caller not found") + # Backgrounds only land in the session via populate_backgrounds(), which used + # to be reachable only through POST /api/session/reset. Taking calls without + # hitting reset left caller_backgrounds empty for the whole show. + if not session.caller_backgrounds: + print("[Session] No caller lineup loaded — populating before first call") + await session.populate_backgrounds() + # Guard against double-click or rapid switching if session.current_caller_key == caller_key: return {"status": "already_on_call", "caller_key": caller_key} diff --git a/backend/services/audio.py b/backend/services/audio.py index 683cd7b..edefb88 100644 --- a/backend/services/audio.py +++ b/backend/services/audio.py @@ -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 diff --git a/backend/services/caller_gen.py b/backend/services/caller_gen.py index 6893e68..1e888f1 100644 --- a/backend/services/caller_gen.py +++ b/backend/services/caller_gen.py @@ -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.", "", diff --git a/backend/services/llm.py b/backend/services/llm.py index d3ca2aa..e6dbb0e 100644 --- a/backend/services/llm.py +++ b/backend/services/llm.py @@ -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 diff --git a/backend/services/regulars_v2.py b/backend/services/regulars_v2.py index 1e13303..fc1a576 100644 --- a/backend/services/regulars_v2.py +++ b/backend/services/regulars_v2.py @@ -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, ) diff --git a/docs/plans/2026-08-15-wellspring-callers-design.md b/docs/plans/2026-08-15-wellspring-callers-design.md new file mode 100644 index 0000000..db2aa6a --- /dev/null +++ b/docs/plans/2026-08-15-wellspring-callers-design.md @@ -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 diff --git a/frontend/css/style.css b/frontend/css/style.css index 821d20c..129fa85 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -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); diff --git a/frontend/index.html b/frontend/index.html index 1493ef4..6b41529 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -43,7 +43,7 @@
-

Callers

+

Callers