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