Merge branch 'feature/caller-redesign' into main

Brings in the Phase 5B caller-generation rewrite:
- Slim two-stage pipeline: Sonnet 4.6 batch identity pregen at session
  start, Haiku 4.5 live dialog per turn. Deletes CallerBackground
  dataclass, shape/style/voice-matching, per-model routing, preflight UI.
- New caller_gen.py (slim prompt builder) and regulars_v2.py (Obsidian
  lore loader for named recurring callers).
- Reworked caller buttons and info panel around the slim background:
  identity, situation, signature, secret want. Removes shape badges,
  energy dots, emotion info-badges.
- Inworld TTS: emotional_register -> (temperature, speed_adjust) mapping
  via _emotional_register_to_params(). applyTextNormalization ON.
- Silas identity/voice leak fix, avatar gender, pre-warm batch gen.
- Archives old regulars to data/regulars.archived.json, adds 10 sample
  caller transcripts under docs/samples/.

Post-merge fixes applied during resolution:
- intern.py: kept main's richer new_show() (NEW SHOW history marker,
  trim, _save()) and _track_suggestion(); removed the branch's
  duplicate minimal new_show() stub.
- tts.py: added 5 voice speed overrides (Graham, Malcolm, Victoria,
  Loretta, Marlene) that main had tuned locally. Evelyn deliberately
  NOT added to VOICE_PROFILES — she is in BLACKLISTED_VOICES for
  unnatural prosody.
- Main's cost dashboard polish, LLM per-model params, and Devon show
  context memory from commits c087c03..61b3cba carried through cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 23:43:56 -06:00
