Show quality fixes + preflight check
Ep47 post-mortem: fixed theme ignored by callers (backgrounds now regenerate when theme is set), style-to-model race condition (fallback to sonnet instead of pool[0]), removed bad pronunciation fixes, added age-awareness to voice matching, raised MIN_RESPONSE_WORDS to 50. Swapped problematic model mappings: conspiracy→qwen, know_it_all→mistral, quiet_nervous→llama, emotional→kimi. Added GET /api/show/preflight endpoint with 4 checks: model diversity, theme penetration, voice-age alignment, response coherence (2-exchange simulation of all callers). Frontend preflight modal with expandable check cards. Fixed active caller button not highlighting (moved highlight code before potentially-failing caller info panel code). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1196
-133
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
|||||||
|
# Show Quality Fixes — Episode 47 Post-Mortem
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Fix 5 bugs that ruined tonight's show: theme ignored by callers, wrong LLM models assigned, phonetic pronunciation mangling, voice-age mismatch, and low minimum response threshold.
|
||||||
|
|
||||||
|
**Architecture:** All fixes are in `backend/main.py` except voice-age matching which also touches `backend/services/tts.py` voice matching logic. Each fix is independent — no ordering dependencies between tasks.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, FastAPI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Regenerate caller backgrounds when theme is set
|
||||||
|
|
||||||
|
**Problem:** `_pregenerate_backgrounds()` runs on startup when `session.show_theme` is still `""`. Setting theme via `POST /api/show-theme` only stores the string — doesn't regenerate. Callers have zero theme connection.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:9891-9900` (`set_show_theme` endpoint)
|
||||||
|
- Modify: `backend/main.py:5899-5927` (`_pregenerate_backgrounds`)
|
||||||
|
|
||||||
|
**Step 1: Modify `set_show_theme` to regenerate unused caller backgrounds**
|
||||||
|
|
||||||
|
In `backend/main.py`, replace the `set_show_theme` endpoint (lines 9891-9900):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/api/show-theme")
|
||||||
|
async def set_show_theme(data: dict):
|
||||||
|
theme = data.get("theme", "").strip()[:100]
|
||||||
|
old_theme = session.show_theme
|
||||||
|
session.show_theme = theme
|
||||||
|
if theme:
|
||||||
|
print(f"[Theme] Show theme set: {theme}")
|
||||||
|
elif old_theme:
|
||||||
|
print(f"[Theme] Show theme cleared (was: {old_theme})")
|
||||||
|
|
||||||
|
# Regenerate backgrounds for callers that haven't been on air yet
|
||||||
|
if theme != old_theme:
|
||||||
|
unused_keys = [k for k in CALLER_BASES if k not in session.used_callers]
|
||||||
|
if unused_keys:
|
||||||
|
print(f"[Theme] Regenerating {len(unused_keys)} unused caller backgrounds for theme: {theme or '(none)'}")
|
||||||
|
asyncio.create_task(_regenerate_backgrounds_for_keys(unused_keys))
|
||||||
|
|
||||||
|
return {"theme": session.show_theme}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add `_regenerate_backgrounds_for_keys` helper**
|
||||||
|
|
||||||
|
Add this right after `_pregenerate_backgrounds()` (after line 5927):
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _regenerate_backgrounds_for_keys(keys: list[str]):
|
||||||
|
"""Regenerate backgrounds for specific caller keys (e.g. after theme change)."""
|
||||||
|
tasks = []
|
||||||
|
for key in keys:
|
||||||
|
base = CALLER_BASES.get(key)
|
||||||
|
if base and not base.get("returning"):
|
||||||
|
tasks.append((key, _generate_caller_background_llm(base)))
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
return
|
||||||
|
|
||||||
|
results = await asyncio.gather(*[t[1] for t in tasks], return_exceptions=True)
|
||||||
|
for (key, _), result in zip(tasks, results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
print(f"[Theme] Regen failed for caller {key}: {result}")
|
||||||
|
else:
|
||||||
|
session.caller_backgrounds[key] = result
|
||||||
|
# Clear cached model so it re-evaluates with new style
|
||||||
|
session.caller_models.pop(key, None)
|
||||||
|
|
||||||
|
print(f"[Theme] Regenerated {sum(1 for r in results if not isinstance(r, Exception))}/{len(tasks)} backgrounds")
|
||||||
|
_match_voices_to_styles()
|
||||||
|
_sort_caller_queue()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify `used_callers` exists on session**
|
||||||
|
|
||||||
|
Check that `session.used_callers` tracks which callers have already been on air. If it doesn't exist, use `session.call_history` caller keys instead.
|
||||||
|
|
||||||
|
**Step 4: Test manually**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start server
|
||||||
|
python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000
|
||||||
|
# Set theme and check logs for "[Theme] Regenerating..." messages
|
||||||
|
curl -X POST http://localhost:8000/api/show-theme -H "Content-Type: application/json" -d '{"theme": "Road Stories"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Regenerate caller backgrounds when show theme is set"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Fix style-to-model matching race condition
|
||||||
|
|
||||||
|
**Problem:** `get_caller_model()` is called before `caller_styles` is populated. `caller_styles.get(key)` returns `""`, `_normalize_style_key("")` returns `""`, no match in `caller_model_map` → falls through to `caller_model_pool[0]` (grok-4.1-fast) for everyone.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:6848-6875` (`get_caller_model`)
|
||||||
|
|
||||||
|
**Step 1: Fix `get_caller_model` to defer assignment when style is unknown**
|
||||||
|
|
||||||
|
Replace `get_caller_model` (lines 6848-6875):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_caller_model(self, caller_key: str) -> str | None:
|
||||||
|
"""Get the assigned model for a caller, or assign one based on strategy.
|
||||||
|
Returns None to use default category routing."""
|
||||||
|
if self.caller_model_strategy == "single":
|
||||||
|
return None # use default category_models["caller_dialog"]
|
||||||
|
|
||||||
|
# Already assigned — keep consistent for the whole call
|
||||||
|
if caller_key in self.caller_models:
|
||||||
|
return self.caller_models[caller_key]
|
||||||
|
|
||||||
|
model = None
|
||||||
|
if self.caller_model_strategy == "cycle":
|
||||||
|
if self.caller_model_pool:
|
||||||
|
model = self.caller_model_pool[self._caller_model_cycle_idx % len(self.caller_model_pool)]
|
||||||
|
self._caller_model_cycle_idx += 1
|
||||||
|
elif self.caller_model_strategy == "style_matched":
|
||||||
|
raw_style = self.caller_styles.get(caller_key, "")
|
||||||
|
style_key = _normalize_style_key(raw_style) if raw_style else ""
|
||||||
|
if style_key:
|
||||||
|
model = self.caller_model_map.get(style_key)
|
||||||
|
if not model:
|
||||||
|
# Style not yet populated or no mapping — use fallback, not pool[0]
|
||||||
|
model = self.caller_model_fallback
|
||||||
|
|
||||||
|
if model:
|
||||||
|
self.caller_models[caller_key] = model
|
||||||
|
caller_name = CALLER_BASES.get(caller_key, {}).get("name", caller_key)
|
||||||
|
style_info = self.caller_styles.get(caller_key, "unknown")
|
||||||
|
print(f"[CallerModel] Assigned {model} to {caller_name} (style={_normalize_style_key(style_info) if style_info else 'none'}, strategy={self.caller_model_strategy})")
|
||||||
|
|
||||||
|
return model
|
||||||
|
```
|
||||||
|
|
||||||
|
The key change: when `style_key` is empty (style not yet populated) or has no mapping, use `caller_model_fallback` (claude-sonnet-4.6) instead of `caller_model_pool[0]` (grok-4.1-fast). Claude Sonnet is a much safer default — empathetic, verbose, coherent.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Fix style-to-model race condition — use fallback instead of pool[0]"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Fix pronunciation fixes producing literal phonetic text
|
||||||
|
|
||||||
|
**Problem:** `_PRONUNCIATION_FIXES` replaces "Animas" with "Ah nee mahs" as literal text. TTS reads each word separately ("Ah" "nee" "mahs") instead of blending into the intended pronunciation.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:9141-9152` (`_PRONUNCIATION_FIXES`)
|
||||||
|
- Modify: `backend/main.py:9212-9216` (`_apply_pronunciation_fixes`)
|
||||||
|
|
||||||
|
**Step 1: Remove pronunciation fixes that sound worse than originals**
|
||||||
|
|
||||||
|
The Inworld TTS actually handles most proper nouns fine. The fixes were added speculatively and cause more harm than good. Remove the place names that TTS can handle, keep only abbreviations:
|
||||||
|
|
||||||
|
Replace `_PRONUNCIATION_FIXES` (lines 9141-9152):
|
||||||
|
|
||||||
|
```python
|
||||||
|
_PRONUNCIATION_FIXES = {
|
||||||
|
"Castopod": "Casto pod",
|
||||||
|
"vs": "versus",
|
||||||
|
"govt": "government",
|
||||||
|
"dept": "department",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove `Lordsburg`, `Hachita`, `Deming`, `Bootheel`, `Animas`, and `Rodeo`. These place names either sound fine through TTS or the phonetic replacement sounds worse.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Remove pronunciation fixes that produce worse TTS output"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add age-awareness to voice matching
|
||||||
|
|
||||||
|
**Problem:** Brandy (55 years old) got "Kayla" (young-sounding voice). `_match_voices_to_styles()` scores on style dimensions (weight, energy, warmth, age_feel) but the `age_feel` preference comes from the communication style, not the character's actual age. A "confrontational" style prefers `age_feel: None` (no preference), so a 55-year-old can get a young voice.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:6106-6156` (`_match_voices_to_styles`)
|
||||||
|
|
||||||
|
**Step 1: Add character age to voice scoring**
|
||||||
|
|
||||||
|
In `_match_voices_to_styles`, after getting the style preferences, override `age_feel` based on the caller's actual age from their background:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
|
||||||
|
# Copy prefs so we don't mutate the shared dict
|
||||||
|
prefs = dict(prefs)
|
||||||
|
|
||||||
|
# Override age_feel based on character's actual age
|
||||||
|
bg = session.caller_backgrounds.get(key)
|
||||||
|
if isinstance(bg, CallerBackground) and bg.age:
|
||||||
|
if bg.age >= 50:
|
||||||
|
prefs["age_feel"] = "mature"
|
||||||
|
elif bg.age >= 35:
|
||||||
|
prefs["age_feel"] = "middle"
|
||||||
|
elif bg.age < 25:
|
||||||
|
prefs["age_feel"] = "young"
|
||||||
|
# 25-34: keep style preference or None
|
||||||
|
|
||||||
|
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}, age: {bg.age if isinstance(bg, CallerBackground) else '?'})")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Add age-awareness to voice matching — 55yo won't get young voices"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Raise minimum response word count
|
||||||
|
|
||||||
|
**Problem:** `MIN_RESPONSE_WORDS = 30` lets through fragmented, telegram-style responses that are technically 30+ words but terrible radio.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:8844` (`MIN_RESPONSE_WORDS`)
|
||||||
|
|
||||||
|
**Step 1: Raise the minimum**
|
||||||
|
|
||||||
|
Change line 8844:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MIN_RESPONSE_WORDS = 50 # Retry if response is shorter than this
|
||||||
|
```
|
||||||
|
|
||||||
|
50 words is roughly 2-3 spoken sentences — enough to be a coherent radio response without being overly demanding for short-form exchanges.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Raise MIN_RESPONSE_WORDS from 30 to 50"
|
||||||
|
```
|
||||||
+114
-2
@@ -347,9 +347,14 @@ section h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.caller-btn.active {
|
.caller-btn.active {
|
||||||
background: var(--accent);
|
background: var(--bg);
|
||||||
border-color: var(--accent);
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.caller-btn.active .caller-name {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.call-status {
|
.call-status {
|
||||||
@@ -1929,3 +1934,110 @@ button:focus-visible {
|
|||||||
.log-toggle-btn:hover {
|
.log-toggle-btn:hover {
|
||||||
color: var(--text);
|
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; }
|
||||||
|
|||||||
+19
-2
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Luke at The Roost</title>
|
<title>Luke at The Roost</title>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css?v=2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
<button id="rec-btn" class="rec-btn" title="Record stems for post-production">REC</button>
|
<button id="rec-btn" class="rec-btn" title="Record stems for post-production">REC</button>
|
||||||
<button id="new-session-btn" class="new-session-btn">New Session</button>
|
<button id="new-session-btn" class="new-session-btn">New Session</button>
|
||||||
<button id="export-session-btn">Export</button>
|
<button id="export-session-btn">Export</button>
|
||||||
|
<button id="preflight-btn" class="preflight-btn">Preflight</button>
|
||||||
<button id="settings-btn">Settings</button>
|
<button id="settings-btn">Settings</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-bar">
|
<div class="theme-bar">
|
||||||
@@ -357,8 +358,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Preflight Modal -->
|
||||||
|
<div id="preflight-modal" class="modal hidden">
|
||||||
|
<div class="modal-content preflight-content">
|
||||||
|
<h2>Show Preflight</h2>
|
||||||
|
<div id="preflight-status" class="preflight-status loading">
|
||||||
|
<span class="preflight-status-icon">...</span>
|
||||||
|
<span class="preflight-status-text">Running checks...</span>
|
||||||
|
</div>
|
||||||
|
<div id="preflight-checks" class="preflight-checks"></div>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button id="preflight-test-btn" class="preflight-test-btn">Test Responses</button>
|
||||||
|
<button id="preflight-rerun-btn">Re-run</button>
|
||||||
|
<button id="close-preflight">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/app.js?v=23"></script>
|
<script src="/js/app.js?v=27"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+232
-6
@@ -392,6 +392,17 @@ function initEventListeners() {
|
|||||||
});
|
});
|
||||||
document.getElementById('refresh-ollama')?.addEventListener('click', refreshOllamaModels);
|
document.getElementById('refresh-ollama')?.addEventListener('click', refreshOllamaModels);
|
||||||
|
|
||||||
|
// Preflight
|
||||||
|
document.getElementById('preflight-btn')?.addEventListener('click', () => {
|
||||||
|
document.getElementById('preflight-modal')?.classList.remove('hidden');
|
||||||
|
runPreflight(false);
|
||||||
|
});
|
||||||
|
document.getElementById('preflight-test-btn')?.addEventListener('click', () => runPreflight(true));
|
||||||
|
document.getElementById('preflight-rerun-btn')?.addEventListener('click', () => runPreflight(false));
|
||||||
|
document.getElementById('close-preflight')?.addEventListener('click', () => {
|
||||||
|
document.getElementById('preflight-modal')?.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
// Wrap-up button
|
// Wrap-up button
|
||||||
document.getElementById('wrapup-btn')?.addEventListener('click', wrapUp);
|
document.getElementById('wrapup-btn')?.addEventListener('click', wrapUp);
|
||||||
|
|
||||||
@@ -686,6 +697,31 @@ async function startCall(key, name) {
|
|||||||
document.querySelector('.callers-section')?.classList.add('call-active');
|
document.querySelector('.callers-section')?.classList.add('call-active');
|
||||||
document.querySelector('.chat-section')?.classList.add('call-active');
|
document.querySelector('.chat-section')?.classList.add('call-active');
|
||||||
|
|
||||||
|
// Highlight active caller button immediately
|
||||||
|
document.querySelectorAll('.caller-btn').forEach(btn => {
|
||||||
|
const isActive = btn.dataset.key === key;
|
||||||
|
btn.classList.toggle('active', isActive);
|
||||||
|
if (isActive) {
|
||||||
|
btn.style.outline = '2px solid #5a8a3c';
|
||||||
|
const nameEl = btn.querySelector('.caller-name');
|
||||||
|
if (nameEl) {
|
||||||
|
nameEl.style.background = '#e8791d';
|
||||||
|
nameEl.style.color = '#fff';
|
||||||
|
nameEl.style.padding = '2px 8px';
|
||||||
|
nameEl.style.borderRadius = '4px';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
btn.style.outline = '';
|
||||||
|
const nameEl = btn.querySelector('.caller-name');
|
||||||
|
if (nameEl) {
|
||||||
|
nameEl.style.background = '';
|
||||||
|
nameEl.style.color = '';
|
||||||
|
nameEl.style.padding = '';
|
||||||
|
nameEl.style.borderRadius = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Check if real caller is active (three-way scenario)
|
// Check if real caller is active (three-way scenario)
|
||||||
const realCallerActive = document.getElementById('real-caller-info') &&
|
const realCallerActive = document.getElementById('real-caller-info') &&
|
||||||
!document.getElementById('real-caller-info').classList.contains('hidden');
|
!document.getElementById('real-caller-info').classList.contains('hidden');
|
||||||
@@ -723,14 +759,32 @@ async function startCall(key, name) {
|
|||||||
if (situation) situation.textContent = ci.situation_summary || '';
|
if (situation) situation.textContent = ci.situation_summary || '';
|
||||||
infoPanel.classList.remove('hidden');
|
infoPanel.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
showCallerModelBadge(callerModelAssignments[key] || data.model);
|
showCallerModelBadge(callerModelAssignments[key] || data.model);
|
||||||
|
} catch(e) { console.error('[startCall] showCallerModelBadge error:', e); }
|
||||||
document.getElementById('caller-model-override')?.classList.add('hidden');
|
document.getElementById('caller-model-override')?.classList.add('hidden');
|
||||||
const bgEl = document.getElementById('caller-background');
|
const bgEl = document.getElementById('caller-background');
|
||||||
if (bgEl && data.background) bgEl.textContent = data.background;
|
if (bgEl && data.background) bgEl.textContent = data.background;
|
||||||
|
|
||||||
|
let matchCount = 0;
|
||||||
document.querySelectorAll('.caller-btn').forEach(btn => {
|
document.querySelectorAll('.caller-btn').forEach(btn => {
|
||||||
btn.classList.toggle('active', btn.dataset.key === key);
|
const isActive = btn.dataset.key === key;
|
||||||
|
btn.classList.toggle('active', isActive);
|
||||||
|
if (isActive) {
|
||||||
|
btn.style.outline = '2px solid #5a8a3c';
|
||||||
|
matchCount++;
|
||||||
|
} else {
|
||||||
|
btn.style.outline = '';
|
||||||
|
}
|
||||||
|
const nameEl = btn.querySelector('.caller-name');
|
||||||
|
if (nameEl) {
|
||||||
|
nameEl.style.background = isActive ? '#e8791d' : '';
|
||||||
|
nameEl.style.color = isActive ? '#fff' : '';
|
||||||
|
nameEl.style.padding = isActive ? '2px 8px' : '';
|
||||||
|
nameEl.style.borderRadius = isActive ? '4px' : '';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
console.log(`[ActiveCaller] key=${key}, matched=${matchCount} buttons`);
|
||||||
|
|
||||||
log(`Connected to ${name}` + (realCallerActive ? ' (three-way)' : ''));
|
log(`Connected to ${name}` + (realCallerActive ? ' (three-way)' : ''));
|
||||||
if (!realCallerActive) clearChat();
|
if (!realCallerActive) clearChat();
|
||||||
@@ -779,7 +833,16 @@ async function hangup() {
|
|||||||
document.getElementById('hangup-btn').disabled = true;
|
document.getElementById('hangup-btn').disabled = true;
|
||||||
const wrapBtn = document.getElementById('wrapup-btn');
|
const wrapBtn = document.getElementById('wrapup-btn');
|
||||||
if (wrapBtn) { wrapBtn.disabled = true; wrapBtn.classList.remove('active'); }
|
if (wrapBtn) { wrapBtn.disabled = true; wrapBtn.classList.remove('active'); }
|
||||||
document.querySelectorAll('.caller-btn').forEach(btn => btn.classList.remove('active'));
|
document.querySelectorAll('.caller-btn').forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
const nameEl = btn.querySelector('.caller-name');
|
||||||
|
if (nameEl) {
|
||||||
|
nameEl.style.background = '';
|
||||||
|
nameEl.style.color = '';
|
||||||
|
nameEl.style.padding = '';
|
||||||
|
nameEl.style.borderRadius = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Hide caller info panel and background
|
// Hide caller info panel and background
|
||||||
document.getElementById('caller-info-panel')?.classList.add('hidden');
|
document.getElementById('caller-info-panel')?.classList.add('hidden');
|
||||||
@@ -964,6 +1027,7 @@ async function sendTypedMessage() {
|
|||||||
|
|
||||||
// --- Music (Server-Side) ---
|
// --- Music (Server-Side) ---
|
||||||
let genreMap = {}; // { genre: [track, ...] }
|
let genreMap = {}; // { genre: [track, ...] }
|
||||||
|
let genreQueues = {}; // { genre: [shuffled track indices...] }
|
||||||
let activeGenre = null;
|
let activeGenre = null;
|
||||||
let currentTrackName = '';
|
let currentTrackName = '';
|
||||||
|
|
||||||
@@ -1009,12 +1073,24 @@ async function loadMusic() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function playGenre(genre) {
|
function getNextTrack(genre) {
|
||||||
const genreTracks = genreMap[genre];
|
const genreTracks = genreMap[genre];
|
||||||
if (!genreTracks || genreTracks.length === 0) return;
|
if (!genreTracks || genreTracks.length === 0) return null;
|
||||||
|
// Refill and shuffle queue when empty
|
||||||
|
if (!genreQueues[genre] || genreQueues[genre].length === 0) {
|
||||||
|
const indices = genreTracks.map((_, i) => i);
|
||||||
|
for (let i = indices.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[indices[i], indices[j]] = [indices[j], indices[i]];
|
||||||
|
}
|
||||||
|
genreQueues[genre] = indices;
|
||||||
|
}
|
||||||
|
return genreTracks[genreQueues[genre].shift()];
|
||||||
|
}
|
||||||
|
|
||||||
// Pick a random track from the genre
|
async function playGenre(genre) {
|
||||||
const track = genreTracks[Math.floor(Math.random() * genreTracks.length)];
|
const track = getNextTrack(genre);
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/music/play', {
|
const res = await fetch('/api/music/play', {
|
||||||
@@ -2353,3 +2429,153 @@ async function dismissDevonSuggestion() {
|
|||||||
document.getElementById('devon-suggestion')?.classList.add('hidden');
|
document.getElementById('devon-suggestion')?.classList.add('hidden');
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Preflight ---
|
||||||
|
|
||||||
|
const PREFLIGHT_STATUS_ICONS = { pass: '✓', warn: '⚠', fail: '✗', skip: '—' };
|
||||||
|
|
||||||
|
const PREFLIGHT_CHECK_NAMES = {
|
||||||
|
model_diversity: 'Model Diversity',
|
||||||
|
theme_penetration: 'Theme Penetration',
|
||||||
|
voice_age_alignment: 'Voice-Age Alignment',
|
||||||
|
response_coherence: 'Response Coherence',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runPreflight(testResponses) {
|
||||||
|
const statusEl = document.getElementById('preflight-status');
|
||||||
|
const checksEl = document.getElementById('preflight-checks');
|
||||||
|
const testBtn = document.getElementById('preflight-test-btn');
|
||||||
|
|
||||||
|
statusEl.className = 'preflight-status loading';
|
||||||
|
statusEl.querySelector('.preflight-status-icon').textContent = '...';
|
||||||
|
statusEl.querySelector('.preflight-status-text').textContent = 'Running checks...';
|
||||||
|
checksEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (testResponses && testBtn) testBtn.classList.add('loading');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = '/api/show/preflight' + (testResponses ? '?test_responses=true' : '');
|
||||||
|
const data = await safeFetch(url, {}, 120000);
|
||||||
|
renderPreflightResults(data, statusEl, checksEl);
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.className = 'preflight-status fail';
|
||||||
|
statusEl.querySelector('.preflight-status-icon').textContent = '✗';
|
||||||
|
statusEl.querySelector('.preflight-status-text').textContent = 'Error: ' + err.message;
|
||||||
|
} finally {
|
||||||
|
if (testBtn) testBtn.classList.remove('loading');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreflightResults(data, statusEl, checksEl) {
|
||||||
|
const overall = data.status || 'pass';
|
||||||
|
statusEl.className = 'preflight-status ' + overall;
|
||||||
|
statusEl.querySelector('.preflight-status-icon').textContent = PREFLIGHT_STATUS_ICONS[overall] || '✓';
|
||||||
|
statusEl.querySelector('.preflight-status-text').textContent =
|
||||||
|
overall === 'pass' ? 'All checks passed' :
|
||||||
|
overall === 'warn' ? 'Passed with warnings' : 'Issues found';
|
||||||
|
|
||||||
|
checksEl.innerHTML = '';
|
||||||
|
const checksObj = data.checks || {};
|
||||||
|
for (const [checkKey, check] of Object.entries(checksObj)) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'preflight-check';
|
||||||
|
|
||||||
|
const status = check.status || 'skip';
|
||||||
|
const name = PREFLIGHT_CHECK_NAMES[checkKey] || checkKey;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="preflight-check-header">
|
||||||
|
<span class="preflight-check-name">${escapeHtml(name)}</span>
|
||||||
|
<span class="preflight-check-badge ${status}">${status.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preflight-check-details">${renderCheckDetails(checkKey, check)}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
card.querySelector('.preflight-check-header').addEventListener('click', () => {
|
||||||
|
card.classList.toggle('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
checksEl.appendChild(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCheckDetails(name, check) {
|
||||||
|
const d = check.details || {};
|
||||||
|
switch (name) {
|
||||||
|
case 'model_diversity': return renderModelDiversity(d);
|
||||||
|
case 'theme_penetration': return renderThemePenetration(d);
|
||||||
|
case 'voice_age_alignment': return renderVoiceAgeAlignment(d);
|
||||||
|
case 'response_coherence': return renderResponseCoherence(check);
|
||||||
|
default: return `<pre>${escapeHtml(JSON.stringify(d, null, 2))}</pre>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModelDiversity(d) {
|
||||||
|
const callers = d.callers || [];
|
||||||
|
if (!callers.length) return '<p>No callers to check.</p>';
|
||||||
|
let html = `<table class="preflight-table">
|
||||||
|
<thead><tr><th>Caller</th><th>Style</th><th>Model</th></tr></thead><tbody>`;
|
||||||
|
for (const c of callers) {
|
||||||
|
html += `<tr><td>${escapeHtml(c.name || '')}</td><td>${escapeHtml(c.style || '')}</td><td>${escapeHtml(c.model || '')}</td></tr>`;
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
if (d.max_same_model_pct != null) {
|
||||||
|
html += `<p style="margin-top:8px">${d.max_same_model_pct}% on same model</p>`;
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThemePenetration(d) {
|
||||||
|
let html = '';
|
||||||
|
if (d.theme) html += `<p><strong>Theme:</strong> ${escapeHtml(d.theme)}</p>`;
|
||||||
|
if (d.connected?.length) {
|
||||||
|
html += `<p style="color:var(--accent-green);margin-top:6px">Connected: ${d.connected.map(n => escapeHtml(n)).join(', ')}</p>`;
|
||||||
|
}
|
||||||
|
if (d.not_connected?.length) {
|
||||||
|
html += `<p style="color:var(--text-muted);margin-top:4px">Not connected: ${d.not_connected.map(n => escapeHtml(n)).join(', ')}</p>`;
|
||||||
|
}
|
||||||
|
if (d.penetration_pct != null) {
|
||||||
|
html += `<p style="margin-top:6px">${d.penetration_pct}% penetration</p>`;
|
||||||
|
}
|
||||||
|
return html || '<p>No theme set.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVoiceAgeAlignment(d) {
|
||||||
|
const callers = d.callers || [];
|
||||||
|
if (!callers.length) return '<p>No callers to check.</p>';
|
||||||
|
let html = `<table class="preflight-table">
|
||||||
|
<thead><tr><th>Caller</th><th>Age</th><th>Voice</th><th>Age Feel</th></tr></thead><tbody>`;
|
||||||
|
for (const c of callers) {
|
||||||
|
const cls = c.mismatch ? ' class="mismatch"' : '';
|
||||||
|
html += `<tr${cls}><td>${escapeHtml(c.name || '')}</td><td>${c.age || ''}</td><td>${escapeHtml(c.voice || '')}</td><td>${escapeHtml(c.age_feel || '')}</td></tr>`;
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResponseCoherence(check) {
|
||||||
|
if (check.status === 'skip') {
|
||||||
|
return '<p>Use <strong>Test Responses</strong> button to run this check.</p>';
|
||||||
|
}
|
||||||
|
const d = check.details || {};
|
||||||
|
const results = d.results || [];
|
||||||
|
if (!results.length) return '<p>No test results.</p>';
|
||||||
|
let html = `<table class="preflight-table">
|
||||||
|
<thead><tr><th>Caller</th><th>Model</th><th>R1</th><th>R2</th><th>Avg</th><th></th></tr></thead><tbody>`;
|
||||||
|
for (const c of results) {
|
||||||
|
const cls = c.pass ? '' : ' class="mismatch"';
|
||||||
|
if (c.error) {
|
||||||
|
html += `<tr class="mismatch"><td>${escapeHtml(c.name || '')}</td><td>${escapeHtml(c.model || '')}</td><td colspan="3">${escapeHtml(c.error)}</td><td>✗</td></tr>`;
|
||||||
|
} else {
|
||||||
|
html += `<tr${cls}><td>${escapeHtml(c.name || '')}</td><td>${escapeHtml(c.model || '')}</td><td>${c.r1_words || 0}</td><td>${c.r2_words || 0}</td><td>${c.word_count || 0}</td><td>${c.pass ? '✓' : '✗'}</td></tr>`;
|
||||||
|
if (c.snippet) {
|
||||||
|
html += `<tr><td colspan="6" style="color:var(--text-muted);font-size:0.75rem;padding-left:16px">${escapeHtml(c.snippet)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
const passed = results.filter(r => r.pass).length;
|
||||||
|
html += `<p style="margin-top:8px">${passed}/${results.length} callers passed (min ${50} words per response)</p>`;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user