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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user