co-authored by Claude Opus 4.6
29 changed files with 3089 additions and 7952 deletions
+1 -2
View File
@@ -34,9 +34,8 @@ class Settings(BaseSettings):
ollama_host: str = "http://localhost:11434"
# Per-category model routing
# caller_dialog is overridden by style_matched routing (see Session.caller_model_map)
category_models: dict = {
"caller_dialog": "x-ai/grok-4.1-fast", # fallback if style_matched disabled ($0.20/$0.50)
"caller_dialog": "anthropic/claude-haiku-4.5", # live caller dialog ($0.80/$4)
"devon_ask": "x-ai/grok-4.1-fast", # Devon matches show energy, cheap ($0.20/$0.50)
"devon_monitor": "google/gemini-2.5-flash", # just yes/no decisions, keep cheap ($0.15/$0.60)
"background_gen": "anthropic/claude-sonnet-4.6", # backgrounds drive the whole call — worth the quality ($3/$15, ~$0.30/show)
+364 -6939
View File
File diff suppressed because it is too large Load Diff
+185
View File
@@ -0,0 +1,185 @@
from dataclasses import dataclass
from typing import Optional
import json
import httpx
from ..config import settings
from .cost_tracker import cost_tracker
BATCH_MODEL = "anthropic/claude-sonnet-4.6"
REQUIRED_FIELDS = {
"name", "age", "voice_suggestion", "location", "identity",
"situation", "reason_calling", "opening_line", "secret_want",
"specific_details", "emotional_register"
}
@dataclass
class CallerIdentity:
name: str
age: int
voice_suggestion: str
location: str
identity: str
situation: str
reason_calling: str
opening_line: str
secret_want: str
specific_details: list[str]
emotional_register: str
# set after voice validation
voice_resolved: Optional[str] = None
def parse_batch_response(raw: str) -> list[CallerIdentity]:
stripped = raw.strip()
if stripped.startswith("```") and stripped.endswith("```"):
lines = stripped.splitlines()
stripped = "\n".join(lines[1:-1])
data = json.loads(stripped)
callers = data.get("callers", [])
result = []
for c in callers:
missing = REQUIRED_FIELDS - set(c.keys())
if missing:
raise ValueError(f"CallerIdentity missing fields: {missing}")
result.append(CallerIdentity(**{k: c[k] for k in REQUIRED_FIELDS}))
return result
def resolve_voice(suggestion: str, roster: list[str]) -> str:
"""Map sonnet's voice suggestion to a real voice in the roster.
Case-insensitive exact match; deterministic fallback to first roster entry."""
if not suggestion or not roster:
return roster[0] if roster else ""
lower_map = {v.lower(): v for v in roster}
return lower_map.get(suggestion.lower(), roster[0])
BATCH_SYSTEM_PROMPT = """You are writing a roster of callers for Luke's late-night radio show in New Mexico.
CREATIVE RANGE: Your callers must span the emotional range of Howard Stern (chaos, strong characters), Coast to Coast AM (earnest weirdos, sincere believers), Loveline (real problems, real advice-seeking), Delilah (emotional vulnerability, connection), and Opie and Anthony (sharp, irreverent, specific people).
Maximum character distance between callers. No two callers should feel like siblings. Do not default to sitcom plots. Real humans are specific and strange. Give each caller details that could only belong to them.
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."""
def build_batch_prompt(ctx: dict) -> str:
lines = [
f"Tonight is {ctx['date']}. {ctx['weather']}.",
"",
"Today's news headlines (ground callers in real context, but do not force topicality):",
]
for h in ctx["headlines"]:
lines.append(f"- {h}")
lines.append("")
if ctx["recent_caller_summaries"]:
lines.append("Recent callers (DO NOT repeat these archetypes or situations):")
for s in ctx["recent_caller_summaries"]:
lines.append(f"- {s}")
lines.append("")
if ctx["regulars_included"]:
lines.append("RECURRING CHARACTERS IN TONIGHT'S LINEUP:")
lines.append("")
for r in ctx["regulars_included"]:
lines.append(f"### {r['name']}")
lines.append(r["lore"])
lines.append(f"Current arc state: {r['arc_state']}")
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.")
lines.append("")
lines.append(f"Available voices (voice_suggestion must match one of these exactly):")
lines.append(", ".join(ctx["voice_roster"]))
lines.append("")
lines.append(f"Generate {ctx['caller_count']} callers. Output JSON only, no prose.")
return BATCH_SYSTEM_PROMPT + "\n\n" + "\n".join(lines)
async def generate_batch(ctx: dict) -> list[CallerIdentity]:
"""Call sonnet-4.6 with the batch prompt, parse + voice-resolve the response."""
prompt = build_batch_prompt(ctx)
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
json={
"model": BATCH_MODEL,
"messages": [{"role": "user", "content": prompt}],
"response_format": {"type": "json_object"},
"max_tokens": 8000,
"temperature": 0.9,
},
)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
usage = data.get("usage", {})
cost_tracker.record_llm_call(
category="background_gen",
model=BATCH_MODEL,
usage_data=usage,
)
callers = parse_batch_response(content)
for c in callers:
c.voice_resolved = resolve_voice(c.voice_suggestion, ctx["voice_roster"])
return callers
REGULAR_SYSTEM_PROMPT = """You are writing a single caller — a recurring character on Luke's late-night radio show. The character's identity is fixed (name, voice, age, core lore). You only invent a fresh reason he's calling tonight, grounded in his current arc state.
Output strict JSON with these fields only: location, identity, situation, reason_calling, opening_line, secret_want, specific_details (array of 2-3 strings), emotional_register. Do NOT include name, voice_suggestion, or age — those are locked."""
async def generate_regular_situation(regular: dict, ctx: dict) -> dict:
"""Generate a fresh situation for a locked regular. Returns a dict matching
the CallerIdentity schema. One small LLM call (~$0.01)."""
lines = [
f"Tonight is {ctx['date']}. {ctx['weather']}.",
"",
f"### {regular['name']}",
regular["lore"],
f"Current arc state: {regular['arc_state']}",
"",
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.",
"",
"Output JSON only, no prose.",
]
prompt = REGULAR_SYSTEM_PROMPT + "\n\n" + "\n".join(lines)
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
json={
"model": BATCH_MODEL,
"messages": [{"role": "user", "content": prompt}],
"response_format": {"type": "json_object"},
"max_tokens": 1500,
"temperature": 0.9,
},
)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
usage = data.get("usage", {})
cost_tracker.record_llm_call(
category="background_gen",
model=BATCH_MODEL,
usage_data=usage,
)
stripped = content.strip()
if stripped.startswith("```") and stripped.endswith("```"):
lines = stripped.splitlines()
stripped = "\n".join(lines[1:-1])
return json.loads(stripped)
+136
View File
@@ -0,0 +1,136 @@
from dataclasses import dataclass
from datetime import date
from pathlib import Path
import json
import re
import httpx
from ..config import settings
from .cost_tracker import cost_tracker
HOME = Path.home()
VAULT = HOME / "code" / "dotfiles"
SILAS_DIR = VAULT / "silas"
REGULARS_DIR = VAULT / "regulars"
ARCHIVED_DIR = REGULARS_DIR / "archived"
@dataclass
class Regular:
name: str
voice: str
age: int
arc_state: str
lore_body: str
file_path: Path
def load_regular(path: Path) -> Regular:
text = path.read_text()
m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.DOTALL)
if not m:
raise ValueError(f"No frontmatter in {path}")
fm_raw, body = m.group(1), m.group(2).strip()
fm = {}
for line in fm_raw.splitlines():
if ":" in line:
k, v = line.split(":", 1)
fm[k.strip()] = v.strip()
return Regular(
name=fm["name"],
voice=fm["voice"],
age=int(fm["age"]),
arc_state=fm.get("arc_state", ""),
lore_body=body,
file_path=path,
)
def load_all_active_regulars() -> list[Regular]:
out = []
if SILAS_DIR.exists():
for f in SILAS_DIR.glob("*.md"):
out.append(load_regular(f))
if REGULARS_DIR.exists():
for f in REGULARS_DIR.glob("*.md"):
out.append(load_regular(f))
return out
PROMOTION_MODEL = "anthropic/claude-sonnet-4.6"
PROMOTION_PROMPT = """You are evaluating whether a one-time caller should become a recurring character.
CALLER: {name}
TRANSCRIPT:
{transcript}
A recurring character must have a 3-5 episode arc with genuine progression — not just "calls weekly to complain about the same thing." The arc must have a possible resolution.
Output JSON:
{{"promote": true|false, "arc_plan": "...", "reason": "..."}}
Bar is HIGH. Only promote if the character has real internal conflict, growth potential, and a believable resolution trajectory."""
async def _call_sonnet(prompt: str) -> dict:
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
json={
"model": PROMOTION_MODEL,
"messages": [{"role": "user", "content": prompt}],
"response_format": {"type": "json_object"},
"max_tokens": 500,
},
)
resp.raise_for_status()
data = resp.json()
usage = data.get("usage", {})
cost_tracker.record_llm_call(
category="promotion_eval",
model=PROMOTION_MODEL,
usage_data=usage,
)
content = data["choices"][0]["message"]["content"].strip()
if content.startswith("```") and content.endswith("```"):
lines = content.splitlines()
content = "\n".join(lines[1:-1])
return json.loads(content)
async def evaluate_promotion(caller_name: str, call_transcript: str) -> dict:
prompt = PROMOTION_PROMPT.format(name=caller_name, transcript=call_transcript)
return await _call_sonnet(prompt)
def write_new_regular(name: str, voice: str, age: int, identity_paragraph: str,
arc_plan: str, first_call_summary: str) -> Path:
REGULARS_DIR.mkdir(parents=True, exist_ok=True)
slug = name.lower().replace(" ", "-")
path = REGULARS_DIR / f"{slug}.md"
today = date.today().isoformat()
content = f"""---
name: {name}
voice: {voice}
age: {age}
arc_state: {arc_plan}
promoted_on: {today}
---
# {name}
{identity_paragraph}
## Arc Plan
{arc_plan}
## Arc Log
- {today}: {first_call_summary}
"""
path.write_text(content)
return path
+50 -14
View File
@@ -128,6 +128,11 @@ INWORLD_SPEED_OVERRIDES = {
"Theodore": 1.15,
"Blake": 1.1,
"Priya": 1.1,
"Graham": 1.15,
"Malcolm": 1.15,
"Victoria": 1.1,
"Loretta": 1.1,
"Marlene": 1.1,
}
DEFAULT_INWORLD_SPEED = 1.1 # Slight bump for all voices
@@ -710,7 +715,33 @@ def _detect_speech_rate(text: str, base_speed: float) -> float:
return base_speed
async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, int]:
def _emotional_register_to_params(emotional_register: str) -> tuple[float, float]:
"""Map caller's emotional_register description to (temperature, speed_adjust).
speed_adjust is added to the base speed BEFORE clamping to 0.5-1.5.
Returns (0.9, 0.0) default for unknown/empty registers."""
if not emotional_register:
return (0.9, 0.0)
er = emotional_register.lower()
# Sadness/grief family
if any(kw in er for kw in ["fractured", "grief", "hollow", "sad", "mournful", "somber", "quietly desperate"]):
return (0.85, -0.1)
# Anger/aggression family
if any(kw in er for kw in ["angry", "combative", "furious", "aggressive", "sharp intelligence"]):
return (1.0, 0.1)
# Manic/excited family
if any(kw in er for kw in ["caffeinated", "manic", "giddy", "vibrating", "adrenaline", "frantic", "hyper-articulate"]):
return (1.0, 0.15)
# Nervous/earnest family
if any(kw in er for kw in ["earnest", "nervous", "unsettled", "anxious", "tentative", "quietly unsettled"]):
return (0.9, 0.0)
# Gruff/restrained family
if any(kw in er for kw in ["gruff", "measured", "precise", "stoic", "restrained", "deadpan", "forthright"]):
return (0.85, -0.05)
# Default
return (0.9, 0.0)
async def generate_speech_inworld(text: str, voice_id: str, emotional_register: str = "") -> tuple[np.ndarray, int]:
"""Generate speech using Inworld TTS API (high quality, natural voices)"""
import httpx
import base64
@@ -727,9 +758,10 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
if not api_key:
raise RuntimeError("INWORLD_API_KEY not set in environment")
base_speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED)
speed = _detect_speech_rate(text, base_speed)
print(f"[Inworld TTS] Voice: {voice}, Speed: {speed:.2f} (base {base_speed}), Text: {text[:50]}...")
temperature, speed_adjust = _emotional_register_to_params(emotional_register)
base_speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED) + speed_adjust
speed = max(0.5, min(1.5, _detect_speech_rate(text, base_speed)))
print(f"[Inworld TTS] Voice: {voice}, Speed: {speed:.2f} (base {base_speed:.2f}), Temp: {temperature}, Text: {text[:50]}...")
url = "https://api.inworld.ai/tts/v1/voice"
headers = {
@@ -740,6 +772,8 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
"text": text,
"voiceId": voice,
"modelId": "inworld-tts-1.5-max",
"temperature": temperature,
"applyTextNormalization": "ON",
"audioConfig": {
"audioEncoding": "LINEAR16",
"sampleRateHertz": 48000,
@@ -797,14 +831,14 @@ def pick_caller_tts_provider() -> str | None:
_TTS_PROVIDERS = {
"kokoro": lambda text, vid: generate_speech_kokoro(text, vid),
"f5tts": lambda text, vid: generate_speech_f5tts(text, vid),
"inworld": lambda text, vid: generate_speech_inworld(text, vid),
"chattts": lambda text, vid: generate_speech_chattts(text, vid),
"styletts2": lambda text, vid: generate_speech_styletts2(text, vid),
"bark": lambda text, vid: generate_speech_bark(text, vid),
"vits": lambda text, vid: generate_speech_vits(text, vid),
"elevenlabs": lambda text, vid: generate_speech_elevenlabs(text, vid),
"kokoro": lambda text, vid, er: generate_speech_kokoro(text, vid),
"f5tts": lambda text, vid, er: generate_speech_f5tts(text, vid),
"inworld": lambda text, vid, er: generate_speech_inworld(text, vid, emotional_register=er),
"chattts": lambda text, vid, er: generate_speech_chattts(text, vid),
"styletts2": lambda text, vid, er: generate_speech_styletts2(text, vid),
"bark": lambda text, vid, er: generate_speech_bark(text, vid),
"vits": lambda text, vid, er: generate_speech_vits(text, vid),
"elevenlabs": lambda text, vid, er: generate_speech_elevenlabs(text, vid),
}
TTS_MAX_RETRIES = 2
@@ -816,7 +850,8 @@ async def generate_speech(
voice_id: str,
phone_quality: str = "normal",
apply_filter: bool = True,
provider_override: str = None
provider_override: str = None,
emotional_register: str = "",
) -> bytes:
"""
Generate speech from text with automatic retry on failure.
@@ -827,6 +862,7 @@ async def generate_speech(
phone_quality: Quality of phone filter ("none" to disable)
apply_filter: Whether to apply phone filter
provider_override: Override the global TTS provider for this call
emotional_register: Caller's emotional register (used by Inworld for temp/speed tuning)
Returns:
Raw PCM audio bytes (16-bit signed int, 24kHz)
@@ -845,7 +881,7 @@ async def generate_speech(
async with asyncio.timeout(20):
for attempt in range(TTS_MAX_RETRIES):
try:
audio, sample_rate = await gen_fn(text, voice_id)
audio, sample_rate = await gen_fn(text, voice_id, emotional_register)
cost_tracker.record_tts_call(provider, voice_id, len(text))
if attempt > 0:
print(f"[TTS] Succeeded on retry {attempt}")