Phase 5B commit 6: delete CallerBackground dataclass, relationship_context, update docs

Final cleanup pass for the caller generation redesign:
- Delete CallerBackground dataclass + all isinstance() checks
- Delete orphaned _build_relationship_context and session.relationship_context
- Delete Session.get_caller_model (caller_dialog category routes to haiku-4.5)
- Delete dead _get_show_energy (CallRecord no longer tracks energy_level)
- Delete unused tone_streak field
- Drop topic_category/emotional_state/energy_level from CallRecord
- Simplify caller property, _find_thematic_match, enrich, promotion paths to slim dict
- Drop broken emotional_state=/energy_level= kwargs from generate_speech calls
- _load_checkpoint drops pre-slim schema backgrounds; startup re-pregens if empty
- Rewrite CLAUDE.md "Caller Generation System" section for the new two-stage architecture

35 tests passing. backend/main.py: 4981 -> 4810 lines.
This commit is contained in:
2026-04-05 13:56:08 -06:00
parent 83c7f441a8
commit 9e37fbf124
2 changed files with 71 additions and 241 deletions
+10 -9
View File
@@ -58,13 +58,14 @@ Required in `.env`:
- `generate_with_tools()` in llm.py supports OpenRouter function calling for the intern feature - `generate_with_tools()` in llm.py supports OpenRouter function calling for the intern feature
## Caller Generation System ## Caller Generation System
- **CallerBackground dataclass**: Structured output from LLM background generation (JSON mode). Fields: name, age, gender, job, location, reason_for_calling, pool_name, communication_style, energy_level, emotional_state, signature_detail, situation_summary, natural_description, seeds, verbal_fluency, calling_from. - **Two-stage pipeline**: (1) batch identity pregen via Sonnet 4.6 at session start, (2) live dialog via Haiku 4.5 per turn. Cost ~$1/show.
- **Voice-personality matching**: `_match_voices_to_styles()` runs after background generation. 68 voice profiles in `VOICE_PROFILES` (tts.py), 18 style-to-voice mappings in `STYLE_VOICE_PREFERENCES` (main.py). Soft matching — scores voices against style preferences. - **Slim caller dict**: Populated once at `Session._pregenerate_backgrounds()` via `caller_gen.generate_batch()`. Keys: `name`, `age`, `voice`, `location`, `identity`, `situation`, `reason_calling`, `opening_line`, `secret_want`, `specific_details`, `emotional_register`. Stored in `session.caller_backgrounds[caller_key]`.
- **Adaptive call shapes**: `SHAPE_STYLE_AFFINITIES` maps communication styles to shape weight multipliers. Consecutive shape repeats are dampened. - **Dialog model**: Always Haiku 4.5 via the `caller_dialog` category in `config.category_models`. No per-caller model routing — deleted in Phase 5B.
- **Inter-caller awareness**: Thematic matching in `get_show_history()` scores previous callers by keyword/category overlap. Adaptive reaction frequency (60%/35%/15%). Show energy tracking via `_get_show_energy()`. - **Prompt builder**: `get_caller_prompt(caller)` in main.py builds the slim system prompt from the dict; see `tests/test_caller_prompt.py` for the contract.
- **Caller memory**: Returning callers store structured backgrounds, key moments, arc status, and relationships with other regulars. `RegularCallerService` has `add_relationship()` and expanded `update_after_call()`. - **Regulars**: `backend/services/regulars_v2.py` loads lore from Obsidian markdown files for named recurring callers (e.g. Silas). The batch prompt optionally includes 2-3 active regulars per session.
- **Show pacing**: `_sort_caller_queue()` sorts presentation order by energy alternation, topic variety, shape variety. - **Inter-caller awareness**: `get_show_history()` scores previous callers by keyword overlap with the current caller's `situation`/`reason_calling`. Reaction frequency scales with match strength (60%/35%/15%).
- **Call quality signals**: `_assess_call_quality()` captures exchange count, response length, host engagement, shape target hit, natural ending. - **Caller memory**: Returning callers auto-promote from first-timers at ~5% probability after 8+ exchanges. `RegularCallerService` tracks summaries, relationships, arc state.
- **Call quality signals**: `_assess_call_quality()` captures exchange count, response length, host engagement, caller depth, natural ending.
## Devon (Intern Character) ## Devon (Intern Character)
- **Service**: `backend/services/intern.py` — persistent show character, not a caller - **Service**: `backend/services/intern.py` — persistent show character, not a caller
@@ -78,8 +79,8 @@ Required in `.env`:
## Frontend Control Panel ## Frontend Control Panel
- **Keyboard shortcuts**: 1-0 (callers), H (hangup), W (wrap up), M (music toggle), D (ask Devon), Escape (close modals) - **Keyboard shortcuts**: 1-0 (callers), H (hangup), W (wrap up), M (music toggle), D (ask Devon), Escape (close modals)
- **Wrap It Up**: Amber button that signals callers to wind down gracefully. Reduces response budget, injects wrap-up signals, forces goodbye after 2 exchanges. - **Wrap It Up**: Amber button that signals callers to wind down gracefully. Reduces response budget, injects wrap-up signals, forces goodbye after 2 exchanges.
- **Caller info panel**: Shows call shape, energy level, emotional state, signature detail, situation summary during active calls - **Caller info panel**: Shows identity, situation, signature detail, secret want during active calls
- **Caller buttons**: Energy dots (colored by level) and shape badges on each button - **Caller buttons**: Populated from the slim caller background dicts
- **Pinned SFX**: Cheer/Applause/Boo always visible, rest collapsible - **Pinned SFX**: Cheer/Applause/Boo always visible, rest collapsible
- **Visual polish**: Thinking pulse, call glow, compact media row, smoother transitions - **Visual polish**: Thinking pulse, call glow, compact media row, smoother transitions
+61 -232
View File
@@ -35,31 +35,6 @@ from .services.intern import intern_service
from .services.avatars import avatar_service from .services.avatars import avatar_service
# --- Structured Caller Background (must be defined before functions that use it) ---
@dataclass
class CallerBackground:
name: str
age: int
gender: str
job: str
location: str | None
reason_for_calling: str
pool_name: str
communication_style: str
energy_level: str # low / medium / high / very_high
emotional_state: str # nervous, excited, angry, vulnerable, calm, etc.
signature_detail: str # The memorable thing about them
situation_summary: str # 1-sentence summary for other callers to reference
natural_description: str # 3-5 sentence prose for the prompt
seeds: list[str] = field(default_factory=list)
verbal_fluency: str = "medium"
calling_from: str = ""
hidden_layers: list[str] = field(default_factory=list) # 3 details they haven't mentioned yet
burning_opinion: str = "" # Something they're dying to say — will bring up even without being asked
stakes: str = "" # What's at risk for them — why this matters, what happens if nothing changes
theme_connected: bool = False # True if this caller's story was generated around the show theme
app = FastAPI(title="AI Radio Show") app = FastAPI(title="AI Radio Show")
app.add_middleware( app.add_middleware(
@@ -276,42 +251,6 @@ async def _regenerate_backgrounds_for_keys(keys: list[str]):
print(f"[Background] Regen failed: {e}") print(f"[Background] Regen failed: {e}")
def _build_relationship_context():
"""Find regulars with existing relationships who are both in the current session.
Inject mutual awareness into both callers' prompts."""
regulars = regular_caller_service.get_regulars()
if not regulars:
return
# Map regular names to their caller keys in this session
name_to_key = {}
key_to_regular = {}
for key, base in CALLER_BASES.items():
if base.get("returning") and base.get("regular_id"):
for reg in regulars:
if reg["id"] == base["regular_id"]:
name_to_key[reg["name"]] = key
key_to_regular[key] = reg
break
if len(name_to_key) < 2:
return # Need at least 2 regulars to have relationships
# Check for mutual relationships
for key, regular in key_to_regular.items():
relationships = regular.get("relationships", {})
for other_name, rel_info in relationships.items():
if other_name in name_to_key:
other_key = name_to_key[other_name]
rel_type = rel_info.get("type", "knows")
context = rel_info.get("context", "")
# Inject awareness into this caller's prompt
line = f"\nSOMEONE YOU KNOW IS ON THE SHOW TONIGHT: {other_name} is also calling in. You know them — {rel_type}. {context} You might hear them on air. If Luke mentions them or you hear them, react naturally. Don't force it — if it comes up, it comes up."
existing = session.relationship_context.get(key, "")
session.relationship_context[key] = existing + line
print(f"[Relationships] {regular['name']} knows {other_name} ({rel_type})")
# Known topics for smarter search queries — maps keywords in backgrounds to search terms # Known topics for smarter search queries — maps keywords in backgrounds to search terms
_TOPIC_SEARCH_MAP = [ _TOPIC_SEARCH_MAP = [
# TV shows # TV shows
@@ -572,12 +511,9 @@ class CallRecord:
started_at: float = 0.0 started_at: float = 0.0
ended_at: float = 0.0 ended_at: float = 0.0
quality_signals: dict = field(default_factory=dict) # Per-call quality heuristics quality_signals: dict = field(default_factory=dict) # Per-call quality heuristics
# Inter-caller awareness fields (populated from CallerBackground) # Inter-caller awareness fields (populated from slim caller background dicts)
topic_category: str = "" # Pool name: PROBLEMS, STORIES, etc.
situation_summary: str = "" # 1-sentence summary for other callers situation_summary: str = "" # 1-sentence summary for other callers
emotional_state: str = "" # How the caller was feeling communication_style: str = "" # Emotional register of the caller
energy_level: str = "" # low/medium/high/very_high
communication_style: str = "" # Style key
key_details: list[str] = field(default_factory=list) # Specific memorable details key_details: list[str] = field(default_factory=list) # Specific memorable details
@@ -590,10 +526,7 @@ def _serialize_call_record(record: CallRecord) -> dict:
"started_at": record.started_at, "started_at": record.started_at,
"ended_at": record.ended_at, "ended_at": record.ended_at,
"quality_signals": record.quality_signals, "quality_signals": record.quality_signals,
"topic_category": record.topic_category,
"situation_summary": record.situation_summary, "situation_summary": record.situation_summary,
"emotional_state": record.emotional_state,
"energy_level": record.energy_level,
"communication_style": record.communication_style, "communication_style": record.communication_style,
"key_details": record.key_details, "key_details": record.key_details,
} }
@@ -608,10 +541,7 @@ def _deserialize_call_record(data: dict) -> CallRecord:
started_at=data.get("started_at", 0.0), started_at=data.get("started_at", 0.0),
ended_at=data.get("ended_at", 0.0), ended_at=data.get("ended_at", 0.0),
quality_signals=data.get("quality_signals", {}), quality_signals=data.get("quality_signals", {}),
topic_category=data.get("topic_category", ""),
situation_summary=data.get("situation_summary", ""), situation_summary=data.get("situation_summary", ""),
emotional_state=data.get("emotional_state", ""),
energy_level=data.get("energy_level", ""),
communication_style=data.get("communication_style", ""), communication_style=data.get("communication_style", ""),
key_details=data.get("key_details", []), key_details=data.get("key_details", []),
) )
@@ -656,7 +586,7 @@ class Session:
self.id = str(uuid.uuid4())[:8] self.id = str(uuid.uuid4())[:8]
self.current_caller_key: str = None self.current_caller_key: str = None
self.conversation: list[dict] = [] self.conversation: list[dict] = []
self.caller_backgrounds: dict[str, CallerBackground | str] = {} # Generated backgrounds self.caller_backgrounds: dict[str, dict] = {} # Slim caller identity dicts, keyed by caller_key
self.call_history: list[CallRecord] = [] self.call_history: list[CallRecord] = []
self._call_started_at: float = 0.0 self._call_started_at: float = 0.0
self.active_real_caller: dict | None = None self.active_real_caller: dict | None = None
@@ -666,13 +596,11 @@ class Session:
self.research_notes: dict[str, list] = {} self.research_notes: dict[str, list] = {}
self._research_task: asyncio.Task | None = None self._research_task: asyncio.Task | None = None
self.used_reasons: set[str] = set() # Track used caller reasons to prevent repeats self.used_reasons: set[str] = set() # Track used caller reasons to prevent repeats
self.tone_streak: list[str] = [] # Track tone per call for variety balancing
self.call_quality_signals: list[dict] = [] # Per-call quality heuristics for tuning self.call_quality_signals: list[dict] = [] # Per-call quality heuristics for tuning
self._caller_hangup: bool = False # Set when [HANGUP] sentinel detected in current call self._caller_hangup: bool = False # Set when [HANGUP] sentinel detected in current call
self._wrapping_up: bool = False # Set via /api/wrap-up to gracefully wind down calls self._wrapping_up: bool = False # Set via /api/wrap-up to gracefully wind down calls
self._wrapup_exchanges: int = 0 # Track how many exchanges since wrap-up started self._wrapup_exchanges: int = 0 # Track how many exchanges since wrap-up started
self.caller_queue: list[str] = [] # Sorted presentation order of caller keys self.caller_queue: list[str] = [] # Sorted presentation order of caller keys
self.relationship_context: dict[str, str] = {} # caller_key → relationship prompt injection
self.intern_monitoring: bool = True # Devon monitors conversations by default self.intern_monitoring: bool = True # Devon monitors conversations by default
self.show_theme: str = "" # Current show theme (e.g. "St. Patrick's Day") self.show_theme: str = "" # Current show theme (e.g. "St. Patrick's Day")
@@ -691,18 +619,11 @@ class Session:
def add_message(self, role: str, content: str): def add_message(self, role: str, content: str):
self.conversation.append({"role": role, "content": content, "timestamp": time.time()}) self.conversation.append({"role": role, "content": content, "timestamp": time.time()})
def get_caller_model(self, caller_key: str) -> str | None:
"""All callers run through the single haiku-4.5 dialog model."""
return "anthropic/claude-haiku-4.5"
def get_caller_background(self, caller_key: str) -> str: def get_caller_background(self, caller_key: str) -> str:
"""Get background for a caller in this session. """Return the caller's situation string for UI display.
Returns the natural_description string for prompt injection.
Backgrounds are populated by _pregenerate_backgrounds at session start.""" Backgrounds are populated by _pregenerate_backgrounds at session start."""
bg = self.caller_backgrounds.get(caller_key, "") bg = self.caller_backgrounds.get(caller_key) or {}
if isinstance(bg, dict): return bg.get("situation", "") if isinstance(bg, dict) else ""
return bg.get("situation", "")
return bg.natural_description if isinstance(bg, CallerBackground) else bg
def get_show_history(self) -> str: def get_show_history(self) -> str:
"""Get formatted show history for AI caller prompts. """Get formatted show history for AI caller prompts.
@@ -743,11 +664,6 @@ class Session:
else: else:
lines.append("You're aware of these but you're calling about YOUR thing, not theirs. Don't bring them up unless the host does.") lines.append("You're aware of these but you're calling about YOUR thing, not theirs. Don't bring them up unless the host does.")
# Show energy tracking
energy_note = self._get_show_energy()
if energy_note:
lines.append(f"\n{energy_note}")
return "\n".join(lines) return "\n".join(lines)
def _find_thematic_match(self, current_bg) -> tuple: def _find_thematic_match(self, current_bg) -> tuple:
@@ -759,16 +675,16 @@ class Session:
best_target = None best_target = None
best_score = 0 best_score = 0
current_pool = current_bg.pool_name if isinstance(current_bg, CallerBackground) else "" if isinstance(current_bg, dict):
current_reason = current_bg.reason_for_calling if isinstance(current_bg, CallerBackground) else "" current_reason = current_bg.get("reason_calling", "")
current_summary = current_bg.situation_summary if isinstance(current_bg, CallerBackground) else "" current_summary = current_bg.get("situation", "")
else:
current_reason = ""
current_summary = ""
current_words = set((current_reason + " " + current_summary).lower().split()) current_words = set((current_reason + " " + current_summary).lower().split())
for record in self.call_history: for record in self.call_history:
score = 0 score = 0
# Same topic pool = strong match
if current_pool and record.topic_category == current_pool:
score += 2
# Keyword overlap in situation summaries # Keyword overlap in situation summaries
if record.situation_summary: if record.situation_summary:
record_words = set(record.situation_summary.lower().split()) record_words = set(record.situation_summary.lower().split())
@@ -777,11 +693,6 @@ class Session:
score += 2 score += 2
elif len(overlap) >= 1: elif len(overlap) >= 1:
score += 1 score += 1
# Emotional contrast bonus (opposite energies are interesting)
if record.energy_level and isinstance(current_bg, CallerBackground):
if (record.energy_level in ("low", "medium") and current_bg.energy_level in ("high", "very_high")) or \
(record.energy_level in ("high", "very_high") and current_bg.energy_level in ("low", "medium")):
score += 1
if score > best_score: if score > best_score:
best_score = score best_score = score
@@ -820,20 +731,6 @@ class Session:
# Fallback to generic reactions # Fallback to generic reactions
return random.choice(SHOW_HISTORY_REACTIONS) return random.choice(SHOW_HISTORY_REACTIONS)
def _get_show_energy(self) -> str:
"""Summarize the energy arc of the show for caller awareness."""
if len(self.call_history) < 3:
return ""
recent = self.call_history[-3:]
energies = [r.energy_level for r in recent if r.energy_level]
if not energies:
return ""
if all(e in ("high", "very_high") for e in energies):
return "SHOW ENERGY: The last few calls have been high-energy — the show could use a breather."
if all(e in ("low", "medium") for e in energies):
return "SHOW ENERGY: The last few calls have been mellow — some energy would shake things up."
return ""
def get_conversation_summary(self) -> str: def get_conversation_summary(self) -> str:
"""Get a brief summary of conversation so far for context""" """Get a brief summary of conversation so far for context"""
if len(self.conversation) <= 2: if len(self.conversation) <= 2:
@@ -863,29 +760,11 @@ class Session:
if self.current_caller_key: if self.current_caller_key:
base = CALLER_BASES.get(self.current_caller_key) base = CALLER_BASES.get(self.current_caller_key)
if base: if base:
bg = self.caller_backgrounds.get(self.current_caller_key)
emotional_state = ""
energy_level = ""
hidden_layers = []
burning_opinion = ""
stakes = ""
if hasattr(bg, "emotional_state"):
emotional_state = bg.emotional_state
energy_level = bg.energy_level
if hasattr(bg, "hidden_layers"):
hidden_layers = bg.hidden_layers
burning_opinion = bg.burning_opinion
stakes = bg.stakes
return { return {
"name": base["name"], "name": base["name"],
"voice": base["voice"], "voice": base["voice"],
"vibe": self.get_caller_background(self.current_caller_key), "vibe": self.get_caller_background(self.current_caller_key),
"tts_provider": base.get("tts_provider"), "tts_provider": base.get("tts_provider"),
"emotional_state": emotional_state,
"energy_level": energy_level,
"hidden_layers": hidden_layers,
"burning_opinion": burning_opinion,
"stakes": stakes,
} }
return None return None
@@ -955,12 +834,10 @@ class Session:
if self._research_task and not self._research_task.done(): if self._research_task and not self._research_task.done():
self._research_task.cancel() self._research_task.cancel()
self._research_task = None self._research_task = None
self.tone_streak = []
self.call_quality_signals = [] self.call_quality_signals = []
self._wrapping_up = False self._wrapping_up = False
self._wrapup_exchanges = 0 self._wrapup_exchanges = 0
self.caller_queue = [] self.caller_queue = []
self.relationship_context = {}
self.used_reasons = set() self.used_reasons = set()
self.intern_monitoring = True self.intern_monitoring = True
intern_service.stop_monitoring() intern_service.stop_monitoring()
@@ -1045,17 +922,15 @@ def _save_checkpoint():
data = { data = {
"session_id": session.id, "session_id": session.id,
"call_history": [_serialize_call_record(r) for r in session.call_history], "call_history": [_serialize_call_record(r) for r in session.call_history],
"caller_backgrounds": {k: asdict(v) if isinstance(v, CallerBackground) else v for k, v in session.caller_backgrounds.items()}, "caller_backgrounds": session.caller_backgrounds,
"used_reasons": list(session.used_reasons), "used_reasons": list(session.used_reasons),
"ai_respond_mode": session.ai_respond_mode, "ai_respond_mode": session.ai_respond_mode,
"auto_followup": session.auto_followup, "auto_followup": session.auto_followup,
"news_headlines": session.news_headlines, "news_headlines": session.news_headlines,
"research_notes": session.research_notes, "research_notes": session.research_notes,
"caller_bases": caller_bases_snapshot, "caller_bases": caller_bases_snapshot,
"tone_streak": session.tone_streak,
"call_quality_signals": session.call_quality_signals, "call_quality_signals": session.call_quality_signals,
"caller_queue": session.caller_queue, "caller_queue": session.caller_queue,
"relationship_context": session.relationship_context,
"intern_monitoring": session.intern_monitoring, "intern_monitoring": session.intern_monitoring,
"costs": cost_tracker.get_live_summary(), "costs": cost_tracker.get_live_summary(),
"cost_records": { "cost_records": {
@@ -1083,22 +958,20 @@ def _load_checkpoint() -> bool:
return False return False
session.id = data["session_id"] session.id = data["session_id"]
session.call_history = [_deserialize_call_record(r) for r in data.get("call_history", [])] session.call_history = [_deserialize_call_record(r) for r in data.get("call_history", [])]
# Drop any legacy background dicts (pre-slim schema). They'll be regenerated
# fresh on next Session.reset or when startup sees no restored backgrounds.
raw_bgs = data.get("caller_backgrounds", {}) raw_bgs = data.get("caller_backgrounds", {})
session.caller_backgrounds = {} session.caller_backgrounds = {
for k, v in raw_bgs.items(): k: v for k, v in raw_bgs.items()
if isinstance(v, dict) and "natural_description" in v: if isinstance(v, dict) and "identity" in v and "situation" in v
session.caller_backgrounds[k] = CallerBackground(**v) }
else:
session.caller_backgrounds[k] = v
session.used_reasons = set(data.get("used_reasons", [])) session.used_reasons = set(data.get("used_reasons", []))
session.ai_respond_mode = data.get("ai_respond_mode", "manual") session.ai_respond_mode = data.get("ai_respond_mode", "manual")
session.auto_followup = data.get("auto_followup", False) session.auto_followup = data.get("auto_followup", False)
session.news_headlines = data.get("news_headlines", []) session.news_headlines = data.get("news_headlines", [])
session.research_notes = data.get("research_notes", {}) session.research_notes = data.get("research_notes", {})
session.tone_streak = data.get("tone_streak", [])
session.call_quality_signals = data.get("call_quality_signals", []) session.call_quality_signals = data.get("call_quality_signals", [])
session.caller_queue = data.get("caller_queue", []) session.caller_queue = data.get("caller_queue", [])
session.relationship_context = data.get("relationship_context", {})
session.intern_monitoring = data.get("intern_monitoring", True) session.intern_monitoring = data.get("intern_monitoring", True)
for key, snapshot in data.get("caller_bases", {}).items(): for key, snapshot in data.get("caller_bases", {}).items():
if key in CALLER_BASES: if key in CALLER_BASES:
@@ -1351,7 +1224,7 @@ async def startup():
asyncio.create_task(_sync_signalwire_voicemails()) asyncio.create_task(_sync_signalwire_voicemails())
asyncio.create_task(_poll_imap_emails()) asyncio.create_task(_poll_imap_emails())
restored = _load_checkpoint() restored = _load_checkpoint()
if not restored: if not restored or not session.caller_backgrounds:
asyncio.create_task(session._pregenerate_backgrounds()) asyncio.create_task(session._pregenerate_backgrounds())
asyncio.create_task(avatar_service.ensure_devon()) asyncio.create_task(avatar_service.ensure_devon())
threading.Thread(target=_update_on_air_cdn, args=(False,), daemon=True).start() threading.Thread(target=_update_on_air_cdn, args=(False,), daemon=True).start()
@@ -2090,10 +1963,8 @@ def _get_all_caller_names() -> list[str]:
names = [] names = []
for key in CALLER_BASES: for key in CALLER_BASES:
bg = session.caller_backgrounds.get(key) bg = session.caller_backgrounds.get(key)
if bg and hasattr(bg, "name"): if isinstance(bg, dict) and bg.get("name"):
names.append(bg.name) names.append(bg["name"])
elif isinstance(bg, str):
pass # raw string background, no structured name
elif "name" in CALLER_BASES[key]: elif "name" in CALLER_BASES[key]:
names.append(CALLER_BASES[key]["name"]) names.append(CALLER_BASES[key]["name"])
# Always include Devon (the intern) # Always include Devon (the intern)
@@ -2311,13 +2182,11 @@ async def start_call(caller_key: str):
if not base.get("returning"): if not base.get("returning"):
callback = _maybe_generate_callback() callback = _maybe_generate_callback()
if callback: if callback:
existing_bg = session.caller_backgrounds.get(caller_key, "") existing_bg = session.caller_backgrounds.get(caller_key)
callback_ctx = f"\n\nPREVIOUS CALLS:\n- (earlier tonight) {callback['original_summary']}\nYou're calling back with an update — {callback['callback_reason']}. Reference your earlier call naturally." if isinstance(existing_bg, dict):
if isinstance(existing_bg, CallerBackground): callback_ctx = f"\n\nPREVIOUS CALLS:\n- (earlier tonight) {callback['original_summary']}\nYou're calling back with an update — {callback['callback_reason']}. Reference your earlier call naturally."
existing_bg.natural_description += callback_ctx existing_bg["situation"] = existing_bg.get("situation", "") + callback_ctx
else: print(f"[Callback] Injected callback context for {base.get('name', caller_key)}")
session.caller_backgrounds[caller_key] = existing_bg + callback_ctx
print(f"[Callback] Injected callback context for {base.get('name', caller_key)}")
caller = session.caller # This generates the background if needed caller = session.caller # This generates the background if needed
@@ -2358,13 +2227,11 @@ async def start_call(caller_key: str):
async def _enrich_background_async(caller_key: str): async def _enrich_background_async(caller_key: str):
"""Enrich caller background with news/weather without blocking the call""" """Enrich caller background with news/weather without blocking the call"""
try: try:
bg = session.caller_backgrounds[caller_key] bg = session.caller_backgrounds.get(caller_key)
bg_text = bg.natural_description if isinstance(bg, CallerBackground) else bg if not isinstance(bg, dict):
enriched = await enrich_caller_background(bg_text) return
if isinstance(bg, CallerBackground): enriched = await enrich_caller_background(bg.get("situation", ""))
bg.natural_description = enriched bg["situation"] = enriched
else:
session.caller_backgrounds[caller_key] = enriched
except Exception as e: except Exception as e:
print(f"[Research] Background enrichment failed: {e}") print(f"[Research] Background enrichment failed: {e}")
@@ -2444,19 +2311,9 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li
# Populate from slim caller background dict # Populate from slim caller background dict
bg = session.caller_backgrounds.get(caller_key) or {} bg = session.caller_backgrounds.get(caller_key) or {}
if isinstance(bg, dict): comm_style = bg.get("emotional_register", "") if isinstance(bg, dict) else ""
comm_style = bg.get("emotional_register", "") sit_summary = bg.get("situation", "") if isinstance(bg, dict) else ""
sit_summary = bg.get("situation", "") key_dets = list(bg.get("specific_details") or []) if isinstance(bg, dict) else []
key_dets = list(bg.get("specific_details") or [])
else:
# Legacy CallerBackground object from stale checkpoint — removed in later commit
comm_style = getattr(bg, "communication_style", "")
sit_summary = getattr(bg, "situation_summary", "")
sig = getattr(bg, "signature_detail", "")
key_dets = [sig] if sig else []
topic_cat = ""
emo_state = ""
energy = ""
quality_signals = _assess_call_quality( quality_signals = _assess_call_quality(
conversation, conversation,
@@ -2472,10 +2329,7 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li
started_at=started_at, started_at=started_at,
ended_at=ended_at, ended_at=ended_at,
quality_signals=quality_signals, quality_signals=quality_signals,
topic_category=topic_cat,
situation_summary=sit_summary, situation_summary=sit_summary,
emotional_state=emo_state,
energy_level=energy,
communication_style=comm_style, communication_style=comm_style,
key_details=key_dets, key_details=key_dets,
)) ))
@@ -2491,47 +2345,28 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li
regular_caller_service.update_after_call(base["regular_id"], summary) regular_caller_service.update_after_call(base["regular_id"], summary)
elif len(conversation) >= 8 and random.random() < 0.05: elif len(conversation) >= 8 and random.random() < 0.05:
# 5% chance to promote first-timer with 8+ messages # 5% chance to promote first-timer with 8+ messages
bg = session.caller_backgrounds.get(caller_key, "") bg = session.caller_backgrounds.get(caller_key) or {}
if isinstance(bg, dict):
if isinstance(bg, CallerBackground): traits = list(bg.get("specific_details") or [])[:4]
# Clean extraction from structured data promo_job = bg.get("identity", "") or ""
traits = [bg.signature_detail] + bg.seeds[:3] if bg.signature_detail else bg.seeds[:4] promo_location = bg.get("location") or "unknown"
promo_job = bg.job promo_age = bg.get("age") or random.randint(*base.get("age_range", (30, 50)))
promo_location = bg.location or "unknown"
promo_age = bg.age
promo_gender = bg.gender
else:
# Legacy fallback — fragile string parsing
traits = []
for label in ["QUIRK", "STRONG OPINION", "SECRET SIDE", "FOOD OPINION"]:
for line in bg.split("\n"):
if label in line:
traits.append(line.split(":", 1)[-1].strip()[:80])
break
first_line = bg.split(".")[0] if bg else ""
parts = first_line.split(",", 1)
job_loc = parts[1].strip() if len(parts) > 1 else ""
job_parts = job_loc.rsplit(" in ", 1) if " in " in job_loc else (job_loc, "unknown")
promo_job = job_parts[0].strip() if isinstance(job_parts, tuple) else job_parts[0]
promo_location = "in " + job_parts[1].strip() if isinstance(job_parts, tuple) and len(job_parts) > 1 else "unknown"
promo_age = random.randint(*base.get("age_range", (30, 50)))
promo_gender = base.get("gender", "male") promo_gender = base.get("gender", "male")
structured_bg = dict(bg)
structured_bg = asdict(bg) if isinstance(bg, CallerBackground) else None avatar_path = avatar_service.get_path(caller_name)
avatar_path = avatar_service.get_path(caller_name) regular_caller_service.add_regular(
regular_caller_service.add_regular( name=caller_name,
name=caller_name, gender=promo_gender,
gender=promo_gender, age=promo_age,
age=promo_age, job=promo_job,
job=promo_job, location=promo_location,
location=promo_location, personality_traits=traits,
personality_traits=traits[:4], first_call_summary=summary,
first_call_summary=summary, voice=base.get("voice"),
voice=base.get("voice"), stable_seeds={},
stable_seeds={}, structured_background=structured_bg,
structured_background=structured_bg, avatar=avatar_path.name if avatar_path else None,
avatar=avatar_path.name if avatar_path else None, )
)
except Exception as e: except Exception as e:
print(f"[Regulars] Promotion logic error: {e}") print(f"[Regulars] Promotion logic error: {e}")
@@ -3289,7 +3124,7 @@ async def chat(request: ChatRequest):
max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping) max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping)
messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():]) messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():])
_caller_name = session.caller.get("name", "") if session.caller else "" _caller_name = session.caller.get("name", "") if session.caller else ""
_model_override = session.get_caller_model(session.current_caller_key) if session.current_caller_key else None _model_override = None # caller_dialog category routes to haiku-4.5
response = await llm_service.generate( response = await llm_service.generate(
messages=messages, messages=messages,
system_prompt=system_prompt, system_prompt=system_prompt,
@@ -4250,7 +4085,7 @@ async def _trigger_ai_auto_respond(accumulated_text: str):
max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping) max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping)
messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():]) messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():])
_caller_name = session.caller.get("name", "") if session.caller else "" _caller_name = session.caller.get("name", "") if session.caller else ""
_model_override = session.get_caller_model(session.current_caller_key) if session.current_caller_key else None _model_override = None # caller_dialog category routes to haiku-4.5
response = await llm_service.generate( response = await llm_service.generate(
messages=messages, messages=messages,
system_prompt=system_prompt, system_prompt=system_prompt,
@@ -4308,9 +4143,7 @@ async def _trigger_ai_auto_respond(accumulated_text: str):
broadcast_event("ai_status", {"text": f"{ai_name} is speaking..."}) broadcast_event("ai_status", {"text": f"{ai_name} is speaking..."})
try: try:
audio_bytes = await generate_speech(response, session.caller["voice"], "none", audio_bytes = await generate_speech(response, session.caller["voice"], "none",
provider_override=session.caller.get("tts_provider"), provider_override=session.caller.get("tts_provider"))
emotional_state=session.caller.get("emotional_state", ""),
energy_level=session.caller.get("energy_level", ""))
except Exception as e: except Exception as e:
print(f"[Auto-Respond] TTS failed: {e}") print(f"[Auto-Respond] TTS failed: {e}")
broadcast_event("ai_done") broadcast_event("ai_done")
@@ -4370,7 +4203,7 @@ async def ai_respond():
max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping) max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping)
messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():]) messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():])
_caller_name = session.caller.get("name", "") if session.caller else "" _caller_name = session.caller.get("name", "") if session.caller else ""
_model_override = session.get_caller_model(session.current_caller_key) if session.current_caller_key else None _model_override = None # caller_dialog category routes to haiku-4.5
response = await llm_service.generate( response = await llm_service.generate(
messages=messages, messages=messages,
system_prompt=system_prompt, system_prompt=system_prompt,
@@ -4420,15 +4253,11 @@ async def ai_respond():
ai_name = caller["name"] ai_name = caller["name"]
ai_voice = caller["voice"] ai_voice = caller["voice"]
ai_tts_provider = caller.get("tts_provider") ai_tts_provider = caller.get("tts_provider")
ai_emotional_state = caller.get("emotional_state", "")
ai_energy_level = caller.get("energy_level", "")
# TTS — outside the lock so other requests aren't blocked # TTS — outside the lock so other requests aren't blocked
try: try:
audio_bytes = await generate_speech(response, ai_voice, "none", audio_bytes = await generate_speech(response, ai_voice, "none",
provider_override=ai_tts_provider, provider_override=ai_tts_provider)
emotional_state=ai_emotional_state,
energy_level=ai_energy_level)
except Exception as e: except Exception as e:
print(f"[AI-Respond] TTS failed: {e}") print(f"[AI-Respond] TTS failed: {e}")
broadcast_event("ai_done") broadcast_event("ai_done")