Phase 5B commit 4: delete style/shape/voice-matching/model-routing systems

Backend cleanup:
- Delete STYLE_SPEED_MODIFIERS, STYLE_PHONE_QUALITY constants
- Delete _normalize_style_key, _match_voices_to_styles,
  get_style_speed_modifier, get_style_phone_quality functions
- Delete _pick_caller_style, _HEAVY_STYLES, _LIGHT_STYLES,
  _EVASIVE_STYLES (only called by template path)
- Delete _is_informal_style + _INFORMAL_STYLES
- Remove orphaned calls to deleted _assign_call_shape
- Drop shape parameter from _pick_response_budget
- Simplify _assess_call_quality (no more shape/style/pool_name)
- Delete Session fields: caller_styles, caller_shapes,
  caller_model_strategy/pool/map/fallback/models/_cycle_idx
- Remove those fields from Session.reset, checkpoint save/load,
  and session.caller property
- Drop style/shape lookups in chat callsites and promotion gate
- Update config.py caller_dialog model stale comment
This commit is contained in:
2026-04-05 13:17:37 -06:00
parent ab811bda91
commit 9982f65742
2 changed files with 15 additions and 558 deletions
+1 -2
View File
@@ -34,9 +34,8 @@ class Settings(BaseSettings):
ollama_host: str = "http://localhost:11434" ollama_host: str = "http://localhost:11434"
# Per-category model routing # Per-category model routing
# caller_dialog is overridden by style_matched routing (see Session.caller_model_map)
category_models: dict = { 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_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) "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) "background_gen": "anthropic/claude-sonnet-4.6", # backgrounds drive the whole call — worth the quality ($3/$15, ~$0.30/show)
+14 -556
View File
@@ -4994,101 +4994,6 @@ CALLER_STYLE_KEYS = [
"rambling", # 17 "rambling", # 17
] ]
# Preferred voice dimensions for each communication style.
# None = no preference (matcher can pick any value for that dimension).
# Maps style key → dict of preferred VOICE_PROFILES dimensions.
# Used by voice matching (Phase 2c) to score voices against caller personality.
STYLE_VOICE_PREFERENCES = {
"quiet_nervous": {"weight": "light", "energy": "low", "warmth": None, "age_feel": None},
"storyteller": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": None},
"deadpan": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": None},
"high_energy": {"weight": None, "energy": "high", "warmth": "warm", "age_feel": "young"},
"confrontational": {"weight": "heavy", "energy": "high", "warmth": "cool", "age_feel": None},
"oversharer": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": None},
"philosopher": {"weight": "heavy", "energy": "low", "warmth": "warm", "age_feel": "mature"},
"bragger": {"weight": "heavy", "energy": "high", "warmth": "neutral", "age_feel": "middle"},
"first_time": {"weight": "light", "energy": "low", "warmth": "warm", "age_feel": "young"},
"emotional": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": None},
"world_weary": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
"conspiracy": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"comedian": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": None},
"angry_venting": {"weight": "heavy", "energy": "high", "warmth": "neutral", "age_feel": None},
"sweet_earnest": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": None},
"mysterious": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "middle"},
"know_it_all": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
"rambling": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": None},
}
# --- Call Shapes ---
# Each shape defines the dramatic arc of a call. Weights control frequency.
CALL_SHAPES = [
("standard", 25), # Normal call — caller has a thing, they talk about it
("escalating_reveal", 15), # Starts mundane, each exchange reveals something bigger
("am_i_the_asshole", 10), # Caller wants validation but the situation is morally gray
("confrontation", 10), # Caller is fired up and wants to argue/vent
("celebration", 8), # Something great happened — caller is riding high
("quick_hit", 10), # Short and punchy — one thing to say, says it, done
("bait_and_switch", 8), # Starts as one thing, turns out to be something completely different
("the_hangup", 7), # Caller gets upset/embarrassed and hangs up mid-call
("reactive", 7), # Caller is reacting to something that happened earlier on the show
]
_CALL_SHAPE_NAMES = [s[0] for s in CALL_SHAPES]
_CALL_SHAPE_WEIGHTS = [s[1] for s in CALL_SHAPES]
# Shape-style affinities: multipliers for base shape weights per communication style
SHAPE_STYLE_AFFINITIES = {
"quiet_nervous": {"the_hangup": 2.0, "escalating_reveal": 1.5, "bait_and_switch": 1.5, "confrontation": 0.3},
"storyteller": {"escalating_reveal": 2.0, "bait_and_switch": 1.5, "standard": 1.5, "quick_hit": 0.3},
"deadpan": {"quick_hit": 1.5, "am_i_the_asshole": 1.5, "confrontation": 1.3},
"high_energy": {"confrontation": 1.5, "celebration": 1.5, "reactive": 1.5, "the_hangup": 0.5},
"confrontational": {"confrontation": 3.0, "reactive": 2.0, "am_i_the_asshole": 1.5, "celebration": 0.3},
"oversharer": {"am_i_the_asshole": 2.0, "escalating_reveal": 1.5, "standard": 1.5},
"philosopher": {"standard": 1.5, "reactive": 1.5, "confrontation": 1.3},
"bragger": {"am_i_the_asshole": 2.0, "confrontation": 1.5, "celebration": 1.5, "the_hangup": 0.3},
"first_time": {"standard": 2.0, "the_hangup": 1.5, "quick_hit": 0.5},
"emotional": {"escalating_reveal": 2.0, "the_hangup": 1.5, "bait_and_switch": 1.5, "quick_hit": 0.3},
"world_weary": {"standard": 1.5, "reactive": 1.5, "am_i_the_asshole": 1.3, "celebration": 0.3},
"conspiracy": {"escalating_reveal": 2.0, "bait_and_switch": 1.5, "confrontation": 1.3},
"comedian": {"quick_hit": 2.0, "bait_and_switch": 1.5, "celebration": 1.3, "the_hangup": 0.3},
"angry_venting": {"confrontation": 2.5, "reactive": 2.0, "the_hangup": 1.5, "celebration": 0.2},
"sweet_earnest": {"celebration": 2.0, "standard": 1.5, "reactive": 1.3, "confrontation": 0.3},
"mysterious": {"the_hangup": 2.5, "escalating_reveal": 2.0, "bait_and_switch": 1.5, "quick_hit": 0.3},
"know_it_all": {"confrontation": 1.5, "am_i_the_asshole": 1.5, "reactive": 1.3},
"rambling": {"bait_and_switch": 1.5, "escalating_reveal": 1.5, "standard": 1.3, "quick_hit": 0.3},
}
def _pick_call_shape(style: str = "") -> str:
"""Pick a call shape using weighted random selection.
If a communication style is provided, applies affinity multipliers.
Also avoids repeating the last used shape."""
weights = list(_CALL_SHAPE_WEIGHTS)
# Apply style affinities
if style:
style_key = _normalize_style_key(style)
affinities = SHAPE_STYLE_AFFINITIES.get(style_key, {})
for i, name in enumerate(_CALL_SHAPE_NAMES):
if name in affinities:
weights[i] *= affinities[name]
# Reduce weight of recently used shapes to avoid consecutive repeats
if hasattr(session, 'call_history') and session.call_history:
# Check if any recent call used this shape
recent_shapes = set()
for record in session.call_history[-2:]:
for k, v in session.caller_shapes.items():
if CALLER_BASES.get(k, {}).get("name") == record.caller_name:
recent_shapes.add(v)
for i, name in enumerate(_CALL_SHAPE_NAMES):
if name in recent_shapes:
weights[i] *= 0.4 # Reduce but don't eliminate
return random.choices(_CALL_SHAPE_NAMES, weights=weights, k=1)[0]
def pick_location() -> str: def pick_location() -> str:
if random.random() < 0.8: if random.random() < 0.8:
@@ -5245,14 +5150,6 @@ Output ONLY valid JSON, no markdown fences."""
person1, person2 = rng.sample(people_pool, 2) person1, person2 = rng.sample(people_pool, 2)
tic1, tic2 = rng.sample(VERBAL_TICS, 2) tic1, tic2 = rng.sample(VERBAL_TICS, 2)
# Restore stored communication style
stored_style = seeds.get("style", "")
if stored_style:
for key, b in CALLER_BASES.items():
if b is base or b.get("name") == base.get("name"):
session.caller_styles[key] = stored_style
break
time_ctx = _get_time_context() time_ctx = _get_time_context()
trait_str = ", ".join(traits) if traits else "a regular caller" trait_str = ", ".join(traits) if traits else "a regular caller"
@@ -5460,72 +5357,6 @@ def _pick_unique_reason() -> tuple[str, str]:
return reason, chosen return reason, chosen
# Style indices by name fragment for filtering
_HEAVY_STYLES = ["emotional", "raw", "quiet", "nervous", "world-weary", "sweet", "earnest"]
_LIGHT_STYLES = ["comedian", "bragger", "high-energy", "confrontational"]
_EVASIVE_STYLES = ["mysterious", "evasive"]
def _pick_caller_style(reason: str, pool_name: str) -> str:
"""Pick a communication style appropriate for the caller's reason and pool."""
reason_lower = reason.lower()
style_lower_map = [(s, s.lower()) for s in CALLER_STYLES]
# Heavy emotional content — exclude styles that trivialize it
heavy_keywords = ["dying", "suicide", "terminal", "cancer", "funeral", "dead ",
"death", "grief", "miscarriage", "abuse", "assault", "murder"]
if any(kw in reason_lower for kw in heavy_keywords):
filtered = [s for s, sl in style_lower_map
if not any(t in sl for t in _LIGHT_STYLES)]
if filtered:
return random.choice(filtered)
# Gossip pool — evasive or oversharer fit well, exclude emotional/raw
if pool_name == "GOSSIP":
filtered = [s for s, sl in style_lower_map
if not any(t in sl for t in _HEAVY_STYLES)]
if filtered:
return random.choice(filtered)
# Stories pool — storyteller and high-energy fit, exclude evasive
if pool_name == "STORIES":
filtered = [s for s, sl in style_lower_map
if not any(t in sl for t in _EVASIVE_STYLES)]
if filtered:
return random.choice(filtered)
# Hot takes — confrontational/opinionated styles only
if pool_name == "HOT_TAKES":
_hot_take_exclude = ["quiet", "nervous", "sweet", "earnest", "emotional", "raw", "world-weary"]
filtered = [s for s, sl in style_lower_map
if not any(t in sl for t in _hot_take_exclude)]
if filtered:
return random.choice(filtered)
# Topic/trivia calls — exclude emotional/raw styles
if pool_name == "TOPIC_CALLIN":
filtered = [s for s, sl in style_lower_map
if not any(t in sl for t in ["emotional", "raw"])]
if filtered:
return random.choice(filtered)
return random.choice(CALLER_STYLES)
def _assign_call_shape(base: dict) -> str:
"""Pick and store a call shape for a caller, logging the assignment.
Uses style-based affinities when a communication style is assigned."""
caller_key = None
for key, b in CALLER_BASES.items():
if b is base or b.get("name") == base.get("name"):
caller_key = key
break
style = session.caller_styles.get(caller_key, "") if caller_key else ""
shape = _pick_call_shape(style)
if caller_key:
session.caller_shapes[caller_key] = shape
print(f"[Shape] {base.get('name', caller_key)} assigned shape: {shape} (style: {style[:30]})")
return shape
def generate_caller_background(base: dict) -> CallerBackground | str: def generate_caller_background(base: dict) -> CallerBackground | str:
"""Generate a template-based background as fallback. The preferred path is """Generate a template-based background as fallback. The preferred path is
@@ -5550,16 +5381,6 @@ def generate_caller_background(base: dict) -> CallerBackground | str:
# Core identity (problem or topic) # Core identity (problem or topic)
reason, pool_name = _pick_unique_reason() reason, pool_name = _pick_unique_reason()
# Assign communication style matched to content
style = _pick_caller_style(reason, pool_name)
for key, b in CALLER_BASES.items():
if b is base or b.get("name") == base.get("name"):
session.caller_styles[key] = style
break
# Assign call shape
_assign_call_shape(base)
interest1, interest2 = random.sample(INTERESTS, 2) interest1, interest2 = random.sample(INTERESTS, 2)
quirk1, quirk2 = random.sample(QUIRKS, 2) quirk1, quirk2 = random.sample(QUIRKS, 2)
@@ -5742,30 +5563,8 @@ async def _generate_caller_background_llm(base: dict) -> CallerBackground | str:
else: else:
reason, pool_name = _pick_unique_reason() reason, pool_name = _pick_unique_reason()
# Assign communication style matched to content style_hint = ""
style = _pick_caller_style(reason, pool_name) energy_level = random.choice(["medium", "high"])
caller_key = None
for key, b in CALLER_BASES.items():
if b is base or b.get("name") == base.get("name"):
caller_key = key
break
if caller_key:
session.caller_styles[caller_key] = style
style_hint = style.split(":")[1].strip()[:120] if ":" in style else ""
# Determine energy level from style
_high_energy_styles = {"high-energy", "confrontational", "angry/venting", "bragger", "comedian"}
_low_energy_styles = {"quiet/nervous", "world-weary", "mysterious/evasive", "sweet/earnest", "emotional/raw"}
style_label = style.split(":")[0].strip().lower() if ":" in style else style.lower()
if style_label in _high_energy_styles:
energy_level = random.choice(["high", "very_high"])
elif style_label in _low_energy_styles:
energy_level = random.choice(["low", "medium"])
else:
energy_level = random.choice(["medium", "high"])
# Assign call shape
_assign_call_shape(base)
# Pick a few random color details as seeds — not a full list # Pick a few random color details as seeds — not a full list
seeds = [] seeds = []
@@ -5923,106 +5722,9 @@ async def _regenerate_backgrounds_for_keys(keys: list[str]):
session.caller_backgrounds[key] = generate_caller_background(CALLER_BASES[key]) session.caller_backgrounds[key] = generate_caller_background(CALLER_BASES[key])
else: else:
session.caller_backgrounds[key] = result session.caller_backgrounds[key] = result
# Clear cached model assignments so style matching re-evaluates
for key in keys:
session.caller_models.pop(key, None)
# Re-run voice matching and queue sorting
_match_voices_to_styles()
_sort_caller_queue()
print(f"[Background] Regenerated {len(keys)} caller backgrounds after theme change") print(f"[Background] Regenerated {len(keys)} caller backgrounds after theme change")
# Dramatic shapes that play better later in the show
_LATE_SHOW_SHAPES = {"escalating_reveal", "bait_and_switch", "the_hangup"}
def _sort_caller_queue():
"""Sort caller presentation order for good show pacing.
Does NOT change which callers exist only the order they're presented.
Prioritizes: energy alternation, topic variety, shape variety,
dramatic shapes later in the show."""
keys = list(session.caller_backgrounds.keys())
if not keys:
return
# Gather attributes for each caller
caller_attrs = {}
for key in keys:
bg = session.caller_backgrounds.get(key)
if isinstance(bg, CallerBackground):
energy = bg.energy_level
pool = bg.pool_name
else:
energy = "medium"
pool = ""
shape = session.caller_shapes.get(key, "standard")
caller_attrs[key] = {"energy": energy, "pool": pool, "shape": shape}
# Greedy placement: pick the best next caller at each position
remaining = list(keys)
ordered = []
for position in range(len(keys)):
best_key = None
best_score = -999
for key in remaining:
attrs = caller_attrs[key]
score = 0.0
# Energy alternation: penalize same energy as previous caller
if ordered:
prev_energy = caller_attrs[ordered[-1]]["energy"]
if attrs["energy"] == prev_energy:
score -= 3.0
# Bonus for contrast
high = {"high", "very_high"}
low = {"low", "medium"}
if (attrs["energy"] in high and prev_energy in low) or \
(attrs["energy"] in low and prev_energy in high):
score += 2.0
# Topic variety: penalize same pool as previous caller
if ordered:
prev_pool = caller_attrs[ordered[-1]]["pool"]
if attrs["pool"] and attrs["pool"] == prev_pool:
score -= 3.0
# Also check 2-back
if len(ordered) >= 2:
prev2_pool = caller_attrs[ordered[-2]]["pool"]
if attrs["pool"] and attrs["pool"] == prev2_pool:
score -= 1.5
# Shape variety: penalize same shape as previous caller
if ordered:
prev_shape = caller_attrs[ordered[-1]]["shape"]
if attrs["shape"] == prev_shape:
score -= 2.0
# Dramatic shapes: boost for later positions (7-10)
if attrs["shape"] in _LATE_SHOW_SHAPES:
if position >= 6: # positions 7-10 (0-indexed 6-9)
score += 3.0
elif position <= 2: # too early
score -= 2.0
if score > best_score:
best_score = score
best_key = key
ordered.append(best_key)
remaining.remove(best_key)
session.caller_queue = ordered
queue_summary = ", ".join(
f"{CALLER_BASES.get(k, {}).get('name', k)}({caller_attrs[k]['energy'][0]}/{caller_attrs[k]['pool'][:4] if caller_attrs[k]['pool'] else '?'}/{caller_attrs[k]['shape'][:4]})"
for k in ordered
)
print(f"[Pacing] Caller queue: {queue_summary}")
def _build_relationship_context(): def _build_relationship_context():
"""Find regulars with existing relationships who are both in the current session. """Find regulars with existing relationships who are both in the current session.
Inject mutual awareness into both callers' prompts.""" Inject mutual awareness into both callers' prompts."""
@@ -6059,135 +5761,6 @@ def _build_relationship_context():
print(f"[Relationships] {regular['name']} knows {other_name} ({rel_type})") print(f"[Relationships] {regular['name']} knows {other_name} ({rel_type})")
# Style-based TTS speed modifiers — stacks with per-voice and per-utterance adjustments
STYLE_SPEED_MODIFIERS = {
"quiet_nervous": -0.1,
"first_time": -0.08,
"emotional": -0.1,
"world_weary": -0.15,
"philosopher": -0.08,
"storyteller": -0.05,
"high_energy": +0.1,
"confrontational": +0.08,
"angry_venting": +0.08,
"rambling": +0.05,
"comedian": +0.05,
}
# Style-based phone filter quality
STYLE_PHONE_QUALITY = {
"quiet_nervous": "bad",
"mysterious": "bad",
"world_weary": "bad",
"conspiracy": "bad",
"high_energy": "good",
"confrontational": "good",
"bragger": "good",
"comedian": "good",
}
def _normalize_style_key(style: str) -> str:
"""Convert a full CALLER_STYLES string to its short key from CALLER_STYLE_KEYS.
Works by finding the style in the CALLER_STYLES list and returning
the corresponding CALLER_STYLE_KEYS entry. Falls back to the input
lowered if the style is already a short key or not found."""
if not style:
return ""
style_lower = style.strip().lower()
if style_lower in CALLER_STYLE_KEYS:
return style_lower
for i, full_style in enumerate(CALLER_STYLES):
if style == full_style:
return CALLER_STYLE_KEYS[i]
for i, full_style in enumerate(CALLER_STYLES):
if style_lower in full_style.lower():
return CALLER_STYLE_KEYS[i]
return style_lower
def _match_voices_to_styles():
"""Re-assign voices to match caller communication styles after backgrounds are generated."""
from .services.tts import VOICE_PROFILES
for key, base in CALLER_BASES.items():
if base.get("returning"):
continue
style_raw = session.caller_styles.get(key, "")
if not style_raw:
continue
style_key = _normalize_style_key(style_raw)
prefs = STYLE_VOICE_PREFERENCES.get(style_key)
if not prefs:
continue
# Override age_feel based on caller's actual age from background
bg = session.caller_backgrounds.get(key)
if isinstance(bg, CallerBackground) and bg.age:
prefs = dict(prefs) # copy before mutating
if bg.age >= 50:
prefs["age_feel"] = "mature"
elif bg.age >= 35:
prefs["age_feel"] = "middle"
elif bg.age < 25:
prefs["age_feel"] = "young"
gender = base["gender"]
pool = INWORLD_MALE_VOICES if gender == "male" else INWORLD_FEMALE_VOICES
voice_pool = [v for v in pool if v not in BLACKLISTED_VOICES]
scored = []
for voice_name in voice_pool:
profile = VOICE_PROFILES.get(voice_name)
if not profile:
scored.append((voice_name, 0))
continue
score = 0
for dim in ["weight", "energy", "warmth", "age_feel"]:
pref_val = prefs.get(dim)
if pref_val and profile.get(dim) == pref_val:
score += 1
scored.append((voice_name, score))
if scored:
names = [s[0] for s in scored]
weights = [max(1, s[1] * 3) for s in scored]
chosen = random.choices(names, weights=weights, k=1)[0]
used_voices = {CALLER_BASES[k]["voice"] for k in CALLER_BASES if k != key and "voice" in CALLER_BASES[k]}
if chosen in used_voices:
alternatives = [(n, w) for n, w in zip(names, weights) if n not in used_voices]
if alternatives:
alt_names, alt_weights = zip(*alternatives)
chosen = random.choices(alt_names, weights=alt_weights, k=1)[0]
old_voice = base.get("voice", "")
base["voice"] = chosen
if old_voice != chosen:
print(f"[VoiceMatch] {base.get('name', key)}: {old_voice}{chosen} (style: {style_key})")
def get_style_speed_modifier(caller_key: str) -> float:
"""Get the TTS speed modifier for a caller based on their communication style."""
style_raw = session.caller_styles.get(caller_key, "")
if not style_raw:
return 0.0
style_key = _normalize_style_key(style_raw)
return STYLE_SPEED_MODIFIERS.get(style_key, 0.0)
def get_style_phone_quality(caller_key: str) -> str | None:
"""Get the phone filter quality override for a caller based on their style."""
style_raw = session.caller_styles.get(caller_key, "")
if not style_raw:
return None
style_key = _normalize_style_key(style_raw)
return STYLE_PHONE_QUALITY.get(style_key)
# 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
@@ -6608,9 +6181,6 @@ def _deserialize_call_record(data: dict) -> CallRecord:
def _assess_call_quality( def _assess_call_quality(
conversation: list[dict], conversation: list[dict],
caller_hangup: bool = False, caller_hangup: bool = False,
shape: str = "",
style: str = "",
pool_name: str = "",
) -> dict: ) -> dict:
"""Compute heuristic quality signals for a completed call. No LLM needed. """Compute heuristic quality signals for a completed call. No LLM needed.
Returns a plain dict for storage in CallRecord.quality_signals and session.call_quality_signals.""" Returns a plain dict for storage in CallRecord.quality_signals and session.call_quality_signals."""
@@ -6639,9 +6209,6 @@ def _assess_call_quality(
"host_engagement": host_engagement, "host_engagement": host_engagement,
"caller_depth": caller_depth, "caller_depth": caller_depth,
"natural_ending": natural_ending, "natural_ending": natural_ending,
"shape": shape,
"style": style,
"pool_name": pool_name,
} }
@@ -6661,8 +6228,6 @@ class Session:
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.pool_weights: dict[str, float] = _generate_pool_weights() self.pool_weights: dict[str, float] = _generate_pool_weights()
self.caller_styles: dict[str, str] = {}
self.caller_shapes: dict[str, str] = {}
self.tone_streak: list[str] = [] # Track tone per call for variety balancing 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
@@ -6672,50 +6237,6 @@ class Session:
self.relationship_context: dict[str, str] = {} # caller_key → relationship prompt injection 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")
# Caller model routing
self.caller_model_strategy: str = "style_matched" # "single" | "cycle" | "style_matched"
self.caller_model_pool: list[str] = [
"x-ai/grok-4.1-fast", # edgy, casual ($0.20/$0.50)
"x-ai/grok-4", # deep edgy reasoning ($3/$15)
"anthropic/claude-sonnet-4.6", # empathetic, nuanced ($3/$15)
"moonshotai/kimi-k2", # creative, warm, expressive ($0.60/$2)
"mistralai/mistral-large-2512", # dry wit, precise ($0.50/$1.50)
"deepseek/deepseek-chat-v3-0324", # direct, unfiltered ($0.27/$1.10)
"qwen/qwen3-235b-a22b", # meandering storyteller ($0.20/$0.60)
"google/gemini-2.5-pro", # articulate, analytical ($1.25/$10)
"meta-llama/llama-3.3-70b-instruct", # casual, natural hesitation ($0.10/$0.32)
]
self.caller_model_map: dict[str, str] = {
# Grok 4.1 Fast — high-energy swagger, edgy humor, fast
"high_energy": "x-ai/grok-4.1-fast",
"bragger": "x-ai/grok-4.1-fast",
"comedian": "x-ai/grok-4.1-fast",
# Grok 4 Full — deep reasoning for confrontation and arguments
"confrontational": "x-ai/grok-4",
# DeepSeek Chat — raw, direct, no filter
"angry_venting": "deepseek/deepseek-chat-v3-0324",
# Claude Sonnet 4.6 — genuine vulnerability, emotional depth, nuance
"quiet_nervous": "anthropic/claude-sonnet-4.6",
"emotional": "anthropic/claude-sonnet-4.6",
"sweet_earnest": "moonshotai/kimi-k2",
"first_time": "moonshotai/kimi-k2",
"world_weary": "anthropic/claude-sonnet-4.6",
"philosopher": "anthropic/claude-sonnet-4.6",
# Mistral Large — dry, precise, strategic omission
"deadpan": "mistralai/mistral-large-2512",
"mysterious": "mistralai/mistral-large-2512",
# Qwen — loves tangents, detail-rich, born rambler
"storyteller": "qwen/qwen3-235b-a22b",
"rambling": "qwen/qwen3-235b-a22b",
"conspiracy": "qwen/qwen3-235b-a22b",
# Grok 4.1 Fast — oversharing energy, can't stop talking
"oversharer": "x-ai/grok-4.1-fast",
# Mistral Large — pedantic, articulate
"know_it_all": "mistralai/mistral-large-2512",
}
self.caller_model_fallback: str = "anthropic/claude-sonnet-4.6"
self.caller_models: dict[str, str] = {} # caller_key → assigned model
self._caller_model_cycle_idx: int = 0
def start_call(self, caller_key: str): def start_call(self, caller_key: str):
self.current_caller_key = caller_key self.current_caller_key = caller_key
@@ -6925,8 +6446,6 @@ class Session:
"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),
"style": self.caller_styles.get(self.current_caller_key, ""),
"shape": self.caller_shapes.get(self.current_caller_key, "standard"),
"tts_provider": base.get("tts_provider"), "tts_provider": base.get("tts_provider"),
"emotional_state": emotional_state, "emotional_state": emotional_state,
"energy_level": energy_level, "energy_level": energy_level,
@@ -7003,8 +6522,6 @@ class Session:
self._research_task.cancel() self._research_task.cancel()
self._research_task = None self._research_task = None
self.pool_weights = _generate_pool_weights() self.pool_weights = _generate_pool_weights()
self.caller_styles = {}
self.caller_shapes = {}
self.tone_streak = [] self.tone_streak = []
self.call_quality_signals = [] self.call_quality_signals = []
self._wrapping_up = False self._wrapping_up = False
@@ -7153,19 +6670,11 @@ def _save_checkpoint():
"research_notes": session.research_notes, "research_notes": session.research_notes,
"caller_bases": caller_bases_snapshot, "caller_bases": caller_bases_snapshot,
"pool_weights": session.pool_weights, "pool_weights": session.pool_weights,
"caller_styles": session.caller_styles,
"caller_shapes": session.caller_shapes,
"tone_streak": session.tone_streak, "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, "relationship_context": session.relationship_context,
"intern_monitoring": session.intern_monitoring, "intern_monitoring": session.intern_monitoring,
"caller_model_strategy": session.caller_model_strategy,
"caller_model_pool": session.caller_model_pool,
"caller_model_map": session.caller_model_map,
"caller_model_fallback": session.caller_model_fallback,
"caller_models": session.caller_models,
"caller_model_cycle_idx": session._caller_model_cycle_idx,
"costs": cost_tracker.get_live_summary(), "costs": cost_tracker.get_live_summary(),
"cost_records": { "cost_records": {
"llm": [asdict(r) for r in cost_tracker.llm_records], "llm": [asdict(r) for r in cost_tracker.llm_records],
@@ -7205,29 +6714,11 @@ def _load_checkpoint() -> bool:
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.pool_weights = data.get("pool_weights", _generate_pool_weights()) session.pool_weights = data.get("pool_weights", _generate_pool_weights())
session.caller_styles = data.get("caller_styles", {})
session.caller_shapes = data.get("caller_shapes", {})
session.tone_streak = data.get("tone_streak", []) 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.relationship_context = data.get("relationship_context", {})
session.intern_monitoring = data.get("intern_monitoring", True) session.intern_monitoring = data.get("intern_monitoring", True)
session.caller_model_strategy = data.get("caller_model_strategy", "style_matched")
# Use fresh defaults if checkpoint has stale/empty model config
fresh = Session()
saved_pool = data.get("caller_model_pool", [])
saved_map = data.get("caller_model_map", {})
# Detect stale config: check if saved models are in the current OPENROUTER_MODELS list
from backend.services.llm import OPENROUTER_MODELS
valid_models = set(OPENROUTER_MODELS)
saved_map_models = set(saved_map.values()) if saved_map else set()
pool_has_invalid = saved_pool and not all(m in valid_models for m in saved_pool)
map_has_invalid = saved_map_models and not all(m in valid_models for m in saved_map_models)
session.caller_model_pool = saved_pool if saved_pool and not pool_has_invalid else fresh.caller_model_pool
session.caller_model_map = saved_map if saved_map and not map_has_invalid else fresh.caller_model_map
session.caller_model_fallback = fresh.caller_model_fallback if data.get("caller_model_fallback", "") not in valid_models else data["caller_model_fallback"]
session.caller_models = data.get("caller_models", {})
session._caller_model_cycle_idx = data.get("caller_model_cycle_idx", 0)
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:
CALLER_BASES[key]["name"] = snapshot["name"] CALLER_BASES[key]["name"] = snapshot["name"]
@@ -8589,9 +8080,6 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li
quality_signals = _assess_call_quality( quality_signals = _assess_call_quality(
conversation, conversation,
caller_hangup=caller_hangup, caller_hangup=caller_hangup,
shape="",
style=comm_style,
pool_name="",
) )
session.call_quality_signals.append(quality_signals) session.call_quality_signals.append(quality_signals)
@@ -8611,7 +8099,7 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li
key_details=key_dets, key_details=key_dets,
)) ))
print(f"[AI Summary] {caller_name} call summarized: {summary[:80]}...") print(f"[AI Summary] {caller_name} call summarized: {summary[:80]}...")
print(f"[Quality] {caller_name}: exchanges={quality_signals['exchange_count']} avg_len={quality_signals['avg_response_length']:.0f}c host_engagement={quality_signals['host_engagement']} caller_depth={quality_signals['caller_depth']} natural_end={quality_signals['natural_ending']} shape={quality_signals['shape']} style={quality_signals['style']} pool={quality_signals['pool_name']}") print(f"[Quality] {caller_name}: exchanges={quality_signals['exchange_count']} avg_len={quality_signals['avg_response_length']:.0f}c host_engagement={quality_signals['host_engagement']} caller_depth={quality_signals['caller_depth']} natural_end={quality_signals['natural_ending']}")
# Returning caller promotion/update logic # Returning caller promotion/update logic
try: try:
@@ -8623,7 +8111,6 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li
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, "")
caller_style = session.caller_styles.get(caller_key, "")
if isinstance(bg, CallerBackground): if isinstance(bg, CallerBackground):
# Clean extraction from structured data # Clean extraction from structured data
@@ -8660,7 +8147,7 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li
personality_traits=traits[:4], personality_traits=traits[:4],
first_call_summary=summary, first_call_summary=summary,
voice=base.get("voice"), voice=base.get("voice"),
stable_seeds={"style": caller_style}, 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,
) )
@@ -8721,29 +8208,16 @@ def _detect_caller_relationships(caller_key: str, caller_name: str,
import re import re
def _pick_response_budget(shape: str = "standard", wrapping_up: bool = False) -> tuple[int, int]: def _pick_response_budget(wrapping_up: bool = False) -> tuple[int, int]:
"""Pick a random max_tokens and sentence cap for response variety. """Pick a random max_tokens and sentence cap for response variety.
Returns (max_tokens, max_sentences). Returns (max_tokens, max_sentences).
Keeps responses conversational but gives room for real answers. Keeps responses conversational but gives room for real answers.
Token budget is intentionally generous to avoid mid-sentence cutoffs Token budget is intentionally generous to avoid mid-sentence cutoffs
the sentence cap controls actual length. the sentence cap controls actual length."""
Shape overrides the default distribution for certain call types."""
if wrapping_up: if wrapping_up:
return 200, 2 return 200, 2
# Shape-specific overrides
if shape == "quick_hit":
return random.choice([(450, 4), (500, 5)])
elif shape == "escalating_reveal":
roll = random.random()
if roll < 0.4:
return 600, 6 # 40% — tight, forces restraint
else:
return 800, 8 # 60% — room to build but capped
elif shape == "confrontation":
return random.choice([(700, 6), (800, 8)])
# Default distribution — give callers room to tell their story # Default distribution — give callers room to tell their story
roll = random.random() roll = random.random()
if roll < 0.10: if roll < 0.10:
@@ -9171,16 +8645,6 @@ def _apply_pronunciation_fixes(text: str) -> str:
return text return text
_INFORMAL_STYLES = {"nervous", "scattered", "rambling", "high-energy", "comedian",
"angry", "venting", "confrontational"}
def _is_informal_style(style: str) -> bool:
"""Check if a caller style should keep colloquialisms."""
style_lower = style.lower()
return any(s in style_lower for s in _INFORMAL_STYLES)
def clean_for_tts(text: str, formal: bool = True) -> str: def clean_for_tts(text: str, formal: bool = True) -> str:
"""Strip out non-speakable content and fix phonetic spellings for TTS. """Strip out non-speakable content and fix phonetic spellings for TTS.
When formal=False, keeps colloquialisms (gonna, kinda, etc.) for natural-sounding callers.""" When formal=False, keeps colloquialisms (gonna, kinda, etc.) for natural-sounding callers."""
@@ -9441,8 +8905,7 @@ async def chat(request: ChatRequest):
slim_caller = session.caller_backgrounds.get(session.current_caller_key, {}) slim_caller = session.caller_backgrounds.get(session.current_caller_key, {})
system_prompt = get_caller_prompt(slim_caller) system_prompt = get_caller_prompt(slim_caller)
call_shape = session.caller.get("shape", "standard") if session.caller else "standard" max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping)
max_tokens, max_sentences = _pick_response_budget(call_shape, 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 = session.get_caller_model(session.current_caller_key) if session.current_caller_key else None
@@ -9476,11 +8939,10 @@ async def chat(request: ChatRequest):
print(f"[Chat] Discarding stale response (epoch {epoch}{_session_epoch})") print(f"[Chat] Discarding stale response (epoch {epoch}{_session_epoch})")
raise HTTPException(409, "Call changed during response") raise HTTPException(409, "Call changed during response")
print(f"[Chat] Raw LLM ({max_tokens}tok/{max_sentences}s, shape={call_shape}): {response[:100] if response else '(empty)'}...") print(f"[Chat] Raw LLM ({max_tokens}tok/{max_sentences}s): {response[:100] if response else '(empty)'}...")
# Clean response for TTS (remove parenthetical actions, asterisks, etc.) # Clean response for TTS (remove parenthetical actions, asterisks, etc.)
caller_style = session.caller.get("style", "") if session.caller else "" response = clean_for_tts(response, formal=False)
response = clean_for_tts(response, formal=not _is_informal_style(caller_style))
response = _trim_to_sentences(response, max_sentences) response = _trim_to_sentences(response, max_sentences)
response = ensure_complete_thought(response) response = ensure_complete_thought(response)
@@ -9489,7 +8951,7 @@ async def chat(request: ChatRequest):
if caller_hangup: if caller_hangup:
response = response.replace("[HANGUP]", "").strip() response = response.replace("[HANGUP]", "").strip()
session._caller_hangup = True session._caller_hangup = True
print(f"[Chat] Caller hangup detected (shape={call_shape})") print(f"[Chat] Caller hangup detected")
print(f"[Chat] Cleaned: {response[:100] if response else '(empty)'}...") print(f"[Chat] Cleaned: {response[:100] if response else '(empty)'}...")
@@ -10404,8 +9866,7 @@ async def _trigger_ai_auto_respond(accumulated_text: str):
slim_caller = session.caller_backgrounds.get(session.current_caller_key, {}) slim_caller = session.caller_backgrounds.get(session.current_caller_key, {})
system_prompt = get_caller_prompt(slim_caller) system_prompt = get_caller_prompt(slim_caller)
call_shape = session.caller.get("shape", "standard") if session.caller else "standard" max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping)
max_tokens, max_sentences = _pick_response_budget(call_shape, 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 = session.get_caller_model(session.current_caller_key) if session.current_caller_key else None
@@ -10440,8 +9901,7 @@ async def _trigger_ai_auto_respond(accumulated_text: str):
broadcast_event("ai_done") broadcast_event("ai_done")
return return
auto_style = session.caller.get("style", "") if session.caller else "" response = clean_for_tts(response, formal=False)
response = clean_for_tts(response, formal=not _is_informal_style(auto_style))
response = _trim_to_sentences(response, max_sentences) response = _trim_to_sentences(response, max_sentences)
response = ensure_complete_thought(response) response = ensure_complete_thought(response)
@@ -10526,8 +9986,7 @@ async def ai_respond():
slim_caller = session.caller_backgrounds.get(session.current_caller_key, {}) slim_caller = session.caller_backgrounds.get(session.current_caller_key, {})
system_prompt = get_caller_prompt(slim_caller) system_prompt = get_caller_prompt(slim_caller)
call_shape = session.caller.get("shape", "standard") if session.caller else "standard" max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping)
max_tokens, max_sentences = _pick_response_budget(call_shape, 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 = session.get_caller_model(session.current_caller_key) if session.current_caller_key else None
@@ -10559,8 +10018,7 @@ async def ai_respond():
if _session_epoch != epoch: if _session_epoch != epoch:
raise HTTPException(409, "Call changed during response") raise HTTPException(409, "Call changed during response")
ai_style = session.caller.get("style", "") if session.caller else "" response = clean_for_tts(response, formal=False)
response = clean_for_tts(response, formal=not _is_informal_style(ai_style))
response = _trim_to_sentences(response, max_sentences) response = _trim_to_sentences(response, max_sentences)
response = ensure_complete_thought(response) response = ensure_complete_thought(response)