Fix Silas identity/voice leak, avatar gender, and add pre-warm batch gen
session.caller was reading name/voice from CALLER_BASES randomized defaults instead of the slim bg dict — causing Silas to appear as "Earl" with wrong voice. Now prefers bg data when populated. Also: avatar endpoint infers gender from voice pool, /api/callers returns bg name, regulars get canonical voice force-locked, batch gen splits into two parallel calls with pre-warm so subsequent resets are instant. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+197
-30
@@ -32,7 +32,7 @@ from .services.stem_recorder import StemRecorder
|
||||
from .services.news import news_service, extract_keywords, STOP_WORDS
|
||||
from .services.regulars import regular_caller_service
|
||||
from .services.intern import intern_service
|
||||
from .services.avatars import avatar_service
|
||||
from .services.avatars import avatar_service, AVATAR_DIR
|
||||
|
||||
|
||||
app = FastAPI(title="AI Radio Show")
|
||||
@@ -245,7 +245,7 @@ async def _regenerate_backgrounds_for_keys(keys: list[str]):
|
||||
if not keys:
|
||||
return
|
||||
try:
|
||||
await session._pregenerate_backgrounds()
|
||||
session.caller_backgrounds = await session._build_backgrounds()
|
||||
print(f"[Background] Regenerated backgrounds after theme change (touched {len(keys)} unused slots)")
|
||||
except Exception as e:
|
||||
print(f"[Background] Regen failed: {e}")
|
||||
@@ -603,6 +603,8 @@ class Session:
|
||||
self.caller_queue: list[str] = [] # Sorted presentation order of caller keys
|
||||
self.intern_monitoring: bool = True # Devon monitors conversations by default
|
||||
self.show_theme: str = "" # Current show theme (e.g. "St. Patrick's Day")
|
||||
self.pending_backgrounds: dict[str, dict] | None = None # Pre-warmed next-session lineup
|
||||
self._prewarm_task: asyncio.Task | None = None
|
||||
|
||||
def start_call(self, caller_key: str):
|
||||
self.current_caller_key = caller_key
|
||||
@@ -760,6 +762,17 @@ class Session:
|
||||
if self.current_caller_key:
|
||||
base = CALLER_BASES.get(self.current_caller_key)
|
||||
if base:
|
||||
# Prefer the slim background's name/voice (actual caller identity)
|
||||
# over the CALLER_BASES randomized defaults. The bg dict is the
|
||||
# source of truth once pregen has populated it.
|
||||
bg = self.caller_backgrounds.get(self.current_caller_key)
|
||||
if isinstance(bg, dict) and bg.get("name") and bg.get("voice"):
|
||||
return {
|
||||
"name": bg["name"],
|
||||
"voice": bg["voice"],
|
||||
"vibe": self.get_caller_background(self.current_caller_key),
|
||||
"tts_provider": base.get("tts_provider"),
|
||||
}
|
||||
return {
|
||||
"name": base["name"],
|
||||
"voice": base["voice"],
|
||||
@@ -768,40 +781,126 @@ class Session:
|
||||
}
|
||||
return None
|
||||
|
||||
async def _pregenerate_backgrounds(self):
|
||||
"""Single sonnet-4.6 batch call generates all caller identities."""
|
||||
async def _build_backgrounds(self) -> dict[str, dict]:
|
||||
"""Generate a fresh caller lineup via two parallel sonnet-4.6 calls.
|
||||
Returns a caller_backgrounds dict keyed by caller slot (no mutation)."""
|
||||
from .services import caller_gen, regulars_v2
|
||||
from datetime import datetime
|
||||
|
||||
voice_roster = [name for name in INWORLD_MALE_VOICES + INWORLD_FEMALE_VOICES
|
||||
if name not in BLACKLISTED_VOICES]
|
||||
|
||||
# Each active regular has an independent 50% chance to appear tonight.
|
||||
REGULAR_APPEARANCE_PROB = 0.5
|
||||
active_regulars = regulars_v2.load_all_active_regulars()
|
||||
regulars_for_tonight = [
|
||||
{"name": r.name, "lore": r.lore_body, "arc_state": r.arc_state}
|
||||
{"name": r.name, "voice": r.voice, "age": r.age,
|
||||
"lore": r.lore_body, "arc_state": r.arc_state}
|
||||
for r in active_regulars
|
||||
if random.random() < REGULAR_APPEARANCE_PROB
|
||||
][:3]
|
||||
if regulars_for_tonight:
|
||||
names = ", ".join(r["name"] for r in regulars_for_tonight)
|
||||
print(f"[Background] Regulars tonight: {names}")
|
||||
else:
|
||||
print("[Background] No regulars tonight — all walk-ins")
|
||||
|
||||
headlines: list[str] = []
|
||||
if self.news_headlines:
|
||||
for h in self.news_headlines[:5]:
|
||||
headlines.append(h.title if hasattr(h, "title") else str(h))
|
||||
|
||||
ctx = {
|
||||
base_ctx = {
|
||||
"date": datetime.now().strftime("%A, %B %d, %Y"),
|
||||
"weather": "cool desert night", # TODO: real weather feed
|
||||
"headlines": headlines,
|
||||
"recent_caller_summaries": self._get_recent_summaries(),
|
||||
"regulars_included": regulars_for_tonight,
|
||||
"caller_count": 12,
|
||||
"voice_roster": voice_roster,
|
||||
}
|
||||
# Split into two parallel calls: call 1 includes regulars + 5 walk-ins,
|
||||
# call 2 is 6 pure walk-ins. Halves generation time by parallelizing.
|
||||
ctx_a = {**base_ctx, "regulars_included": regulars_for_tonight, "caller_count": 6}
|
||||
ctx_b = {**base_ctx, "regulars_included": [], "caller_count": 6}
|
||||
|
||||
identities = await caller_gen.generate_batch(ctx)
|
||||
batches = await asyncio.gather(
|
||||
caller_gen.generate_batch(ctx_a),
|
||||
caller_gen.generate_batch(ctx_b),
|
||||
return_exceptions=True,
|
||||
)
|
||||
identities: list = []
|
||||
for i, result in enumerate(batches):
|
||||
if isinstance(result, Exception):
|
||||
print(f"[Background] Batch {i+1} failed: {result}")
|
||||
continue
|
||||
identities.extend(result)
|
||||
|
||||
for i, identity in enumerate(identities[:10]):
|
||||
key = str(i + 1)
|
||||
self.caller_backgrounds[key] = {
|
||||
# Build regular lookup tables
|
||||
regular_names = {r["name"] for r in regulars_for_tonight}
|
||||
regular_voice_by_name = {r["name"]: r["voice"] for r in regulars_for_tonight}
|
||||
regular_age_by_name = {r["name"]: r["age"] for r in regulars_for_tonight}
|
||||
|
||||
# Separate regulars from walk-ins, filter leaks, dedupe walk-in names
|
||||
regular_idents: dict[str, caller_gen.CallerIdentity] = {}
|
||||
walk_ins: list[caller_gen.CallerIdentity] = []
|
||||
seen_walk_in_names: set[str] = set()
|
||||
for ident in identities:
|
||||
if ident.name in regular_names:
|
||||
if ident.name in regular_idents:
|
||||
continue
|
||||
# Force-lock canonical voice/age (these voices are blacklisted
|
||||
# from the sonnet roster, so voice_resolved would otherwise fall
|
||||
# back to roster[0]).
|
||||
ident.voice_resolved = regular_voice_by_name[ident.name]
|
||||
ident.age = regular_age_by_name[ident.name]
|
||||
regular_idents[ident.name] = ident
|
||||
continue
|
||||
# Drop walk-ins whose content references a regular by name (leak guard)
|
||||
leaks_regular = False
|
||||
for rname in regular_names:
|
||||
if rname in ident.identity or rname in ident.situation:
|
||||
leaks_regular = True
|
||||
break
|
||||
if leaks_regular:
|
||||
print(f"[Background] Dropping caller '{ident.name}' — leaks regular into content")
|
||||
continue
|
||||
# Dedupe walk-ins by name (both parallel calls could produce same name)
|
||||
if ident.name in seen_walk_in_names:
|
||||
continue
|
||||
seen_walk_in_names.add(ident.name)
|
||||
walk_ins.append(ident)
|
||||
|
||||
# If sonnet missed a gated-in regular, generate their situation directly
|
||||
for r in regulars_for_tonight:
|
||||
if r["name"] not in regular_idents:
|
||||
print(f"[Background] Regular '{r['name']}' missing from batch output — generating directly")
|
||||
try:
|
||||
fields = await caller_gen.generate_regular_situation(r, ctx_a)
|
||||
regular_idents[r["name"]] = caller_gen.CallerIdentity(
|
||||
name=r["name"],
|
||||
age=r["age"],
|
||||
voice_suggestion=r["voice"],
|
||||
location=fields.get("location", ""),
|
||||
identity=fields.get("identity", ""),
|
||||
situation=fields.get("situation", ""),
|
||||
reason_calling=fields.get("reason_calling", ""),
|
||||
opening_line=fields.get("opening_line", ""),
|
||||
secret_want=fields.get("secret_want", ""),
|
||||
specific_details=fields.get("specific_details", []),
|
||||
emotional_register=fields.get("emotional_register", ""),
|
||||
voice_resolved=r["voice"],
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Background] Failed to generate fallback for '{r['name']}': {e}")
|
||||
|
||||
# Regulars first (in the order they were gated in), then walk-ins
|
||||
ordered = [regular_idents[r["name"]] for r in regulars_for_tonight if r["name"] in regular_idents]
|
||||
ordered.extend(walk_ins)
|
||||
|
||||
backgrounds: dict[str, dict] = {}
|
||||
caller_keys = list(CALLER_BASES.keys()) # ["1"-"9","0"]
|
||||
for i, identity in enumerate(ordered[:10]):
|
||||
key = caller_keys[i]
|
||||
backgrounds[key] = {
|
||||
"name": identity.name,
|
||||
"age": identity.age,
|
||||
"voice": identity.voice_resolved,
|
||||
@@ -814,7 +913,47 @@ class Session:
|
||||
"specific_details": identity.specific_details,
|
||||
"emotional_register": identity.emotional_register,
|
||||
}
|
||||
print(f"[Background] Slim batch generated {len(identities[:10])} caller identities")
|
||||
print(f"[Background] Built {len(backgrounds)} callers (from {len(identities)} raw, {len(regular_idents)} regulars locked)")
|
||||
return backgrounds
|
||||
|
||||
def start_prewarm(self):
|
||||
"""Kick off background generation of the NEXT session's lineup.
|
||||
No-op if a pre-warm task is already running."""
|
||||
if self._prewarm_task and not self._prewarm_task.done():
|
||||
return
|
||||
self._prewarm_task = asyncio.create_task(self._prewarm_next())
|
||||
|
||||
async def _prewarm_next(self):
|
||||
"""Generate next session's lineup and stash it in pending_backgrounds."""
|
||||
try:
|
||||
print("[Background] Pre-warming next session's callers...")
|
||||
self.pending_backgrounds = await self._build_backgrounds()
|
||||
print("[Background] Next session's callers are pre-warmed and ready")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[Background] Pre-warm failed: {e}")
|
||||
print(traceback.format_exc())
|
||||
|
||||
async def populate_backgrounds(self):
|
||||
"""Install callers into this session. Uses pre-warmed batch if ready,
|
||||
else generates fresh (blocking). Always kicks off next pre-warm."""
|
||||
if self.pending_backgrounds is not None:
|
||||
self.caller_backgrounds = self.pending_backgrounds
|
||||
self.pending_backgrounds = None
|
||||
print(f"[Background] Swapped in pre-warmed backgrounds ({len(self.caller_backgrounds)} callers)")
|
||||
else:
|
||||
# Wait for in-flight pre-warm if present, otherwise generate fresh
|
||||
if self._prewarm_task and not self._prewarm_task.done():
|
||||
print("[Background] Waiting for in-flight pre-warm to finish...")
|
||||
await self._prewarm_task
|
||||
if self.pending_backgrounds is not None:
|
||||
self.caller_backgrounds = self.pending_backgrounds
|
||||
self.pending_backgrounds = None
|
||||
print(f"[Background] Swapped in pre-warmed backgrounds ({len(self.caller_backgrounds)} callers)")
|
||||
self.start_prewarm()
|
||||
return
|
||||
self.caller_backgrounds = await self._build_backgrounds()
|
||||
self.start_prewarm()
|
||||
|
||||
def _get_recent_summaries(self) -> list[str]:
|
||||
# Return last 2 shows' caller summaries — stub for now, can wire into cost_db
|
||||
@@ -1225,7 +1364,11 @@ async def startup():
|
||||
asyncio.create_task(_poll_imap_emails())
|
||||
restored = _load_checkpoint()
|
||||
if not restored or not session.caller_backgrounds:
|
||||
asyncio.create_task(session._pregenerate_backgrounds())
|
||||
# First reset will need callers — prewarm now so they're ready
|
||||
session.start_prewarm()
|
||||
else:
|
||||
# Existing session good, but prewarm so next reset is instant
|
||||
session.start_prewarm()
|
||||
asyncio.create_task(avatar_service.ensure_devon())
|
||||
threading.Thread(target=_update_on_air_cdn, args=(False,), daemon=True).start()
|
||||
|
||||
@@ -2089,13 +2232,15 @@ async def get_callers():
|
||||
"""Get list of available callers with background info for UI display"""
|
||||
callers = []
|
||||
for k, v in CALLER_BASES.items():
|
||||
bg = session.caller_backgrounds.get(k)
|
||||
# Prefer the slim bg name (actual caller identity) over the CALLER_BASES default
|
||||
display_name = bg.get("name") if isinstance(bg, dict) and bg.get("name") else v["name"]
|
||||
caller_info = {
|
||||
"key": k,
|
||||
"name": v["name"],
|
||||
"name": display_name,
|
||||
"returning": v.get("returning", False),
|
||||
"avatar_url": f"/api/avatar/{v['name']}",
|
||||
"avatar_url": f"/api/avatar/{display_name}",
|
||||
}
|
||||
bg = session.caller_backgrounds.get(k)
|
||||
if isinstance(bg, dict):
|
||||
details = bg.get("specific_details") or []
|
||||
caller_info["identity"] = bg.get("identity", "")
|
||||
@@ -2119,11 +2264,11 @@ async def get_regulars():
|
||||
|
||||
@app.post("/api/session/reset")
|
||||
async def reset_session():
|
||||
"""Reset session - all callers get fresh backgrounds"""
|
||||
"""Reset session - all callers get fresh backgrounds.
|
||||
Uses pre-warmed batch if available (instant); otherwise generates fresh."""
|
||||
session.reset()
|
||||
_chat_updates.clear()
|
||||
# Pre-generate backgrounds in background so they're ready when callers are clicked
|
||||
asyncio.create_task(session._pregenerate_backgrounds())
|
||||
await session.populate_backgrounds()
|
||||
return {"status": "reset", "session_id": session.id}
|
||||
|
||||
|
||||
@@ -4568,22 +4713,44 @@ async def _play_intern_audio(text: str):
|
||||
|
||||
# --- Avatars ---
|
||||
|
||||
def _infer_gender_for_name(name: str) -> str:
|
||||
"""Infer gender for avatar lookup. Priority:
|
||||
1. Match against slim caller_backgrounds by name → use voice pool
|
||||
2. Match against CALLER_BASES by name → use slot gender
|
||||
3. Default to male
|
||||
"""
|
||||
# Priority 1: slim caller backgrounds (new redesigned system)
|
||||
for bg in session.caller_backgrounds.values():
|
||||
if isinstance(bg, dict) and bg.get("name") == name:
|
||||
voice = bg.get("voice", "")
|
||||
if voice in INWORLD_FEMALE_VOICES:
|
||||
return "female"
|
||||
if voice in INWORLD_MALE_VOICES:
|
||||
return "male"
|
||||
break
|
||||
# Priority 2: CALLER_BASES randomized names
|
||||
for base in CALLER_BASES.values():
|
||||
if base.get("name") == name:
|
||||
return base.get("gender", "male")
|
||||
return "male"
|
||||
|
||||
|
||||
@app.get("/api/avatar/{name}")
|
||||
async def get_avatar(name: str):
|
||||
"""Serve a caller's avatar image"""
|
||||
path = avatar_service.get_path(name)
|
||||
if path:
|
||||
gender = _infer_gender_for_name(name)
|
||||
# Check cache with gender-correctness; fetch/re-fetch if missing or wrong gender
|
||||
path = AVATAR_DIR / f"{name}.jpg"
|
||||
marker = AVATAR_DIR / f"{name}.gender"
|
||||
if path.exists() and marker.exists() and marker.read_text().strip() == gender:
|
||||
return FileResponse(path, media_type="image/jpeg")
|
||||
# Try to fetch on the fly — find gender from CALLER_BASES
|
||||
gender = "male"
|
||||
for base in CALLER_BASES.values():
|
||||
if base.get("name") == name:
|
||||
gender = base.get("gender", "male")
|
||||
break
|
||||
try:
|
||||
path = await avatar_service.get_or_fetch(name, gender)
|
||||
return FileResponse(path, media_type="image/jpeg")
|
||||
fetched = await avatar_service.get_or_fetch(name, gender)
|
||||
return FileResponse(fetched, media_type="image/jpeg")
|
||||
except Exception:
|
||||
# If fetch fails but a stale file exists, serve it rather than 404
|
||||
if path.exists():
|
||||
return FileResponse(path, media_type="image/jpeg")
|
||||
raise HTTPException(404, "Avatar not found")
|
||||
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ def build_batch_prompt(ctx: dict) -> str:
|
||||
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):")
|
||||
@@ -131,3 +132,54 @@ async def generate_batch(ctx: dict) -> list[CallerIdentity]:
|
||||
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)
|
||||
|
||||
+1
-1
@@ -324,6 +324,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/app.js?v=27"></script>
|
||||
<script src="/js/app.js?v=28"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+23
-9
@@ -744,18 +744,32 @@ async function newSession() {
|
||||
await hangup();
|
||||
}
|
||||
|
||||
await fetch('/api/session/reset', { method: 'POST' });
|
||||
conversationSince = 0;
|
||||
const btn = document.getElementById('new-session-btn');
|
||||
const originalText = btn?.textContent;
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Generating...';
|
||||
}
|
||||
|
||||
// Hide caller background
|
||||
const bgDetails = document.getElementById('caller-background-details');
|
||||
if (bgDetails) bgDetails.classList.add('hidden');
|
||||
try {
|
||||
await fetch('/api/session/reset', { method: 'POST' });
|
||||
conversationSince = 0;
|
||||
|
||||
// Reload callers to get new session ID
|
||||
await loadCallers();
|
||||
await loadShowTheme();
|
||||
// Hide caller background
|
||||
const bgDetails = document.getElementById('caller-background-details');
|
||||
if (bgDetails) bgDetails.classList.add('hidden');
|
||||
|
||||
log('New session started - all callers have fresh backgrounds');
|
||||
// Reload callers to get new session ID
|
||||
await loadCallers();
|
||||
await loadShowTheme();
|
||||
|
||||
log('New session started - all callers have fresh backgrounds');
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText || 'New Session';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user