diff --git a/backend/main.py b/backend/main.py index 0b4e943..8136522 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9884,281 +9884,6 @@ async def set_show_theme(data: dict): return {"theme": session.show_theme} -# --- Caller Model Routing --- - -@app.get("/api/caller-models") -async def get_caller_models(): - """Get current caller model routing config and per-caller assignments.""" - assignments = {} - for key in CALLER_BASES: - name = CALLER_BASES[key].get("name", key) - model = session.caller_models.get(key) - assignments[key] = {"name": name, "model": model or "(default)"} - return { - "strategy": session.caller_model_strategy, - "pool": session.caller_model_pool, - "map": session.caller_model_map, - "fallback": session.caller_model_fallback, - "assignments": assignments, - } - - -@app.post("/api/caller-models") -async def set_caller_models(data: dict): - """Update caller model routing strategy, pool, map, or fallback.""" - if "strategy" in data: - strategy = data["strategy"] - if strategy not in ("single", "cycle", "style_matched"): - raise HTTPException(400, f"Invalid strategy: {strategy}") - session.caller_model_strategy = strategy - print(f"[CallerModel] Strategy set to: {strategy}") - if "pool" in data: - pool = data["pool"] - if not isinstance(pool, list) or not pool: - raise HTTPException(400, "pool must be a non-empty list of model IDs") - session.caller_model_pool = pool - print(f"[CallerModel] Pool set to: {pool}") - if "map" in data: - session.caller_model_map = data["map"] - print(f"[CallerModel] Style map set: {len(data['map'])} entries") - if "fallback" in data: - session.caller_model_fallback = data["fallback"] - print(f"[CallerModel] Fallback set to: {data['fallback']}") - # Clear existing assignments so new strategy takes effect - if "strategy" in data or "pool" in data or "map" in data: - session.caller_models.clear() - session._caller_model_cycle_idx = 0 - print(f"[CallerModel] Cleared caller assignments (new config)") - _save_checkpoint() - return await get_caller_models() - - -@app.post("/api/caller-models/{caller_key}") -async def set_caller_model_override(caller_key: str, data: dict): - """Override the model for a specific caller mid-show.""" - if caller_key not in CALLER_BASES: - raise HTTPException(404, f"Unknown caller key: {caller_key}") - model = data.get("model", "").strip() - if not model: - # Clear override - session.caller_models.pop(caller_key, None) - name = CALLER_BASES[caller_key].get("name", caller_key) - print(f"[CallerModel] Cleared override for {name}") - else: - session.caller_models[caller_key] = model - name = CALLER_BASES[caller_key].get("name", caller_key) - print(f"[CallerModel] Override {name} → {model}") - _save_checkpoint() - return {"caller_key": caller_key, "model": session.caller_models.get(caller_key, "(default)")} - - -# --- Show Preflight --- - -@app.get("/api/show/preflight") -async def show_preflight(test_responses: bool = False): - """Run diagnostic checks before a show goes live.""" - from .services.tts import VOICE_PROFILES - - checks = {} - - # --- 1. Model diversity --- - caller_assignments = [] - model_counts: dict[str, int] = {} - for key, base in CALLER_BASES.items(): - raw_style = session.caller_styles.get(key, "") - style_key = _normalize_style_key(raw_style) if raw_style else "" - # Look up what model they'd get without persisting - model = session.caller_model_map.get(style_key) if style_key else None - if not model: - model = session.caller_model_fallback - caller_assignments.append({ - "name": base.get("name", key), - "style": style_key or "(none)", - "model": model, - }) - model_counts[model] = model_counts.get(model, 0) + 1 - - total_callers = len(caller_assignments) - max_same = max(model_counts.values()) if model_counts else 0 - diversity_status = "fail" if total_callers > 0 and max_same / total_callers > 0.5 else "pass" - checks["model_diversity"] = { - "status": diversity_status, - "details": { - "callers": caller_assignments, - "model_distribution": model_counts, - "max_same_model_pct": round(max_same / total_callers * 100) if total_callers else 0, - }, - } - - # --- 2. Theme penetration --- - if session.show_theme: - theme_words = [w.lower() for w in session.show_theme.split() if len(w) > 2] - hits = [] - misses = [] - for key, base in CALLER_BASES.items(): - bg = session.caller_backgrounds.get(key) - if not isinstance(bg, CallerBackground): - misses.append(base.get("name", key)) - continue - searchable = f"{bg.natural_description} {bg.situation_summary}".lower() - if any(tw in searchable for tw in theme_words): - hits.append(base.get("name", key)) - else: - misses.append(base.get("name", key)) - total_bg = len(hits) + len(misses) - hit_pct = round(len(hits) / total_bg * 100) if total_bg else 0 - theme_status = "warn" if total_bg > 0 and hit_pct < 40 else "pass" - checks["theme_penetration"] = { - "status": theme_status, - "details": { - "theme": session.show_theme, - "connected": hits, - "not_connected": misses, - "penetration_pct": hit_pct, - }, - } - else: - checks["theme_penetration"] = {"status": "skip", "details": {"reason": "No theme set"}} - - # --- 3. Voice-age alignment --- - mismatches = [] - alignments = [] - for key, base in CALLER_BASES.items(): - bg = session.caller_backgrounds.get(key) - if not isinstance(bg, CallerBackground): - continue - voice_name = base.get("voice", "") - profile = VOICE_PROFILES.get(voice_name, {}) - age_feel = profile.get("age_feel", "unknown") - is_mismatch = (bg.age >= 50 and age_feel == "young") or (bg.age < 25 and age_feel == "mature") - entry = { - "name": base.get("name", key), - "age": bg.age, - "voice": voice_name, - "voice_age_feel": age_feel, - "mismatch": is_mismatch, - } - alignments.append(entry) - if is_mismatch: - mismatches.append(entry) - - voice_status = "warn" if mismatches else "pass" - checks["voice_age_alignment"] = { - "status": voice_status, - "details": { - "callers": alignments, - "mismatches": len(mismatches), - }, - } - - # --- 4. Response coherence (optional) --- - if test_responses: - # Test ALL callers, not just a sample — we want confidence every caller works - test_callers = [] - for key, base in CALLER_BASES.items(): - raw_style = session.caller_styles.get(key, "") - style_key = _normalize_style_key(raw_style) if raw_style else "" - model = session.caller_model_map.get(style_key) if style_key else None - if not model: - model = session.caller_model_fallback - test_callers.append((key, base, model)) - - coherence_results = [] - coherence_fail = False - - # Run all tests in parallel for speed - async def _test_caller(key, base, model): - slim_caller = session.caller_backgrounds.get(key, {}) - prompt = get_caller_prompt(slim_caller) - # Simulate a real 3-exchange conversation - max_tok, _ = _pick_response_budget(session.caller_shapes.get(key, "standard")) - messages = [ - {"role": "user", "content": "Hey welcome to the show, what's going on tonight?"}, - ] - try: - # First response - r1 = await llm_service.generate( - messages=messages, system_prompt=prompt, - max_tokens=max_tok, category="caller_dialog", - caller_name=base.get("name", ""), model_override=model, - ) - r1 = await _retry_if_too_short( - r1, llm_service, messages, prompt, - max_tok, base.get("name", ""), model_override=model, - ) - r1_words = len(r1.split()) if r1 else 0 - - # Second exchange — host follow-up - messages.append({"role": "assistant", "content": r1}) - messages.append({"role": "user", "content": "Wait, tell me more about that."}) - r2 = await llm_service.generate( - messages=messages, system_prompt=prompt, - max_tokens=max_tok, category="caller_dialog", - caller_name=base.get("name", ""), model_override=model, - ) - r2 = await _retry_if_too_short( - r2, llm_service, messages, prompt, - max_tok, base.get("name", ""), model_override=model, - ) - r2_words = len(r2.split()) if r2 else 0 - - avg_words = (r1_words + r2_words) // 2 - passed = avg_words >= MIN_RESPONSE_WORDS and r1_words >= 30 and r2_words >= 30 - return { - "name": base.get("name", key), - "model": model, - "word_count": avg_words, - "r1_words": r1_words, - "r2_words": r2_words, - "pass": passed, - "snippet": (r1[:150] + "...") if r1 and len(r1) > 150 else r1, - } - except Exception as e: - return { - "name": base.get("name", key), - "model": model, - "error": str(e), - "pass": False, - } - - results = await asyncio.gather(*[_test_caller(k, b, m) for k, b, m in test_callers]) - for r in results: - coherence_results.append(r) - if not r.get("pass"): - coherence_fail = True - - checks["response_coherence"] = { - "status": "fail" if coherence_fail else "pass", - "details": {"results": coherence_results}, - } - else: - checks["response_coherence"] = {"status": "skip", "details": {"reason": "Use ?test_responses=true to enable"}} - - # --- Overall status --- - statuses = [c["status"] for c in checks.values()] - if "fail" in statuses: - overall = "fail" - elif "warn" in statuses: - overall = "warn" - else: - overall = "pass" - - # Summary - issues = [] - if checks["model_diversity"]["status"] == "fail": - issues.append(f"Model diversity: {checks['model_diversity']['details']['max_same_model_pct']}% on same model") - if checks["theme_penetration"]["status"] == "warn": - issues.append(f"Theme penetration: only {checks['theme_penetration']['details']['penetration_pct']}% connected") - if checks["voice_age_alignment"]["status"] == "warn": - issues.append(f"Voice-age mismatches: {len(mismatches)}") - if checks.get("response_coherence", {}).get("status") == "fail": - failed = [r["name"] for r in checks["response_coherence"]["details"]["results"] if not r.get("pass")] - issues.append(f"Response coherence failed: {', '.join(failed)}") - summary = "; ".join(issues) if issues else "All checks passed" - - return {"status": overall, "checks": checks, "summary": summary} - - # --- Cost Tracking Endpoints --- @app.get("/api/costs") diff --git a/frontend/css/style.css b/frontend/css/style.css index d6b1134..77482f4 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -449,84 +449,6 @@ section h2 { line-height: 1.3; } -/* Caller model indicator */ -.info-badge.model { - background: rgba(100, 140, 220, 0.2); - color: #7ab0e8; - font-size: 0.7rem; - cursor: pointer; -} - -.caller-model-override { - font-size: 0.7rem; - padding: 2px 4px; - background: var(--bg); - color: var(--text); - border: 1px solid rgba(100, 140, 220, 0.3); - border-radius: 4px; - max-width: 140px; -} - -/* Caller button model badge */ -.model-tag { - font-size: 0.55rem; - color: #7ab0e8; - background: rgba(100, 140, 220, 0.15); - padding: 0 3px; - border-radius: 2px; - font-weight: 700; - letter-spacing: 0.3px; - flex-shrink: 0; -} - -/* Caller Models settings section */ -.caller-model-row { - margin-bottom: 8px; -} - -.caller-model-row label { - margin-bottom: 0; -} - -.cm-pool-input { - font-size: 0.8rem; -} - -.cm-style-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 4px; - margin-bottom: 8px; - max-height: 200px; - overflow-y: auto; -} - -.cm-style-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 4px; - background: rgba(255, 255, 255, 0.05); - border-radius: 4px; - padding: 3px 6px; -} - -.cm-style-name { - font-size: 0.7rem; - color: var(--text-muted); - white-space: nowrap; -} - -.cm-style-select { - font-size: 0.7rem; - padding: 2px 3px; - background: var(--bg); - color: var(--text); - border: 1px solid rgba(232, 121, 29, 0.15); - border-radius: 4px; - max-width: 110px; -} - .caller-background-full { margin-top: 8px; font-size: 0.75rem; @@ -1915,110 +1837,3 @@ button:focus-visible { .log-toggle-btn:hover { color: var(--text); } - -/* Preflight */ -.preflight-btn { - background: rgba(90, 138, 60, 0.15); - color: var(--accent-green); - border: 1px solid rgba(90, 138, 60, 0.3); -} -.preflight-btn:hover { - background: rgba(90, 138, 60, 0.25); -} - -.preflight-content { - max-width: 700px; -} - -.preflight-status { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 16px; - border-radius: var(--radius-sm); - margin-bottom: 16px; - font-weight: 700; - font-size: 1.1rem; -} -.preflight-status.pass { background: rgba(90, 138, 60, 0.15); color: var(--accent-green); } -.preflight-status.warn { background: rgba(232, 169, 29, 0.15); color: #e8a91d; } -.preflight-status.fail { background: rgba(204, 34, 34, 0.15); color: var(--accent-red); } -.preflight-status.loading { background: rgba(232, 121, 29, 0.1); color: var(--text-muted); } - -.preflight-checks { - display: flex; - flex-direction: column; - gap: 12px; - max-height: 60vh; - overflow-y: auto; -} - -.preflight-check { - background: var(--bg); - border: 1px solid rgba(232, 121, 29, 0.1); - border-radius: var(--radius-sm); - padding: 12px 16px; -} -.preflight-check-header { - display: flex; - justify-content: space-between; - align-items: center; - cursor: pointer; - user-select: none; -} -.preflight-check-name { - font-weight: 600; - font-size: 0.95rem; -} -.preflight-check-badge { - font-size: 0.75rem; - font-weight: 700; - padding: 2px 8px; - border-radius: 4px; - text-transform: uppercase; -} -.preflight-check-badge.pass { background: rgba(90, 138, 60, 0.2); color: var(--accent-green); } -.preflight-check-badge.warn { background: rgba(232, 169, 29, 0.2); color: #e8a91d; } -.preflight-check-badge.fail { background: rgba(204, 34, 34, 0.2); color: var(--accent-red); } -.preflight-check-badge.skip { background: rgba(154, 139, 120, 0.2); color: var(--text-muted); } - -.preflight-check-details { - margin-top: 10px; - font-size: 0.85rem; - color: var(--text-muted); - display: none; -} -.preflight-check.open .preflight-check-details { - display: block; -} - -.preflight-table { - width: 100%; - border-collapse: collapse; - margin-top: 8px; -} -.preflight-table th { - text-align: left; - color: var(--text-muted); - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - padding: 4px 8px; - border-bottom: 1px solid rgba(232, 121, 29, 0.1); -} -.preflight-table td { - padding: 4px 8px; - font-size: 0.8rem; - color: var(--text); - border-bottom: 1px solid rgba(232, 121, 29, 0.05); -} -.preflight-table tr.mismatch td { color: var(--accent-red); } -.preflight-table tr.connected td { color: var(--accent-green); } - -.preflight-test-btn { - background: rgba(232, 121, 29, 0.15); - color: var(--accent); - border: 1px solid rgba(232, 121, 29, 0.3); -} -.preflight-test-btn:hover { background: rgba(232, 121, 29, 0.25); } -.preflight-test-btn.loading { opacity: 0.6; pointer-events: none; } diff --git a/frontend/index.html b/frontend/index.html index 96dc68c..b3345db 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -15,7 +15,6 @@ -
${escapeHtml(JSON.stringify(d, null, 2))}`;
- }
-}
-
-function renderModelDiversity(d) {
- const callers = d.callers || [];
- if (!callers.length) return 'No callers to check.
'; - let html = `| Caller | Style | Model |
|---|---|---|
| ${escapeHtml(c.name || '')} | ${escapeHtml(c.style || '')} | ${escapeHtml(c.model || '')} |
${d.max_same_model_pct}% on same model
`; - } - return html; -} - -function renderThemePenetration(d) { - let html = ''; - if (d.theme) html += `Theme: ${escapeHtml(d.theme)}
`; - if (d.connected?.length) { - html += `Connected: ${d.connected.map(n => escapeHtml(n)).join(', ')}
`; - } - if (d.not_connected?.length) { - html += `Not connected: ${d.not_connected.map(n => escapeHtml(n)).join(', ')}
`; - } - if (d.penetration_pct != null) { - html += `${d.penetration_pct}% penetration
`; - } - return html || 'No theme set.
'; -} - -function renderVoiceAgeAlignment(d) { - const callers = d.callers || []; - if (!callers.length) return 'No callers to check.
'; - let html = `| Caller | Age | Voice | Age Feel |
|---|---|---|---|
| ${escapeHtml(c.name || '')} | ${c.age || ''} | ${escapeHtml(c.voice || '')} | ${escapeHtml(c.age_feel || '')} |
Use Test Responses button to run this check.
'; - } - const d = check.details || {}; - const results = d.results || []; - if (!results.length) return 'No test results.
'; - let html = `| Caller | Model | R1 | R2 | Avg | |
|---|---|---|---|---|---|
| ${escapeHtml(c.name || '')} | ${escapeHtml(c.model || '')} | ${escapeHtml(c.error)} | ✗ | ||
| ${escapeHtml(c.name || '')} | ${escapeHtml(c.model || '')} | ${c.r1_words || 0} | ${c.r2_words || 0} | ${c.word_count || 0} | ${c.pass ? '✓' : '✗'} |
| ${escapeHtml(c.snippet)} | |||||
${passed}/${results.length} callers passed (min ${50} words per response)
`; - return html; -}