From 9982f65742ef5a085884f4cf0ce70b057d4b4296 Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Sun, 5 Apr 2026 13:17:37 -0600 Subject: [PATCH] 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 --- backend/config.py | 3 +- backend/main.py | 570 ++-------------------------------------------- 2 files changed, 15 insertions(+), 558 deletions(-) diff --git a/backend/config.py b/backend/config.py index 6b7359e..d4f5842 100644 --- a/backend/config.py +++ b/backend/config.py @@ -34,9 +34,8 @@ class Settings(BaseSettings): ollama_host: str = "http://localhost:11434" # Per-category model routing - # caller_dialog is overridden by style_matched routing (see Session.caller_model_map) category_models: dict = { - "caller_dialog": "x-ai/grok-4.1-fast", # fallback if style_matched disabled ($0.20/$0.50) + "caller_dialog": "anthropic/claude-haiku-4.5", # live caller dialog ($0.80/$4) "devon_ask": "x-ai/grok-4.1-fast", # Devon matches show energy, cheap ($0.20/$0.50) "devon_monitor": "google/gemini-2.5-flash", # just yes/no decisions, keep cheap ($0.15/$0.60) "background_gen": "anthropic/claude-sonnet-4.6", # backgrounds drive the whole call — worth the quality ($3/$15, ~$0.30/show) diff --git a/backend/main.py b/backend/main.py index 8136522..b27132c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4994,101 +4994,6 @@ CALLER_STYLE_KEYS = [ "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: if random.random() < 0.8: @@ -5245,14 +5150,6 @@ Output ONLY valid JSON, no markdown fences.""" person1, person2 = rng.sample(people_pool, 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() trait_str = ", ".join(traits) if traits else "a regular caller" @@ -5460,72 +5357,6 @@ def _pick_unique_reason() -> tuple[str, str]: 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: """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) 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) quirk1, quirk2 = random.sample(QUIRKS, 2) @@ -5742,30 +5563,8 @@ async def _generate_caller_background_llm(base: dict) -> CallerBackground | str: else: reason, pool_name = _pick_unique_reason() - # Assign communication style matched to content - style = _pick_caller_style(reason, pool_name) - 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) + style_hint = "" + energy_level = random.choice(["medium", "high"]) # Pick a few random color details as seeds — not a full list seeds = [] @@ -5923,106 +5722,9 @@ async def _regenerate_backgrounds_for_keys(keys: list[str]): session.caller_backgrounds[key] = generate_caller_background(CALLER_BASES[key]) else: 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") -# 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(): """Find regulars with existing relationships who are both in the current session. 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})") -# 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 _TOPIC_SEARCH_MAP = [ # TV shows @@ -6608,9 +6181,6 @@ def _deserialize_call_record(data: dict) -> CallRecord: def _assess_call_quality( conversation: list[dict], caller_hangup: bool = False, - shape: str = "", - style: str = "", - pool_name: str = "", ) -> dict: """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.""" @@ -6639,9 +6209,6 @@ def _assess_call_quality( "host_engagement": host_engagement, "caller_depth": caller_depth, "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.used_reasons: set[str] = set() # Track used caller reasons to prevent repeats 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.call_quality_signals: list[dict] = [] # Per-call quality heuristics for tuning 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.intern_monitoring: bool = True # Devon monitors conversations by default 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): self.current_caller_key = caller_key @@ -6925,8 +6446,6 @@ class Session: "name": base["name"], "voice": base["voice"], "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"), "emotional_state": emotional_state, "energy_level": energy_level, @@ -7003,8 +6522,6 @@ class Session: self._research_task.cancel() self._research_task = None self.pool_weights = _generate_pool_weights() - self.caller_styles = {} - self.caller_shapes = {} self.tone_streak = [] self.call_quality_signals = [] self._wrapping_up = False @@ -7153,19 +6670,11 @@ def _save_checkpoint(): "research_notes": session.research_notes, "caller_bases": caller_bases_snapshot, "pool_weights": session.pool_weights, - "caller_styles": session.caller_styles, - "caller_shapes": session.caller_shapes, "tone_streak": session.tone_streak, "call_quality_signals": session.call_quality_signals, "caller_queue": session.caller_queue, "relationship_context": session.relationship_context, "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(), "cost_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.research_notes = data.get("research_notes", {}) 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.call_quality_signals = data.get("call_quality_signals", []) session.caller_queue = data.get("caller_queue", []) session.relationship_context = data.get("relationship_context", {}) 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(): if key in CALLER_BASES: 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( conversation, caller_hangup=caller_hangup, - shape="", - style=comm_style, - pool_name="", ) 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, )) 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 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: # 5% chance to promote first-timer with 8+ messages bg = session.caller_backgrounds.get(caller_key, "") - caller_style = session.caller_styles.get(caller_key, "") if isinstance(bg, CallerBackground): # 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], first_call_summary=summary, voice=base.get("voice"), - stable_seeds={"style": caller_style}, + stable_seeds={}, structured_background=structured_bg, 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 -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. Returns (max_tokens, max_sentences). Keeps responses conversational but gives room for real answers. Token budget is intentionally generous to avoid mid-sentence cutoffs — - the sentence cap controls actual length. - Shape overrides the default distribution for certain call types.""" + the sentence cap controls actual length.""" if wrapping_up: 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 roll = random.random() if roll < 0.10: @@ -9171,16 +8645,6 @@ def _apply_pronunciation_fixes(text: str) -> str: 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: """Strip out non-speakable content and fix phonetic spellings for TTS. 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, {}) 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(call_shape, 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():]) _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 @@ -9476,11 +8939,10 @@ async def chat(request: ChatRequest): print(f"[Chat] Discarding stale response (epoch {epoch} → {_session_epoch})") 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.) - caller_style = session.caller.get("style", "") if session.caller else "" - response = clean_for_tts(response, formal=not _is_informal_style(caller_style)) + response = clean_for_tts(response, formal=False) response = _trim_to_sentences(response, max_sentences) response = ensure_complete_thought(response) @@ -9489,7 +8951,7 @@ async def chat(request: ChatRequest): if caller_hangup: response = response.replace("[HANGUP]", "").strip() 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)'}...") @@ -10404,8 +9866,7 @@ async def _trigger_ai_auto_respond(accumulated_text: str): slim_caller = session.caller_backgrounds.get(session.current_caller_key, {}) 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(call_shape, 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():]) _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 @@ -10440,8 +9901,7 @@ async def _trigger_ai_auto_respond(accumulated_text: str): broadcast_event("ai_done") return - auto_style = session.caller.get("style", "") if session.caller else "" - response = clean_for_tts(response, formal=not _is_informal_style(auto_style)) + response = clean_for_tts(response, formal=False) response = _trim_to_sentences(response, max_sentences) response = ensure_complete_thought(response) @@ -10526,8 +9986,7 @@ async def ai_respond(): slim_caller = session.caller_backgrounds.get(session.current_caller_key, {}) 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(call_shape, 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():]) _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 @@ -10559,8 +10018,7 @@ async def ai_respond(): if _session_epoch != epoch: raise HTTPException(409, "Call changed during response") - ai_style = session.caller.get("style", "") if session.caller else "" - response = clean_for_tts(response, formal=not _is_informal_style(ai_style)) + response = clean_for_tts(response, formal=False) response = _trim_to_sentences(response, max_sentences) response = ensure_complete_thought(response)