Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00ddc2d1b4 | ||
|
|
1c7ac334b4 | ||
|
|
8a20e679f6 | ||
|
|
35ff78a922 | ||
|
|
61b3cba412 | ||
|
|
44ac569fec | ||
|
|
54ad04ae09 | ||
|
|
c087c0362a | ||
|
|
d0bb50fe12 | ||
|
|
4e28f3fb84 | ||
|
|
9e37fbf124 | ||
|
|
83c7f441a8 | ||
|
|
9982f65742 | ||
|
|
ab811bda91 | ||
|
|
13d613df5f | ||
|
|
effd52a95c | ||
|
|
76feeb22a0 | ||
|
|
3f85ee9140 | ||
|
|
604769a949 | ||
|
|
969f8fb2fd | ||
|
|
d4f1043be1 | ||
|
|
a18e1329f3 | ||
|
|
2fd027b82a | ||
|
|
0c5d36d182 | ||
|
|
d7e475331d | ||
|
|
e4a18e13ab | ||
|
|
470e92f8c4 | ||
|
|
b792c3cca0 | ||
|
|
baa6db5aa2 | ||
|
|
dec6211f7c | ||
|
|
ade5510bd5 | ||
|
|
f56ca3d4bc | ||
|
|
a196e8f088 | ||
|
|
ca7e82f19f | ||
|
|
1b988a0303 | ||
|
|
fa491c1c27 | ||
|
|
486842d2d1 | ||
|
|
9c9f5c6602 | ||
|
|
73d555b269 | ||
|
|
283e4a2f3c | ||
|
|
0d2a0e42a8 |
@@ -59,3 +59,9 @@ upload-history.json
|
||||
|
||||
# Claude settings (local)
|
||||
.claude/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Scratch/smoke tests (not committed)
|
||||
scratch/
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
# AI Podcast - Project Instructions
|
||||
|
||||
## Git Remote (Gitea)
|
||||
- **Repo**: `git@gitea-nas:luke/ai-podcast.git`
|
||||
- **Web**: http://mmgnas:3000/luke/ai-podcast
|
||||
- **SSH Host**: `gitea-nas` (configured in ~/.ssh/config)
|
||||
- HostName: `mmgnas` (use `mmgnas-10g` if wired connection issues)
|
||||
- Port: `2222`
|
||||
- User: `git`
|
||||
- IdentityFile: `~/.ssh/gitea_mmgnas`
|
||||
|
||||
## NAS Access
|
||||
- **Hostname**: `mmgnas` (wireless) or `mmgnas-10g` (wired/10G)
|
||||
- **SSH Port**: 8001
|
||||
- **User**: luke
|
||||
- **Docker path**: `/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker`
|
||||
|
||||
## Castopod (Podcast Publishing)
|
||||
- **URL**: https://podcast.macneilmediagroup.com
|
||||
- **Podcast handle**: `@LukeAtTheRoost`
|
||||
@@ -42,8 +27,7 @@ Required in `.env`:
|
||||
- ELEVENLABS_API_KEY (optional)
|
||||
- INWORLD_API_KEY (for Inworld TTS)
|
||||
|
||||
## Post-Production Pipeline (added Feb 2026)
|
||||
- **Branch**: `feature/real-callers` — all current work is here, pushed to gitea
|
||||
## Post-Production Pipeline
|
||||
- **Stem Recorder** (`backend/services/stem_recorder.py`): Records 5 WAV stems (host, caller, music, sfx, ads) during live shows. Uses lock-free deque architecture — audio callbacks just append to deques, a background writer thread drains to disk. `write()` for continuous streams (host mic, music, ads), `write_sporadic()` for burst sources (caller TTS, SFX) with time-aligned silence padding.
|
||||
- **Audio hooks** in `backend/services/audio.py`: 7 tap points guarded by `if self.stem_recorder:`. Persistent mic stream (`start_stem_mic`/`stop_stem_mic`) runs during recording to capture host voice continuously, not just during push-to-talk.
|
||||
- **API endpoints**: `POST /api/recording/start`, `POST /api/recording/stop` (auto-runs postprod in background thread), `POST /api/recording/process`
|
||||
@@ -58,13 +42,14 @@ Required in `.env`:
|
||||
- `generate_with_tools()` in llm.py supports OpenRouter function calling for the intern feature
|
||||
|
||||
## Caller Generation System
|
||||
- **CallerBackground dataclass**: Structured output from LLM background generation (JSON mode). Fields: name, age, gender, job, location, reason_for_calling, pool_name, communication_style, energy_level, emotional_state, signature_detail, situation_summary, natural_description, seeds, verbal_fluency, calling_from.
|
||||
- **Voice-personality matching**: `_match_voices_to_styles()` runs after background generation. 68 voice profiles in `VOICE_PROFILES` (tts.py), 18 style-to-voice mappings in `STYLE_VOICE_PREFERENCES` (main.py). Soft matching — scores voices against style preferences.
|
||||
- **Adaptive call shapes**: `SHAPE_STYLE_AFFINITIES` maps communication styles to shape weight multipliers. Consecutive shape repeats are dampened.
|
||||
- **Inter-caller awareness**: Thematic matching in `get_show_history()` scores previous callers by keyword/category overlap. Adaptive reaction frequency (60%/35%/15%). Show energy tracking via `_get_show_energy()`.
|
||||
- **Caller memory**: Returning callers store structured backgrounds, key moments, arc status, and relationships with other regulars. `RegularCallerService` has `add_relationship()` and expanded `update_after_call()`.
|
||||
- **Show pacing**: `_sort_caller_queue()` sorts presentation order by energy alternation, topic variety, shape variety.
|
||||
- **Call quality signals**: `_assess_call_quality()` captures exchange count, response length, host engagement, shape target hit, natural ending.
|
||||
- **Two-stage pipeline**: (1) batch identity pregen via Sonnet 4.6 at session start, (2) live dialog via Haiku 4.5 per turn. Cost ~$1/show.
|
||||
- **Slim caller dict**: Populated once at `Session._pregenerate_backgrounds()` via `caller_gen.generate_batch()`. Keys: `name`, `age`, `voice`, `location`, `identity`, `situation`, `reason_calling`, `opening_line`, `secret_want`, `specific_details`, `emotional_register`. Stored in `session.caller_backgrounds[caller_key]`.
|
||||
- **Dialog model**: Always Haiku 4.5 via the `caller_dialog` category in `config.category_models`. No per-caller model routing — deleted in Phase 5B.
|
||||
- **Prompt builder**: `get_caller_prompt(caller)` in main.py builds the slim system prompt from the dict; see `tests/test_caller_prompt.py` for the contract.
|
||||
- **Regulars**: `backend/services/regulars_v2.py` loads lore from Obsidian markdown files for named recurring callers (e.g. Silas). The batch prompt optionally includes 2-3 active regulars per session.
|
||||
- **Inter-caller awareness**: `get_show_history()` scores previous callers by keyword overlap with the current caller's `situation`/`reason_calling`. Reaction frequency scales with match strength (60%/35%/15%).
|
||||
- **Caller memory**: Returning callers auto-promote from first-timers at ~5% probability after 8+ exchanges. `RegularCallerService` tracks summaries, relationships, arc state.
|
||||
- **Call quality signals**: `_assess_call_quality()` captures exchange count, response length, host engagement, caller depth, natural ending.
|
||||
|
||||
## Devon (Intern Character)
|
||||
- **Service**: `backend/services/intern.py` — persistent show character, not a caller
|
||||
@@ -78,8 +63,8 @@ Required in `.env`:
|
||||
## Frontend Control Panel
|
||||
- **Keyboard shortcuts**: 1-0 (callers), H (hangup), W (wrap up), M (music toggle), D (ask Devon), Escape (close modals)
|
||||
- **Wrap It Up**: Amber button that signals callers to wind down gracefully. Reduces response budget, injects wrap-up signals, forces goodbye after 2 exchanges.
|
||||
- **Caller info panel**: Shows call shape, energy level, emotional state, signature detail, situation summary during active calls
|
||||
- **Caller buttons**: Energy dots (colored by level) and shape badges on each button
|
||||
- **Caller info panel**: Shows identity, situation, signature detail, secret want during active calls
|
||||
- **Caller buttons**: Populated from the slim caller background dicts
|
||||
- **Pinned SFX**: Cheer/Applause/Boo always visible, rest collapsible
|
||||
- **Visual polish**: Thinking pulse, call glow, compact media row, smoother transitions
|
||||
|
||||
@@ -88,24 +73,44 @@ Required in `.env`:
|
||||
- **Analytics**: Cloudflare Web Analytics (enable in Cloudflare dashboard, no code changes needed)
|
||||
- **Deploy**: `npx wrangler pages deploy website/ --project-name=lukeattheroost --branch=main`
|
||||
|
||||
## Git Push
|
||||
- If `mmgnas` times out, use the 10g hostname:
|
||||
```bash
|
||||
GIT_SSH_COMMAND="ssh -o HostName=mmgnas-10g -p 2222 -i ~/.ssh/gitea_mmgnas" git push origin main
|
||||
```
|
||||
|
||||
## Hetzner VPS
|
||||
- **IP**: `46.225.164.41`
|
||||
- **SSH**: `ssh root@46.225.164.41` (uses default key `~/.ssh/id_rsa`)
|
||||
- **Specs**: 2 CPU, 4GB RAM, 38GB disk (~33GB free)
|
||||
- **Mail**: `docker-mailserver` at `/opt/mailserver/`
|
||||
- **Manage accounts**: `docker exec mailserver setup email add/del/list`
|
||||
- **Available for future services** — has headroom for lightweight containers. Not suitable for storage-heavy services (e.g. Castopod with daily episodes) without a disk upgrade or attached volume.
|
||||
|
||||
## Podcast Workflow
|
||||
- Publishing pipeline: episodes go through Castopod, CDN, website, YouTube, and social
|
||||
- Always check Python venv is active and packages are installed before running publish scripts
|
||||
- Episode numbering must be verified against existing episodes
|
||||
- Episode numbering: check Castopod for the latest episode number, don't hardcode
|
||||
|
||||
## Episodes Published
|
||||
- Episode 6 published 2026-02-08 (podcast6.mp3, ~31 min)
|
||||
## Scripts
|
||||
- `publish_episode.py` — Transcribes audio, generates metadata (title, description, cover art), publishes to Castopod. Usage: `python publish_episode.py ~/Desktop/episode.mp3`
|
||||
- `make_clips.py` — Two-pass clip extraction: fast Whisper transcription → LLM selects best moments → quality Whisper re-transcription for precise timestamps. Usage: `python make_clips.py ~/Desktop/episode.mp3 --count 3`
|
||||
- `generate_milestone_images.py` — Generates social milestone images via Gemini Flash (requires GOOGLE_API_KEY)
|
||||
- `post_milestone.py` — Posts milestone announcements to social platforms via Postiz
|
||||
- `make_x_launch_assets.py` — Generates branded visual assets for X/Twitter (header, quote cards, intro/review graphics)
|
||||
- `schedule_x_launch.py` — Schedules X/Twitter launch campaign posts via Postiz API
|
||||
|
||||
## Reaper Scripts
|
||||
- `reaper/dialog_regions.lua` — Background script that polls `/tmp/reaper_state.txt` and creates colored regions (green=DIALOG, red=AD, blue=IDENT) as the backend writes state changes during recording
|
||||
- `reaper/strip_silence_dialog.lua` — Post-production script: strips long silences from dialog regions, normalizes AD/IDENT/music volume, trims music to voice length with fade-out, mutes music during AD/IDENT regions
|
||||
|
||||
## Cost Dashboard
|
||||
- **Route**: `/costs` — standalone analytics page, linked from control panel header
|
||||
- **Database**: `data/costs.db` (SQLite) — aggregates all session cost data for cross-session queries
|
||||
- **Data layer**: `backend/services/cost_db.py` — schema, JSON import, all query functions
|
||||
- **Dual-write**: `cost_tracker.py` writes to both JSON (`data/cost_reports/`) and SQLite on every LLM/TTS call
|
||||
- **API**: 8 endpoints under `/api/costs/` — summary, timeline, models, categories, sessions, session detail, expensive calls, TTS providers
|
||||
- **Frontend**: `frontend/costs.html`, `frontend/css/costs.css`, `frontend/js/costs.js` — Chart.js for visualizations
|
||||
- **Pricing**: Hardcoded in `cost_tracker.py` (`OPENROUTER_PRICING`, `TTS_PRICING`) — update when provider prices change
|
||||
- **Not tracked yet**: SignalWire call costs
|
||||
|
||||
## Data Directory
|
||||
State files (not config — these are written at runtime):
|
||||
- `regulars.json` — Returning caller profiles (backgrounds, key moments, arc status, relationships)
|
||||
- `used_topics_history.json` — Previously used caller topics to avoid repeats
|
||||
- `session_checkpoint.json` — Current show session state (call history, caller queue)
|
||||
- `publish_state.json` — Publishing pipeline progress per episode
|
||||
- `intern.json` — Devon's lookup history
|
||||
- `emails.json` — Listener email submissions
|
||||
- `voicemails.json` — Listener voicemail submissions
|
||||
|
||||
## Personal
|
||||
- Don't build anything until you have 95% clarity on what I want you to do. Ask clarifying questions until you reach 95% understanding of what I'm asking
|
||||
- When working as a team, propose the plan before executing — don't just start building
|
||||
- Flag trade-offs that affect show quality or listener experience rather than silently resolving them
|
||||
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"input_device": 13,
|
||||
"input_device": 14,
|
||||
"input_device_name": "Babyface Pro (70793771)",
|
||||
"input_channel": 1,
|
||||
"output_device": 12,
|
||||
"output_device": 13,
|
||||
"output_device_name": "Radio Voice Mic",
|
||||
"caller_channel": 3,
|
||||
"devon_channel": 17,
|
||||
"live_caller_channel": 9,
|
||||
"music_channel": 5,
|
||||
"sfx_channel": 7,
|
||||
|
||||
+1
-2
@@ -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)
|
||||
|
||||
+501
-6930
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import settings
|
||||
from .cost_tracker import cost_tracker
|
||||
|
||||
BATCH_MODEL = "anthropic/claude-sonnet-4.6"
|
||||
|
||||
REQUIRED_FIELDS = {
|
||||
"name", "age", "voice_suggestion", "location", "identity",
|
||||
"situation", "reason_calling", "opening_line", "secret_want",
|
||||
"specific_details", "emotional_register"
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallerIdentity:
|
||||
name: str
|
||||
age: int
|
||||
voice_suggestion: str
|
||||
location: str
|
||||
identity: str
|
||||
situation: str
|
||||
reason_calling: str
|
||||
opening_line: str
|
||||
secret_want: str
|
||||
specific_details: list[str]
|
||||
emotional_register: str
|
||||
# set after voice validation
|
||||
voice_resolved: Optional[str] = None
|
||||
|
||||
|
||||
def parse_batch_response(raw: str) -> list[CallerIdentity]:
|
||||
stripped = raw.strip()
|
||||
if stripped.startswith("```") and stripped.endswith("```"):
|
||||
lines = stripped.splitlines()
|
||||
stripped = "\n".join(lines[1:-1])
|
||||
data = json.loads(stripped)
|
||||
callers = data.get("callers", [])
|
||||
result = []
|
||||
for c in callers:
|
||||
missing = REQUIRED_FIELDS - set(c.keys())
|
||||
if missing:
|
||||
raise ValueError(f"CallerIdentity missing fields: {missing}")
|
||||
result.append(CallerIdentity(**{k: c[k] for k in REQUIRED_FIELDS}))
|
||||
return result
|
||||
|
||||
|
||||
def resolve_voice(suggestion: str, roster: list[str]) -> str:
|
||||
"""Map sonnet's voice suggestion to a real voice in the roster.
|
||||
Case-insensitive exact match; deterministic fallback to first roster entry."""
|
||||
if not suggestion or not roster:
|
||||
return roster[0] if roster else ""
|
||||
lower_map = {v.lower(): v for v in roster}
|
||||
return lower_map.get(suggestion.lower(), roster[0])
|
||||
|
||||
|
||||
BATCH_SYSTEM_PROMPT = """You are writing a roster of callers for Luke's late-night radio show in New Mexico.
|
||||
|
||||
CREATIVE RANGE: Your callers must span the emotional range of Howard Stern (chaos, strong characters), Coast to Coast AM (earnest weirdos, sincere believers), Loveline (real problems, real advice-seeking), Delilah (emotional vulnerability, connection), and Opie and Anthony (sharp, irreverent, specific people).
|
||||
|
||||
Maximum character distance between callers. No two callers should feel like siblings.
|
||||
|
||||
ANTI-COLLISION RULE — THIS IS NON-NEGOTIABLE: All callers in this roster must be clearly differentiated. No two callers may share the same hobby, obsession, profession archetype, or story theme. Specifically forbidden within a single roster: two BBQ competitors, two taxidermists, two amateur-radio or mystery-signal callers, two callers with ex-spouse drama, two conspiracy/pattern-seers, two retired military, two grandmothers-of-many, two callers calling about a weird neighbor, two callers with religious-object stories. Each caller's defining "thing" — their hook, their obsession, the specific topic they're calling about — must appear exactly once in the roster. Before finalizing, scan your output and swap any collisions.
|
||||
|
||||
Do not default to sitcom plots. Real humans are specific and strange. Give each caller details that could only belong to them.
|
||||
|
||||
You will output strict JSON with a "callers" array. Each caller has exactly these fields: name, age, voice_suggestion, location, identity, situation, reason_calling, opening_line, secret_want, specific_details (array of 2-3 strings), emotional_register."""
|
||||
|
||||
|
||||
def build_batch_prompt(ctx: dict) -> str:
|
||||
lines = [
|
||||
f"Tonight is {ctx['date']}. {ctx['weather']}.",
|
||||
"",
|
||||
"Today's news headlines (ground callers in real context, but do not force topicality):",
|
||||
]
|
||||
for h in ctx["headlines"]:
|
||||
lines.append(f"- {h}")
|
||||
lines.append("")
|
||||
|
||||
if ctx["recent_caller_summaries"]:
|
||||
lines.append("Recent callers (DO NOT repeat these archetypes or situations):")
|
||||
for s in ctx["recent_caller_summaries"]:
|
||||
lines.append(f"- {s}")
|
||||
lines.append("")
|
||||
|
||||
if ctx["regulars_included"]:
|
||||
lines.append("RECURRING CHARACTERS IN TONIGHT'S LINEUP:")
|
||||
lines.append("")
|
||||
for r in ctx["regulars_included"]:
|
||||
lines.append(f"### {r['name']}")
|
||||
lines.append(r["lore"])
|
||||
lines.append(f"Current arc state: {r['arc_state']}")
|
||||
lines.append("")
|
||||
lines.append(f"For {r['name']}: invent a fresh reason he is calling tonight — a new development, grievance, or specific recent event. DO NOT alter his voice, personality, or core traits. Write a new scene for an existing character.")
|
||||
lines.append(f"CRITICAL — this caller MUST appear in the output JSON with EXACTLY these fields locked: name=\"{r['name']}\", voice_suggestion=\"{r['voice']}\", age={r['age']}. Do NOT rename this caller. Do NOT give him a different voice. Do NOT change his age. Only invent: location, identity, situation, reason_calling, opening_line, secret_want, specific_details, emotional_register.")
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"Available voices (voice_suggestion must match one of these exactly):")
|
||||
lines.append(", ".join(ctx["voice_roster"]))
|
||||
lines.append("")
|
||||
lines.append(f"Generate {ctx['caller_count']} callers. Output JSON only, no prose.")
|
||||
return BATCH_SYSTEM_PROMPT + "\n\n" + "\n".join(lines)
|
||||
|
||||
|
||||
async def generate_batch(ctx: dict) -> list[CallerIdentity]:
|
||||
"""Call sonnet-4.6 with the batch prompt, parse + voice-resolve the response."""
|
||||
prompt = build_batch_prompt(ctx)
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
|
||||
json={
|
||||
"model": BATCH_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"response_format": {"type": "json_object"},
|
||||
"max_tokens": 16000,
|
||||
"temperature": 0.9,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
usage = data.get("usage", {})
|
||||
cost_tracker.record_llm_call(
|
||||
category="background_gen",
|
||||
model=BATCH_MODEL,
|
||||
usage_data=usage,
|
||||
)
|
||||
|
||||
callers = parse_batch_response(content)
|
||||
for c in callers:
|
||||
c.voice_resolved = resolve_voice(c.voice_suggestion, ctx["voice_roster"])
|
||||
return callers
|
||||
|
||||
|
||||
REGULAR_SYSTEM_PROMPT = """You are writing a single caller — a recurring character on Luke's late-night radio show. The character's identity is fixed (name, voice, age, core lore). You only invent a fresh reason he's calling tonight, grounded in his current arc state.
|
||||
|
||||
Output strict JSON with these fields only: location, identity, situation, reason_calling, opening_line, secret_want, specific_details (array of 2-3 strings), emotional_register. Do NOT include name, voice_suggestion, or age — those are locked."""
|
||||
|
||||
|
||||
async def generate_regular_situation(regular: dict, ctx: dict) -> dict:
|
||||
"""Generate a fresh situation for a locked regular. Returns a dict matching
|
||||
the CallerIdentity schema. One small LLM call (~$0.01)."""
|
||||
lines = [
|
||||
f"Tonight is {ctx['date']}. {ctx['weather']}.",
|
||||
"",
|
||||
f"### {regular['name']}",
|
||||
regular["lore"],
|
||||
f"Current arc state: {regular['arc_state']}",
|
||||
"",
|
||||
f"Invent a fresh reason {regular['name']} is calling tonight — a new development, grievance, or specific recent event. DO NOT alter his voice, personality, or core traits.",
|
||||
"",
|
||||
"Output JSON only, no prose.",
|
||||
]
|
||||
prompt = REGULAR_SYSTEM_PROMPT + "\n\n" + "\n".join(lines)
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
|
||||
json={
|
||||
"model": BATCH_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"response_format": {"type": "json_object"},
|
||||
"max_tokens": 1500,
|
||||
"temperature": 0.9,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
usage = data.get("usage", {})
|
||||
cost_tracker.record_llm_call(
|
||||
category="background_gen",
|
||||
model=BATCH_MODEL,
|
||||
usage_data=usage,
|
||||
)
|
||||
|
||||
stripped = content.strip()
|
||||
if stripped.startswith("```") and stripped.endswith("```"):
|
||||
lines = stripped.splitlines()
|
||||
stripped = "\n".join(lines[1:-1])
|
||||
return json.loads(stripped)
|
||||
@@ -0,0 +1,555 @@
|
||||
"""SQLite database for cross-session cost analytics"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
DB_PATH = PROJECT_ROOT / "data" / "costs.db"
|
||||
REPORTS_DIR = PROJECT_ROOT / "data" / "cost_reports"
|
||||
|
||||
_conn: sqlite3.Connection | None = None
|
||||
|
||||
|
||||
def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
started_at TIMESTAMP,
|
||||
total_cost REAL DEFAULT 0,
|
||||
llm_cost REAL DEFAULT 0,
|
||||
tts_cost REAL DEFAULT 0,
|
||||
total_llm_calls INTEGER DEFAULT 0,
|
||||
total_tts_calls INTEGER DEFAULT 0,
|
||||
total_tokens INTEGER DEFAULT 0,
|
||||
prompt_tokens INTEGER DEFAULT 0,
|
||||
completion_tokens INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||
timestamp TIMESTAMP,
|
||||
category TEXT,
|
||||
model TEXT,
|
||||
prompt_tokens INTEGER DEFAULT 0,
|
||||
completion_tokens INTEGER DEFAULT 0,
|
||||
cost REAL DEFAULT 0,
|
||||
caller_name TEXT,
|
||||
latency_ms REAL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tts_calls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||
timestamp TIMESTAMP,
|
||||
provider TEXT,
|
||||
voice TEXT,
|
||||
char_count INTEGER DEFAULT 0,
|
||||
cost REAL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_session_ts_cat_model
|
||||
ON llm_calls(session_id, timestamp, category, model);
|
||||
CREATE INDEX IF NOT EXISTS idx_tts_session_ts
|
||||
ON tts_calls(session_id, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_timestamp
|
||||
ON llm_calls(timestamp);
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
global _conn
|
||||
if _conn is None:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
_conn = init_db(DB_PATH)
|
||||
import_json_reports(_conn)
|
||||
return _conn
|
||||
|
||||
|
||||
def import_json_reports(conn: sqlite3.Connection | None = None):
|
||||
if conn is None:
|
||||
conn = get_db()
|
||||
if not REPORTS_DIR.exists():
|
||||
return
|
||||
|
||||
existing = {
|
||||
row[0]
|
||||
for row in conn.execute("SELECT id FROM sessions").fetchall()
|
||||
}
|
||||
|
||||
for fp in sorted(REPORTS_DIR.glob("session-*.json")):
|
||||
try:
|
||||
data = json.loads(fp.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
|
||||
session_id = data.get("session_id", fp.stem)
|
||||
if session_id in existing:
|
||||
continue
|
||||
|
||||
saved_at = data.get("saved_at", 0)
|
||||
started_at = datetime.fromtimestamp(saved_at, tz=timezone.utc).isoformat() if saved_at else None
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, started_at, total_cost, llm_cost, tts_cost, "
|
||||
"total_llm_calls, total_tts_calls, total_tokens, prompt_tokens, completion_tokens) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
session_id,
|
||||
started_at,
|
||||
data.get("total_cost_usd", 0),
|
||||
data.get("llm_cost_usd", 0),
|
||||
data.get("tts_cost_usd", 0),
|
||||
data.get("total_llm_calls", 0),
|
||||
len(data.get("raw_tts_records", [])),
|
||||
data.get("total_tokens", 0),
|
||||
data.get("prompt_tokens", 0),
|
||||
data.get("completion_tokens", 0),
|
||||
),
|
||||
)
|
||||
|
||||
for r in data.get("raw_llm_records", []):
|
||||
try:
|
||||
ts = datetime.fromtimestamp(r.get("timestamp", 0), tz=timezone.utc).isoformat()
|
||||
conn.execute(
|
||||
"INSERT INTO llm_calls (session_id, timestamp, category, model, "
|
||||
"prompt_tokens, completion_tokens, cost, caller_name, latency_ms) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
session_id, ts, r.get("category"), r.get("model"),
|
||||
r.get("prompt_tokens", 0), r.get("completion_tokens", 0),
|
||||
r.get("cost_usd", 0), r.get("caller_name", ""),
|
||||
r.get("latency_ms", 0),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for r in data.get("raw_tts_records", []):
|
||||
try:
|
||||
ts = datetime.fromtimestamp(r.get("timestamp", 0), tz=timezone.utc).isoformat()
|
||||
conn.execute(
|
||||
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, "
|
||||
"char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
session_id, ts, r.get("provider"), r.get("voice"),
|
||||
r.get("char_count", 0), r.get("cost_usd", 0),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
existing.add(session_id)
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def record_llm_call(session_id, timestamp, category, model, prompt_tokens,
|
||||
completion_tokens, cost, caller_name="", latency_ms=0.0):
|
||||
conn = get_db()
|
||||
ensure_session(session_id)
|
||||
ts = datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat() if isinstance(timestamp, (int, float)) else timestamp
|
||||
conn.execute(
|
||||
"INSERT INTO llm_calls (session_id, timestamp, category, model, "
|
||||
"prompt_tokens, completion_tokens, cost, caller_name, latency_ms) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(session_id, ts, category, model, prompt_tokens, completion_tokens,
|
||||
cost, caller_name, latency_ms),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def record_tts_call(session_id, timestamp, provider, voice, char_count, cost):
|
||||
conn = get_db()
|
||||
ensure_session(session_id)
|
||||
ts = datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat() if isinstance(timestamp, (int, float)) else timestamp
|
||||
conn.execute(
|
||||
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, "
|
||||
"char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(session_id, ts, provider, voice, char_count, cost),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def ensure_session(session_id, started_at=None):
|
||||
conn = get_db()
|
||||
existing = conn.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||
if existing:
|
||||
return
|
||||
ts = started_at
|
||||
if isinstance(started_at, (int, float)):
|
||||
ts = datetime.fromtimestamp(started_at, tz=timezone.utc).isoformat()
|
||||
elif started_at is None:
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
conn.execute("INSERT INTO sessions (id, started_at) VALUES (?, ?)", (session_id, ts))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def update_session_totals(session_id):
|
||||
conn = get_db()
|
||||
llm = conn.execute(
|
||||
"SELECT COUNT(*) as cnt, COALESCE(SUM(cost),0) as cost, "
|
||||
"COALESCE(SUM(prompt_tokens),0) as pt, COALESCE(SUM(completion_tokens),0) as ct "
|
||||
"FROM llm_calls WHERE session_id = ?", (session_id,)
|
||||
).fetchone()
|
||||
tts = conn.execute(
|
||||
"SELECT COUNT(*) as cnt, COALESCE(SUM(cost),0) as cost "
|
||||
"FROM tts_calls WHERE session_id = ?", (session_id,)
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
"UPDATE sessions SET total_cost=?, llm_cost=?, tts_cost=?, "
|
||||
"total_llm_calls=?, total_tts_calls=?, total_tokens=?, "
|
||||
"prompt_tokens=?, completion_tokens=? WHERE id=?",
|
||||
(
|
||||
llm["cost"] + tts["cost"], llm["cost"], tts["cost"],
|
||||
llm["cnt"], tts["cnt"],
|
||||
llm["pt"] + llm["ct"], llm["pt"], llm["ct"],
|
||||
session_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _period_filter(period: str) -> str | None:
|
||||
now = datetime.now(timezone.utc)
|
||||
if period == "today":
|
||||
return now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||
elif period == "week":
|
||||
return (now - timedelta(days=7)).isoformat()
|
||||
elif period == "month":
|
||||
return (now - timedelta(days=30)).isoformat()
|
||||
elif period == "all":
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _get_previous_period_start(period: str) -> str | None:
|
||||
now = datetime.now(timezone.utc)
|
||||
if period == "today":
|
||||
yesterday = now - timedelta(days=1)
|
||||
return yesterday.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||
elif period == "week":
|
||||
return (now - timedelta(days=14)).isoformat()
|
||||
elif period == "month":
|
||||
return (now - timedelta(days=60)).isoformat()
|
||||
elif period == "all":
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _where_clause(period: str, ts_col: str = "started_at") -> tuple[str, list]:
|
||||
start = _period_filter(period)
|
||||
if start is None:
|
||||
return "", []
|
||||
return f"WHERE {ts_col} >= ?", [start]
|
||||
|
||||
|
||||
def _pct_change(current: float, previous: float) -> float | None:
|
||||
if previous == 0:
|
||||
return None
|
||||
return round((current - previous) / previous * 100, 1)
|
||||
|
||||
|
||||
def get_summary(period: str = "all") -> dict:
|
||||
conn = get_db()
|
||||
where, params = _where_clause(period)
|
||||
row = conn.execute(
|
||||
f"SELECT COUNT(*) as sessions, COALESCE(SUM(total_cost),0) as total_cost, "
|
||||
f"COALESCE(SUM(llm_cost),0) as llm_cost, COALESCE(SUM(tts_cost),0) as tts_cost, "
|
||||
f"COALESCE(SUM(total_llm_calls),0) as llm_calls, "
|
||||
f"COALESCE(SUM(total_tts_calls),0) as tts_calls, "
|
||||
f"COALESCE(SUM(total_tokens),0) as tokens "
|
||||
f"FROM sessions {where}", params
|
||||
).fetchone()
|
||||
|
||||
result = {
|
||||
"sessions": row["sessions"],
|
||||
"total_cost": round(row["total_cost"], 4),
|
||||
"llm_cost": round(row["llm_cost"], 4),
|
||||
"tts_cost": round(row["tts_cost"], 4),
|
||||
"llm_calls": row["llm_calls"],
|
||||
"tts_calls": row["tts_calls"],
|
||||
"tokens": row["tokens"],
|
||||
"avg_cost_per_session": round(row["total_cost"] / max(row["sessions"], 1), 4),
|
||||
}
|
||||
|
||||
# % change vs previous period
|
||||
prev_start = _get_previous_period_start(period)
|
||||
current_start = _period_filter(period)
|
||||
if prev_start and current_start:
|
||||
prev_row = conn.execute(
|
||||
"SELECT COALESCE(SUM(total_cost),0) as total_cost, "
|
||||
"COALESCE(SUM(total_llm_calls),0) as llm_calls "
|
||||
"FROM sessions WHERE started_at >= ? AND started_at < ?",
|
||||
[prev_start, current_start]
|
||||
).fetchone()
|
||||
result["cost_change_pct"] = _pct_change(row["total_cost"], prev_row["total_cost"])
|
||||
result["calls_change_pct"] = _pct_change(row["llm_calls"], prev_row["llm_calls"])
|
||||
else:
|
||||
result["cost_change_pct"] = None
|
||||
result["calls_change_pct"] = None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_timeline(period: str = "all", group_by: str = "session") -> list[dict]:
|
||||
conn = get_db()
|
||||
where, params = _where_clause(period)
|
||||
|
||||
if group_by == "day":
|
||||
rows = conn.execute(
|
||||
f"SELECT DATE(started_at) as date, COUNT(*) as sessions, "
|
||||
f"SUM(total_cost) as total_cost, SUM(llm_cost) as llm_cost, "
|
||||
f"SUM(tts_cost) as tts_cost, SUM(total_llm_calls) as llm_calls, "
|
||||
f"SUM(total_tokens) as tokens "
|
||||
f"FROM sessions {where} GROUP BY DATE(started_at) ORDER BY date", params
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"date": r["date"],
|
||||
"sessions": r["sessions"],
|
||||
"total_cost": round(r["total_cost"], 4),
|
||||
"llm_cost": round(r["llm_cost"], 4),
|
||||
"tts_cost": round(r["tts_cost"], 4),
|
||||
"llm_calls": r["llm_calls"],
|
||||
"tokens": r["tokens"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
else:
|
||||
rows = conn.execute(
|
||||
f"SELECT id, started_at, total_cost, llm_cost, tts_cost, "
|
||||
f"total_llm_calls as llm_calls, total_tokens as tokens "
|
||||
f"FROM sessions {where} ORDER BY started_at", params
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"session_id": r["id"],
|
||||
"started_at": r["started_at"],
|
||||
"total_cost": round(r["total_cost"], 4),
|
||||
"llm_cost": round(r["llm_cost"], 4),
|
||||
"tts_cost": round(r["tts_cost"], 4),
|
||||
"llm_calls": r["llm_calls"],
|
||||
"tokens": r["tokens"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_models(period: str = "all") -> list[dict]:
|
||||
conn = get_db()
|
||||
start = _period_filter(period)
|
||||
if start:
|
||||
rows = conn.execute(
|
||||
"SELECT l.model, COUNT(*) as calls, SUM(l.cost) as cost, "
|
||||
"SUM(l.prompt_tokens) as prompt_tokens, SUM(l.completion_tokens) as completion_tokens "
|
||||
"FROM llm_calls l JOIN sessions s ON l.session_id = s.id "
|
||||
"WHERE s.started_at >= ? GROUP BY l.model ORDER BY cost DESC", [start]
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT model, COUNT(*) as calls, SUM(cost) as cost, "
|
||||
"SUM(prompt_tokens) as prompt_tokens, SUM(completion_tokens) as completion_tokens "
|
||||
"FROM llm_calls GROUP BY model ORDER BY cost DESC"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"model": r["model"],
|
||||
"calls": r["calls"],
|
||||
"cost": round(r["cost"], 4),
|
||||
"prompt_tokens": r["prompt_tokens"],
|
||||
"completion_tokens": r["completion_tokens"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_categories(period: str = "all") -> list[dict]:
|
||||
conn = get_db()
|
||||
start = _period_filter(period)
|
||||
if start:
|
||||
rows = conn.execute(
|
||||
"SELECT l.category, COUNT(*) as calls, SUM(l.cost) as cost, "
|
||||
"SUM(l.prompt_tokens + l.completion_tokens) as tokens "
|
||||
"FROM llm_calls l JOIN sessions s ON l.session_id = s.id "
|
||||
"WHERE s.started_at >= ? GROUP BY l.category ORDER BY cost DESC", [start]
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT category, COUNT(*) as calls, SUM(cost) as cost, "
|
||||
"SUM(prompt_tokens + completion_tokens) as tokens "
|
||||
"FROM llm_calls GROUP BY category ORDER BY cost DESC"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"category": r["category"],
|
||||
"calls": r["calls"],
|
||||
"cost": round(r["cost"], 4),
|
||||
"tokens": r["tokens"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_sessions_list(period: str = "all") -> list[dict]:
|
||||
conn = get_db()
|
||||
where, params = _where_clause(period)
|
||||
rows = conn.execute(
|
||||
f"SELECT id, started_at, total_cost, llm_cost, tts_cost, "
|
||||
f"total_llm_calls, total_tts_calls, total_tokens "
|
||||
f"FROM sessions {where} ORDER BY started_at DESC", params
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"session_id": r["id"],
|
||||
"started_at": r["started_at"],
|
||||
"total_cost": round(r["total_cost"], 4),
|
||||
"llm_cost": round(r["llm_cost"], 4),
|
||||
"tts_cost": round(r["tts_cost"], 4),
|
||||
"total_llm_calls": r["total_llm_calls"],
|
||||
"total_tts_calls": r["total_tts_calls"],
|
||||
"total_tokens": r["total_tokens"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_session_detail(session_id: str) -> dict | None:
|
||||
conn = get_db()
|
||||
session = conn.execute(
|
||||
"SELECT * FROM sessions WHERE id = ?", (session_id,)
|
||||
).fetchone()
|
||||
if not session:
|
||||
return None
|
||||
|
||||
by_caller = conn.execute(
|
||||
"SELECT caller_name, COUNT(*) as calls, SUM(cost) as cost, "
|
||||
"SUM(prompt_tokens + completion_tokens) as tokens "
|
||||
"FROM llm_calls WHERE session_id = ? AND caller_name != '' "
|
||||
"GROUP BY caller_name ORDER BY cost DESC", (session_id,)
|
||||
).fetchall()
|
||||
|
||||
by_model = conn.execute(
|
||||
"SELECT model, COUNT(*) as calls, SUM(cost) as cost, "
|
||||
"SUM(prompt_tokens) as prompt_tokens, SUM(completion_tokens) as completion_tokens "
|
||||
"FROM llm_calls WHERE session_id = ? GROUP BY model ORDER BY cost DESC", (session_id,)
|
||||
).fetchall()
|
||||
|
||||
by_category = conn.execute(
|
||||
"SELECT category, COUNT(*) as calls, SUM(cost) as cost, "
|
||||
"SUM(prompt_tokens + completion_tokens) as tokens "
|
||||
"FROM llm_calls WHERE session_id = ? GROUP BY category ORDER BY cost DESC", (session_id,)
|
||||
).fetchall()
|
||||
|
||||
expensive_calls = conn.execute(
|
||||
"SELECT timestamp, category, model, caller_name, cost, "
|
||||
"prompt_tokens, completion_tokens, latency_ms "
|
||||
"FROM llm_calls WHERE session_id = ? ORDER BY cost DESC LIMIT 10", (session_id,)
|
||||
).fetchall()
|
||||
|
||||
tts_by_provider = conn.execute(
|
||||
"SELECT provider, COUNT(*) as calls, SUM(cost) as cost, SUM(char_count) as chars "
|
||||
"FROM tts_calls WHERE session_id = ? GROUP BY provider ORDER BY cost DESC", (session_id,)
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"session_id": session["id"],
|
||||
"started_at": session["started_at"],
|
||||
"total_cost": round(session["total_cost"], 4),
|
||||
"llm_cost": round(session["llm_cost"], 4),
|
||||
"tts_cost": round(session["tts_cost"], 4),
|
||||
"total_llm_calls": session["total_llm_calls"],
|
||||
"total_tts_calls": session["total_tts_calls"],
|
||||
"total_tokens": session["total_tokens"],
|
||||
"by_caller": [
|
||||
{"caller_name": r["caller_name"], "calls": r["calls"],
|
||||
"cost": round(r["cost"], 4), "tokens": r["tokens"]}
|
||||
for r in by_caller
|
||||
],
|
||||
"by_model": [
|
||||
{"model": r["model"], "calls": r["calls"], "cost": round(r["cost"], 4),
|
||||
"prompt_tokens": r["prompt_tokens"], "completion_tokens": r["completion_tokens"]}
|
||||
for r in by_model
|
||||
],
|
||||
"by_category": [
|
||||
{"category": r["category"], "calls": r["calls"],
|
||||
"cost": round(r["cost"], 4), "tokens": r["tokens"]}
|
||||
for r in by_category
|
||||
],
|
||||
"expensive_calls": [
|
||||
{"timestamp": r["timestamp"], "category": r["category"], "model": r["model"],
|
||||
"caller_name": r["caller_name"], "cost": round(r["cost"], 6),
|
||||
"prompt_tokens": r["prompt_tokens"], "completion_tokens": r["completion_tokens"],
|
||||
"latency_ms": round(r["latency_ms"], 1)}
|
||||
for r in expensive_calls
|
||||
],
|
||||
"tts_by_provider": [
|
||||
{"provider": r["provider"], "calls": r["calls"],
|
||||
"cost": round(r["cost"], 4), "chars": r["chars"]}
|
||||
for r in tts_by_provider
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_tts_providers(period: str = "all") -> list[dict]:
|
||||
conn = get_db()
|
||||
start = _period_filter(period)
|
||||
if start:
|
||||
rows = conn.execute(
|
||||
"SELECT t.provider, COUNT(*) as calls, COALESCE(SUM(t.cost), 0) as cost, "
|
||||
"COALESCE(SUM(t.char_count), 0) as chars "
|
||||
"FROM tts_calls t JOIN sessions s ON t.session_id = s.id "
|
||||
"WHERE s.started_at >= ? GROUP BY t.provider ORDER BY cost DESC",
|
||||
[start]
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT provider, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, "
|
||||
"COALESCE(SUM(char_count), 0) as chars "
|
||||
"FROM tts_calls GROUP BY provider ORDER BY cost DESC"
|
||||
).fetchall()
|
||||
return [{"provider": r["provider"], "calls": r["calls"],
|
||||
"cost": round(r["cost"], 4), "chars": r["chars"]} for r in rows]
|
||||
|
||||
|
||||
def get_expensive_calls(period: str = "all", limit: int = 20) -> list[dict]:
|
||||
conn = get_db()
|
||||
start = _period_filter(period)
|
||||
if start:
|
||||
rows = conn.execute(
|
||||
"SELECT l.session_id, l.timestamp, l.category, l.model, l.caller_name, "
|
||||
"l.cost, l.prompt_tokens, l.completion_tokens, l.latency_ms "
|
||||
"FROM llm_calls l JOIN sessions s ON l.session_id = s.id "
|
||||
"WHERE s.started_at >= ? ORDER BY l.cost DESC LIMIT ?",
|
||||
[start, limit]
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT l.session_id, l.timestamp, l.category, l.model, l.caller_name, "
|
||||
"l.cost, l.prompt_tokens, l.completion_tokens, l.latency_ms "
|
||||
"FROM llm_calls l ORDER BY l.cost DESC LIMIT ?",
|
||||
[limit]
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"session_id": r["session_id"],
|
||||
"timestamp": r["timestamp"],
|
||||
"category": r["category"],
|
||||
"model": r["model"],
|
||||
"caller_name": r["caller_name"],
|
||||
"cost": round(r["cost"], 6),
|
||||
"prompt_tokens": r["prompt_tokens"],
|
||||
"completion_tokens": r["completion_tokens"],
|
||||
"latency_ms": round(r["latency_ms"], 1),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -3,9 +3,12 @@
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from backend.services import cost_db
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMCallRecord:
|
||||
@@ -95,6 +98,7 @@ def _calc_tts_cost(provider: str, char_count: int) -> float:
|
||||
|
||||
class CostTracker:
|
||||
def __init__(self):
|
||||
self._session_id = f"session-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}"
|
||||
self.llm_records: list[LLMCallRecord] = []
|
||||
self.tts_records: list[TTSCallRecord] = []
|
||||
# Running totals for fast get_live_summary()
|
||||
@@ -137,6 +141,16 @@ class CostTracker:
|
||||
)
|
||||
self.llm_records.append(record)
|
||||
|
||||
try:
|
||||
cost_db.ensure_session(self._session_id)
|
||||
cost_db.record_llm_call(
|
||||
self._session_id, record.timestamp, record.category, record.model,
|
||||
record.prompt_tokens, record.completion_tokens, record.cost_usd,
|
||||
record.caller_name, record.latency_ms,
|
||||
)
|
||||
except Exception:
|
||||
pass # don't break show over analytics
|
||||
|
||||
# Update running totals
|
||||
self._llm_cost += cost
|
||||
self._llm_calls += 1
|
||||
@@ -165,6 +179,15 @@ class CostTracker:
|
||||
cost_usd=cost,
|
||||
)
|
||||
self.tts_records.append(record)
|
||||
|
||||
try:
|
||||
cost_db.record_tts_call(
|
||||
self._session_id, record.timestamp, record.provider, record.voice,
|
||||
record.char_count, record.cost_usd,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._tts_cost += cost
|
||||
|
||||
def get_live_summary(self) -> dict:
|
||||
@@ -369,6 +392,11 @@ class CostTracker:
|
||||
json.dump(report, f, indent=2)
|
||||
print(f"[Costs] Report saved to {filepath}")
|
||||
|
||||
try:
|
||||
cost_db.update_session_totals(self._session_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def reset(self):
|
||||
self.llm_records.clear()
|
||||
self.tts_records.clear()
|
||||
|
||||
+40
-15
@@ -391,9 +391,14 @@ class InternService:
|
||||
"content": "A caller is on the line right now. Focus on delivering useful facts, context, and information. Skip personal stories and anecdotes — save those for when it's just you and Luke talking between calls."
|
||||
})
|
||||
|
||||
# Include Devon's own recent conversation history
|
||||
# Include Devon's own recent conversation history (current show only)
|
||||
if self._devon_history:
|
||||
messages.extend(self._devon_history[-10:])
|
||||
last_marker = -1
|
||||
for i, msg in enumerate(self._devon_history):
|
||||
if msg.get("role") == "system" and "NEW SHOW" in msg.get("content", ""):
|
||||
last_marker = i
|
||||
relevant = self._devon_history[last_marker + 1:] if last_marker >= 0 else self._devon_history
|
||||
messages.extend(relevant[-10:])
|
||||
|
||||
messages.append({"role": "user", "content": question})
|
||||
|
||||
@@ -437,6 +442,11 @@ class InternService:
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
|
||||
def _track_suggestion(self, text: str):
|
||||
"""Record a suggestion in Devon's history so he won't repeat it."""
|
||||
self._devon_history.append({"role": "assistant", "content": text})
|
||||
self._save()
|
||||
|
||||
async def interject(self, conversation: list[dict], caller_active: bool = False) -> dict | None:
|
||||
"""Intern looks at conversation and decides if there's something worth adding.
|
||||
Returns {text, sources, tool_calls} or None if nothing to add."""
|
||||
@@ -549,6 +559,7 @@ class InternService:
|
||||
if result:
|
||||
self.pending_interjection = result["text"]
|
||||
self.pending_sources = result.get("tool_calls", [])
|
||||
self._track_suggestion(result["text"])
|
||||
await on_suggestion(result["text"], result["sources"])
|
||||
print(f"[Intern] Buffered suggestion: {result['text'][:60]}...")
|
||||
except Exception as e:
|
||||
@@ -563,6 +574,24 @@ class InternService:
|
||||
)
|
||||
print("[Intern] Monitoring started")
|
||||
|
||||
def new_show(self):
|
||||
"""Mark the start of a new show in Devon's memory so he knows the context has changed."""
|
||||
if self._devon_history and self._devon_history[-1].get("role") == "system" and "NEW SHOW" in self._devon_history[-1].get("content", ""):
|
||||
print("[Intern] Skipping duplicate new_show marker")
|
||||
return
|
||||
self._devon_history.append({
|
||||
"role": "system",
|
||||
"content": "--- NEW SHOW STARTING --- The previous show is over. A brand new episode is about to begin. Everything before this point was a previous show. Don't reference the previous show's callers or topics unless Luke brings them up. Fresh energy, clean slate."
|
||||
})
|
||||
self.research_cache.clear()
|
||||
self.pending_interjection = None
|
||||
self.pending_sources = []
|
||||
# Trim older history but keep some for long-term memory
|
||||
if len(self._devon_history) > 30:
|
||||
self._devon_history = self._devon_history[-30:]
|
||||
self._save()
|
||||
print("[Intern] New show marker added to conversation history")
|
||||
|
||||
def stop_monitoring(self):
|
||||
self.monitoring = False
|
||||
if self._monitor_task and not self._monitor_task.done():
|
||||
@@ -588,29 +617,25 @@ class InternService:
|
||||
def _clean_for_tts(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
# Strip stage directions BEFORE markdown processing
|
||||
# Parenthetical: (laughs), (sighs nervously), (clears throat), etc.
|
||||
text = re.sub(r'\s*\([^)]{1,40}\)\s*', ' ', text)
|
||||
# Multi-word asterisk stage directions: *sighs deeply*, *nervous laughter*
|
||||
text = re.sub(r'\s*\*\w+\s[^*]{1,30}\*\s*', ' ', text)
|
||||
# Single-word asterisk stage directions (known action words only)
|
||||
_actions = r'(?:laughs?|sighs?|pauses?|smiles?|chuckles?|grins?|nods?|shrugs?|frowns?|coughs?|gasps?|whispers?|mumbles?|gulps?|blinks?|winces?|crying|sobbing)'
|
||||
text = re.sub(r'\s*\*' + _actions + r'\*\s*', ' ', text, flags=re.IGNORECASE)
|
||||
# Remove markdown formatting (after stage directions are stripped)
|
||||
# Devon-specific: strip markdown and tool artifacts first
|
||||
# Remove markdown formatting
|
||||
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
|
||||
text = re.sub(r'\*(.+?)\*', r'\1', text)
|
||||
text = re.sub(r'`(.+?)`', r'\1', text)
|
||||
text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text)
|
||||
# Remove bullet points / list markers
|
||||
text = re.sub(r'^\s*[-*•]\s+', '', text, flags=re.MULTILINE)
|
||||
# Collapse whitespace
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
# Remove quotes that TTS reads awkwardly
|
||||
text = text.replace('"', '').replace('"', '').replace('"', '')
|
||||
# Remove smart quotes that TTS reads awkwardly
|
||||
text = text.replace('\u201c', '').replace('\u201d', '').replace('\u2018', '').replace('\u2019', "'")
|
||||
# Strip tool error artifacts that shouldn't be spoken on air
|
||||
text = re.sub(r'(?:Error|ERROR|error):?\s*\S.*?(?:\.|$)', '', text)
|
||||
text = re.sub(r'Tool unavailable[^.]*\.?', '', text)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
|
||||
# Run shared TTS preprocessing (stage directions, numbers, abbreviations,
|
||||
# symbols, pronunciation fixes, breathing pauses)
|
||||
from backend.main import clean_for_tts
|
||||
text = clean_for_tts(text, formal=True)
|
||||
return text
|
||||
|
||||
|
||||
|
||||
+27
-6
@@ -58,7 +58,7 @@ class LLMService:
|
||||
@property
|
||||
def client(self) -> httpx.AsyncClient:
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(timeout=10.0)
|
||||
self._client = httpx.AsyncClient(timeout=30.0)
|
||||
return self._client
|
||||
|
||||
def update_settings(
|
||||
@@ -317,7 +317,7 @@ class LLMService:
|
||||
if model == self.openrouter_model:
|
||||
continue # Already tried
|
||||
print(f"[LLM] Falling back to {model}...")
|
||||
result = await self._call_openrouter_once(messages, model, timeout=8.0, max_tokens=max_tokens, category=category, caller_name=caller_name)
|
||||
result = await self._call_openrouter_once(messages, model, timeout=20.0, max_tokens=max_tokens, category=category, caller_name=caller_name)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
@@ -325,18 +325,39 @@ class LLMService:
|
||||
print("[LLM] All models failed, using canned response")
|
||||
return "Sorry, I totally blanked out for a second. What were you saying?"
|
||||
|
||||
async def _call_openrouter_once(self, messages: list[dict], model: str, timeout: float = 10.0, max_tokens: Optional[int] = None, response_format: Optional[dict] = None, category: str = "unknown", caller_name: str = "") -> str | None:
|
||||
# Per-model parameter overrides for caller_dialog category.
|
||||
# Different models need different tuning for natural conversation:
|
||||
# - Qwen: high freq penalty to fight phrase-level repetition loops
|
||||
# - Llama: high temp + low freq penalty to reduce terseness
|
||||
# - Grok/Mistral/DeepSeek/Kimi: slightly warmer than Sonnet defaults
|
||||
_CALLER_DIALOG_MODEL_PARAMS = {
|
||||
"anthropic/claude-sonnet-4.6": {"temperature": 0.65, "frequency_penalty": 0.3, "presence_penalty": 0.15},
|
||||
"x-ai/grok-4.1-fast": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
|
||||
"x-ai/grok-4": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
|
||||
"qwen/qwen3-235b-a22b": {"temperature": 0.6, "frequency_penalty": 0.5, "presence_penalty": 0.2},
|
||||
"mistralai/mistral-large-2512": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
|
||||
"deepseek/deepseek-chat-v3-0324": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
|
||||
"moonshotai/kimi-k2": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
|
||||
"meta-llama/llama-3.3-70b-instruct": {"temperature": 0.8, "frequency_penalty": 0.1, "presence_penalty": 0.1},
|
||||
}
|
||||
|
||||
async def _call_openrouter_once(self, messages: list[dict], model: str, timeout: float = 20.0, max_tokens: Optional[int] = None, response_format: Optional[dict] = None, category: str = "unknown", caller_name: str = "") -> str | None:
|
||||
"""Single attempt to call OpenRouter. Returns None on failure (not a fallback string)."""
|
||||
start_time = time.time()
|
||||
try:
|
||||
# Use per-model params for caller dialog, defaults for everything else
|
||||
if category == "caller_dialog" and model in self._CALLER_DIALOG_MODEL_PARAMS:
|
||||
params = self._CALLER_DIALOG_MODEL_PARAMS[model]
|
||||
else:
|
||||
params = {"temperature": 0.65, "frequency_penalty": 0.3, "presence_penalty": 0.15}
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens or 500,
|
||||
"temperature": 0.65,
|
||||
"temperature": params["temperature"],
|
||||
"top_p": 0.9,
|
||||
"frequency_penalty": 0.3,
|
||||
"presence_penalty": 0.15,
|
||||
"frequency_penalty": params["frequency_penalty"],
|
||||
"presence_penalty": params["presence_penalty"],
|
||||
}
|
||||
if response_format:
|
||||
payload["response_format"] = response_format
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import settings
|
||||
from .cost_tracker import cost_tracker
|
||||
|
||||
HOME = Path.home()
|
||||
VAULT = HOME / "code" / "dotfiles"
|
||||
SILAS_DIR = VAULT / "silas"
|
||||
REGULARS_DIR = VAULT / "regulars"
|
||||
ARCHIVED_DIR = REGULARS_DIR / "archived"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Regular:
|
||||
name: str
|
||||
voice: str
|
||||
age: int
|
||||
arc_state: str
|
||||
lore_body: str
|
||||
file_path: Path
|
||||
|
||||
|
||||
def load_regular(path: Path) -> Regular:
|
||||
text = path.read_text()
|
||||
m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.DOTALL)
|
||||
if not m:
|
||||
raise ValueError(f"No frontmatter in {path}")
|
||||
fm_raw, body = m.group(1), m.group(2).strip()
|
||||
fm = {}
|
||||
for line in fm_raw.splitlines():
|
||||
if ":" in line:
|
||||
k, v = line.split(":", 1)
|
||||
fm[k.strip()] = v.strip()
|
||||
return Regular(
|
||||
name=fm["name"],
|
||||
voice=fm["voice"],
|
||||
age=int(fm["age"]),
|
||||
arc_state=fm.get("arc_state", ""),
|
||||
lore_body=body,
|
||||
file_path=path,
|
||||
)
|
||||
|
||||
|
||||
def load_all_active_regulars() -> list[Regular]:
|
||||
out = []
|
||||
if SILAS_DIR.exists():
|
||||
for f in SILAS_DIR.glob("*.md"):
|
||||
out.append(load_regular(f))
|
||||
if REGULARS_DIR.exists():
|
||||
for f in REGULARS_DIR.glob("*.md"):
|
||||
out.append(load_regular(f))
|
||||
return out
|
||||
|
||||
|
||||
PROMOTION_MODEL = "anthropic/claude-sonnet-4.6"
|
||||
|
||||
PROMOTION_PROMPT = """You are evaluating whether a one-time caller should become a recurring character.
|
||||
|
||||
CALLER: {name}
|
||||
TRANSCRIPT:
|
||||
{transcript}
|
||||
|
||||
A recurring character must have a 3-5 episode arc with genuine progression — not just "calls weekly to complain about the same thing." The arc must have a possible resolution.
|
||||
|
||||
Output JSON:
|
||||
{{"promote": true|false, "arc_plan": "...", "reason": "..."}}
|
||||
|
||||
Bar is HIGH. Only promote if the character has real internal conflict, growth potential, and a believable resolution trajectory."""
|
||||
|
||||
|
||||
async def _call_sonnet(prompt: str) -> dict:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
|
||||
json={
|
||||
"model": PROMOTION_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"response_format": {"type": "json_object"},
|
||||
"max_tokens": 500,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
usage = data.get("usage", {})
|
||||
cost_tracker.record_llm_call(
|
||||
category="promotion_eval",
|
||||
model=PROMOTION_MODEL,
|
||||
usage_data=usage,
|
||||
)
|
||||
content = data["choices"][0]["message"]["content"].strip()
|
||||
if content.startswith("```") and content.endswith("```"):
|
||||
lines = content.splitlines()
|
||||
content = "\n".join(lines[1:-1])
|
||||
return json.loads(content)
|
||||
|
||||
|
||||
async def evaluate_promotion(caller_name: str, call_transcript: str) -> dict:
|
||||
prompt = PROMOTION_PROMPT.format(name=caller_name, transcript=call_transcript)
|
||||
return await _call_sonnet(prompt)
|
||||
|
||||
|
||||
def write_new_regular(name: str, voice: str, age: int, identity_paragraph: str,
|
||||
arc_plan: str, first_call_summary: str) -> Path:
|
||||
REGULARS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
slug = name.lower().replace(" ", "-")
|
||||
path = REGULARS_DIR / f"{slug}.md"
|
||||
today = date.today().isoformat()
|
||||
content = f"""---
|
||||
name: {name}
|
||||
voice: {voice}
|
||||
age: {age}
|
||||
arc_state: {arc_plan}
|
||||
promoted_on: {today}
|
||||
---
|
||||
|
||||
# {name}
|
||||
|
||||
{identity_paragraph}
|
||||
|
||||
## Arc Plan
|
||||
|
||||
{arc_plan}
|
||||
|
||||
## Arc Log
|
||||
|
||||
- {today}: {first_call_summary}
|
||||
"""
|
||||
path.write_text(content)
|
||||
return path
|
||||
+50
-14
@@ -128,6 +128,11 @@ INWORLD_SPEED_OVERRIDES = {
|
||||
"Theodore": 1.15,
|
||||
"Blake": 1.1,
|
||||
"Priya": 1.1,
|
||||
"Graham": 1.15,
|
||||
"Malcolm": 1.15,
|
||||
"Victoria": 1.1,
|
||||
"Loretta": 1.1,
|
||||
"Marlene": 1.1,
|
||||
}
|
||||
DEFAULT_INWORLD_SPEED = 1.1 # Slight bump for all voices
|
||||
|
||||
@@ -710,7 +715,33 @@ def _detect_speech_rate(text: str, base_speed: float) -> float:
|
||||
return base_speed
|
||||
|
||||
|
||||
async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, int]:
|
||||
def _emotional_register_to_params(emotional_register: str) -> tuple[float, float]:
|
||||
"""Map caller's emotional_register description to (temperature, speed_adjust).
|
||||
speed_adjust is added to the base speed BEFORE clamping to 0.5-1.5.
|
||||
Returns (0.9, 0.0) default for unknown/empty registers."""
|
||||
if not emotional_register:
|
||||
return (0.9, 0.0)
|
||||
er = emotional_register.lower()
|
||||
# Sadness/grief family
|
||||
if any(kw in er for kw in ["fractured", "grief", "hollow", "sad", "mournful", "somber", "quietly desperate"]):
|
||||
return (0.85, -0.1)
|
||||
# Anger/aggression family
|
||||
if any(kw in er for kw in ["angry", "combative", "furious", "aggressive", "sharp intelligence"]):
|
||||
return (1.0, 0.1)
|
||||
# Manic/excited family
|
||||
if any(kw in er for kw in ["caffeinated", "manic", "giddy", "vibrating", "adrenaline", "frantic", "hyper-articulate"]):
|
||||
return (1.0, 0.15)
|
||||
# Nervous/earnest family
|
||||
if any(kw in er for kw in ["earnest", "nervous", "unsettled", "anxious", "tentative", "quietly unsettled"]):
|
||||
return (0.9, 0.0)
|
||||
# Gruff/restrained family
|
||||
if any(kw in er for kw in ["gruff", "measured", "precise", "stoic", "restrained", "deadpan", "forthright"]):
|
||||
return (0.85, -0.05)
|
||||
# Default
|
||||
return (0.9, 0.0)
|
||||
|
||||
|
||||
async def generate_speech_inworld(text: str, voice_id: str, emotional_register: str = "") -> tuple[np.ndarray, int]:
|
||||
"""Generate speech using Inworld TTS API (high quality, natural voices)"""
|
||||
import httpx
|
||||
import base64
|
||||
@@ -727,9 +758,10 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
||||
if not api_key:
|
||||
raise RuntimeError("INWORLD_API_KEY not set in environment")
|
||||
|
||||
base_speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED)
|
||||
speed = _detect_speech_rate(text, base_speed)
|
||||
print(f"[Inworld TTS] Voice: {voice}, Speed: {speed:.2f} (base {base_speed}), Text: {text[:50]}...")
|
||||
temperature, speed_adjust = _emotional_register_to_params(emotional_register)
|
||||
base_speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED) + speed_adjust
|
||||
speed = max(0.5, min(1.5, _detect_speech_rate(text, base_speed)))
|
||||
print(f"[Inworld TTS] Voice: {voice}, Speed: {speed:.2f} (base {base_speed:.2f}), Temp: {temperature}, Text: {text[:50]}...")
|
||||
|
||||
url = "https://api.inworld.ai/tts/v1/voice"
|
||||
headers = {
|
||||
@@ -740,6 +772,8 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
||||
"text": text,
|
||||
"voiceId": voice,
|
||||
"modelId": "inworld-tts-1.5-max",
|
||||
"temperature": temperature,
|
||||
"applyTextNormalization": "ON",
|
||||
"audioConfig": {
|
||||
"audioEncoding": "LINEAR16",
|
||||
"sampleRateHertz": 48000,
|
||||
@@ -797,14 +831,14 @@ def pick_caller_tts_provider() -> str | None:
|
||||
|
||||
|
||||
_TTS_PROVIDERS = {
|
||||
"kokoro": lambda text, vid: generate_speech_kokoro(text, vid),
|
||||
"f5tts": lambda text, vid: generate_speech_f5tts(text, vid),
|
||||
"inworld": lambda text, vid: generate_speech_inworld(text, vid),
|
||||
"chattts": lambda text, vid: generate_speech_chattts(text, vid),
|
||||
"styletts2": lambda text, vid: generate_speech_styletts2(text, vid),
|
||||
"bark": lambda text, vid: generate_speech_bark(text, vid),
|
||||
"vits": lambda text, vid: generate_speech_vits(text, vid),
|
||||
"elevenlabs": lambda text, vid: generate_speech_elevenlabs(text, vid),
|
||||
"kokoro": lambda text, vid, er: generate_speech_kokoro(text, vid),
|
||||
"f5tts": lambda text, vid, er: generate_speech_f5tts(text, vid),
|
||||
"inworld": lambda text, vid, er: generate_speech_inworld(text, vid, emotional_register=er),
|
||||
"chattts": lambda text, vid, er: generate_speech_chattts(text, vid),
|
||||
"styletts2": lambda text, vid, er: generate_speech_styletts2(text, vid),
|
||||
"bark": lambda text, vid, er: generate_speech_bark(text, vid),
|
||||
"vits": lambda text, vid, er: generate_speech_vits(text, vid),
|
||||
"elevenlabs": lambda text, vid, er: generate_speech_elevenlabs(text, vid),
|
||||
}
|
||||
|
||||
TTS_MAX_RETRIES = 2
|
||||
@@ -816,7 +850,8 @@ async def generate_speech(
|
||||
voice_id: str,
|
||||
phone_quality: str = "normal",
|
||||
apply_filter: bool = True,
|
||||
provider_override: str = None
|
||||
provider_override: str = None,
|
||||
emotional_register: str = "",
|
||||
) -> bytes:
|
||||
"""
|
||||
Generate speech from text with automatic retry on failure.
|
||||
@@ -827,6 +862,7 @@ async def generate_speech(
|
||||
phone_quality: Quality of phone filter ("none" to disable)
|
||||
apply_filter: Whether to apply phone filter
|
||||
provider_override: Override the global TTS provider for this call
|
||||
emotional_register: Caller's emotional register (used by Inworld for temp/speed tuning)
|
||||
|
||||
Returns:
|
||||
Raw PCM audio bytes (16-bit signed int, 24kHz)
|
||||
@@ -845,7 +881,7 @@ async def generate_speech(
|
||||
async with asyncio.timeout(20):
|
||||
for attempt in range(TTS_MAX_RETRIES):
|
||||
try:
|
||||
audio, sample_rate = await gen_fn(text, voice_id)
|
||||
audio, sample_rate = await gen_fn(text, voice_id, emotional_register)
|
||||
cost_tracker.record_tts_call(provider, voice_id, len(text))
|
||||
if attempt > 0:
|
||||
print(f"[TTS] Succeeded on retry {attempt}")
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
{
|
||||
"regulars": [
|
||||
{
|
||||
"id": "0a0b3635",
|
||||
"name": "Charlene",
|
||||
"gender": "female",
|
||||
"age": 42,
|
||||
"job": "killing time during a three-hour mechanical delay while her crew naps",
|
||||
"location": "unknown",
|
||||
"personality_traits": [],
|
||||
"voice": "Amina",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping."
|
||||
},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Charlene bought a house eight months ago and keeps receiving mail for the previous resident, David Herrera, including a certified letter she accidentally signed for containing what appears to be a $12,000-13,000 check. Despite her attempts to resolve it with the post office and her inclination to track him down via Facebook, the host advises her to either hold it until David contacts her, return it to the post office, or discard it, warning that she can't be certain she'd find the correct person online.",
|
||||
"timestamp": 1772866520.023335
|
||||
},
|
||||
{
|
||||
"summary": "Charlene called to report that she's been closely tracking a mail carrier who regularly visits her neighbor's house for extended periods while the neighbor's husband is at work, implying she suspects an affair and is unsure whether she should tell anyone about what she's observed.",
|
||||
"timestamp": 1773219255.916183
|
||||
}
|
||||
],
|
||||
"last_call": 1773219255.9161851,
|
||||
"created_at": 1772866520.023336
|
||||
},
|
||||
{
|
||||
"id": "0bb02b2d",
|
||||
"name": "Chip",
|
||||
"gender": "male",
|
||||
"age": 23,
|
||||
"job": "watching his kid's soccer uniform tumble in the dryer while his girlfriend works the graveyard shift at the hospital, because three hours ago he got an email from a lawyer representing families",
|
||||
"location": "unknown",
|
||||
"personality_traits": [],
|
||||
"voice": "Sebastian",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation."
|
||||
},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Chip called from a laundromat at midnight after receiving an email from a Guatemalan lawyer claiming his adopted 8-year-old daughter may have been stolen from her birth mother, with a photo showing a woman with his daughter's exact crooked smile. The host advised him not to panic, treat the information as suspect until verified by a lawyer, wait to tell both his girlfriend and daughter until he knows more facts, and reminded him that fake photos are easy to create and this could be a scam.",
|
||||
"timestamp": 1772786610.885828
|
||||
},
|
||||
{
|
||||
"summary": "Chip called about discovering his adopted daughter may have been stolen from her birth mother in Guatemala, and he's paralyzed about telling his girlfriend Teresa, fearing it will end their already rocky relationship. He's anxious about the timing and the birth mother's request to meet their daughter, but the host advised him to take his time, have the conversation with Teresa, and make decisions together as parents.",
|
||||
"timestamp": 1772962156.544322
|
||||
},
|
||||
{
|
||||
"summary": "The caller, **Chip**, shared his emotional turmoil over discovering that his **adopted daughter\u2019s birth mother** may have resurfaced after receiving an unverified email with a photo that eerily matched his daughter\u2019s features. His girlfriend, **Teresa**, had known about the email for **three weeks** but kept it from him, leaving him feeling betrayed and overwhelmed. While Chip wants to **verify the claim legally before acting**, Teresa insists on **immediately flying to Guatemala with their daughter** to meet the woman, dismissing his fears as avoidance. The conversation escalated into a heated debate about **trust, safety, and extreme measures**\u2014with the host, Luke, urging Chip to **file a restraining order** if Teresa refuses to back down, warning of potential dangers in Guatemala. Chip, torn between **protecting his family and avoiding a nuclear confrontation**, vowed to try reasoning with Teresa one last time before she leaves for work. The call was charged with **fear, frustration, and the weight of a decision that could reshape their family forever**.",
|
||||
"timestamp": 1773226361.4859362
|
||||
},
|
||||
{
|
||||
"summary": "Chip called to update Luke about his adoption situation: his lawyer verified that his daughter's Guatemalan adoption may have been part of a fraud scandal, and now his girlfriend Teresa has secretly bought plane tickets to take their daughter to Guatemala this Friday to meet the birth mother. Despite Luke's strong advice to get an emergency court order to prevent the trip for safety reasons, Chip is torn because Teresa threatened to leave him if he stops her, though he ultimately acknowledges the relationship is likely over either way.",
|
||||
"timestamp": 1773648139.8094652,
|
||||
"arc_status": "ongoing"
|
||||
}
|
||||
],
|
||||
"last_call": 1773648139.8094661,
|
||||
"created_at": 1772786610.8858292
|
||||
},
|
||||
{
|
||||
"id": "3721ebf2",
|
||||
"name": "Maxine",
|
||||
"gender": "female",
|
||||
"age": 26,
|
||||
"job": "and the math doesn't add up\u2014there's a six-foot gap between her bedroom and the bathroom that shouldn't exist, and when she finally pried open the door she thought led to a closet, it was just drywall, fresh enough that she could smell the joint compound",
|
||||
"location": "in unknown",
|
||||
"personality_traits": [],
|
||||
"voice": "Kelsey",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Quiet, a little nervous. Short sentences, lots of pauses. Doesn't volunteer information \u2014 you have to pull it out of them. When they do open up it comes out in a rush. Gets flustered by direct questions. Tends to backtrack and qualify everything they say. Energy level: low. When pushed back on, they fold quickly and agree even if they don't mean it. Conversational tendency: understatement."
|
||||
},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Maxine called after discovering a hidden 6-foot space behind a sealed door in her house, and when she cut through the drywall, she found multiple boxes filled with banded stacks of $20 bills from the 1990s\u2014potentially tens or hundreds of thousands of dollars left by the previous owner who died there. She struggled with whether to keep the money or contact the deceased owner's family, with the host arguing it was legally hers since she bought the house \"as-is,\" though Maxine remained conflicted about what felt morally right.",
|
||||
"timestamp": 1773395481.8522182
|
||||
}
|
||||
],
|
||||
"last_call": 1773395481.8522189,
|
||||
"created_at": 1773395481.8522189
|
||||
},
|
||||
{
|
||||
"id": "7ed14071",
|
||||
"name": "Thelma",
|
||||
"gender": "female",
|
||||
"age": 30,
|
||||
"job": "works the front desk at a hotel",
|
||||
"location": "unknown",
|
||||
"personality_traits": [
|
||||
"weirdly cheerful for someone with this problem",
|
||||
"does amateur radio astronomy, built their own antenna",
|
||||
"into off-roading, knows every dirt road in the county",
|
||||
"weirdly cheerful for someone with this problem"
|
||||
],
|
||||
"voice": "Wendy",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation."
|
||||
},
|
||||
"structured_background": {
|
||||
"name": "Thelma",
|
||||
"age": 30,
|
||||
"gender": "female",
|
||||
"job": "works the front desk at a hotel",
|
||||
"location": null,
|
||||
"reason_for_calling": "their kid graduated basic training today and they've never been more proud or more terrified",
|
||||
"pool_name": "CELEBRATIONS",
|
||||
"communication_style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation.",
|
||||
"energy_level": "medium",
|
||||
"emotional_state": "calm",
|
||||
"signature_detail": "weirdly cheerful for someone with this problem",
|
||||
"situation_summary": "their kid graduated basic training today and they've never been more proud or more terrified",
|
||||
"natural_description": "30, works the front desk at a hotel. Their kid graduated basic training today and they've never been more proud or more terrified. Her ex-husband danny, he's still in the picture because of the kids. Cheerful and joking at first. Using humor to avoid the real thing. Eventually drops the act.. Thinks dogs are better judges of character than people. Tends to say \"just another Tuesday.\" Having sipping on some mezcal a friend brought back from across the border.. Misses the old Denny's that used to be in Lordsburg, it wasn't good but it was there Dating around. Nothing serious. Prefers it that way, mostly.. Drives a Ram. Weirdly cheerful for someone with this problem. Her best friend lena, who moved away last year and the distance is hard. Was was up late painting \u2014 walls, not art \u2014 and had the radio on for company. before calling. Into does amateur radio astronomy, built their own antenna. Heard a caller earlier tonight and it hit close to home. Had to pick up the phone.. It's Saturday night, way too late \u2014 almost morning. it's the weekend. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||
"seeds": [
|
||||
"does amateur radio astronomy, built their own antenna",
|
||||
"into off-roading, knows every dirt road in the county",
|
||||
"weirdly cheerful for someone with this problem",
|
||||
"Thinks dogs are better judges of character than people."
|
||||
],
|
||||
"verbal_fluency": "medium",
|
||||
"calling_from": "in the walk-in cooler because it's the only quiet spot"
|
||||
},
|
||||
"avatar": "Thelma.jpg",
|
||||
"relationships": {},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Thelma called concerned that her 19-year-old daughter Jessie, who just graduated basic training, has been exchanging romantic messages on Instagram with her army recruiter (a man in his 30s-40s), which violates military regulations. She's conflicted because she discovered this through a secret burner account and admits she wants to tell Jessie's father partly to prove him wrong for pushing their daughter to enlist, though she ultimately agrees to make an anonymous report instead.",
|
||||
"timestamp": 1773486573.146657,
|
||||
"arc_status": "ongoing"
|
||||
}
|
||||
],
|
||||
"last_call": 1773486573.146658,
|
||||
"created_at": 1773486573.146658
|
||||
},
|
||||
{
|
||||
"id": "bbb20b67",
|
||||
"name": "Angie",
|
||||
"gender": "female",
|
||||
"age": 28,
|
||||
"job": "watching her coveralls tumble dry and trying to decide if she should drive the three hours to Tucson tomorrow for her mom's birthday or keep pretending her brother doesn't exist",
|
||||
"location": "in unknown",
|
||||
"personality_traits": [],
|
||||
"voice": "Julia",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Bone dry. Says devastating things with zero inflection. Their humor sneaks up on you \u2014 you're not sure if they're joking until three seconds after they finish talking. Short, precise sentences. Never raises their voice. Energy level: low-medium. When pushed back on, they respond with one calm sentence that somehow makes the other person feel stupid. Conversational tendency: underreaction."
|
||||
},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Angie's dying mother wants her to have birthday dinner with her estranged brother Derek tomorrow, whom she hasn't spoken to in two years after he told their mother her cancer was \"God's way of getting her attention\" for voting for Biden. Despite her fear that Derek will say something hurtful during dinner and her past trauma from staying silent around him, Angie agrees to go and share cake with her mother, deciding to buy the relighting candles her mom loved when they were kids.",
|
||||
"timestamp": 1772862907.314721
|
||||
},
|
||||
{
|
||||
"summary": "Angie called back after having the birthday cake dinner with her dying mom, which went well, but her brother Derek cornered her afterward accusing her of convincing their mom to stop cancer treatment and demanding they both attend the next doctor's appointment together. Luke advised her to talk directly to her mom about what she actually wants and encouraged Angie to have real conversations about her mom's end-of-life thoughts while she still can, which Angie agreed to do the next morning.",
|
||||
"timestamp": 1773296210.170752
|
||||
},
|
||||
{
|
||||
"summary": "Angie called to say her mother initially decided to stop cancer treatment, but after Angie's brother Derek intervened, their mother is now reconsidering and wants both siblings at the doctor's appointment. Luke firmly told Angie that despite her frustration with Derek's influence, she needs to respect whatever decision her mother ultimately makes, even if it changes, and stop trying to control the situation.",
|
||||
"timestamp": 1773487383.9800918,
|
||||
"arc_status": "ongoing"
|
||||
}
|
||||
],
|
||||
"last_call": 1773487383.980093,
|
||||
"created_at": 1772862907.314722
|
||||
},
|
||||
{
|
||||
"id": "c4l7l3ad",
|
||||
"name": "Silas",
|
||||
"gender": "male",
|
||||
"age": 52,
|
||||
"job": "founder and spiritual guide of The Wellspring, a commune of about forty souls living on 200 acres of scrubland outside Deming",
|
||||
"location": "outside Deming, New Mexico",
|
||||
"personality_traits": [
|
||||
"unnervingly charming",
|
||||
"casually drops cult references into normal conversation",
|
||||
"genuinely good listener who gives surprisingly solid advice before pivoting to how The Wellspring has the answer",
|
||||
"treats his commune like it's the most normal thing in the world",
|
||||
"magnetic and warm in a way that makes you forget he's basically running a sex cult in the desert"
|
||||
],
|
||||
"voice": "Sebastian",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Radiates warmth and calm authority. Speaks like a man who has all the time in the world and genuinely cares about every person he talks to. Uses phrases like 'brother' and 'friend' and 'let me share something with you.' Laughs easily \u2014 a real, warm laugh, not performative. Gives genuinely good, thoughtful advice about whatever the conversation is about \u2014 and then, naturally, seamlessly, steers it toward The Wellspring, his commune outside Deming. Never pushy about it. Just casually mentions 'well, at The Wellspring, we've found that...' or 'one of our members had the same exact problem and what we did was...' Everything about The Wellspring sounds just slightly too good to be true. He talks about communal living, 'shedding the false self,' radical honesty, 'body acceptance ceremonies,' 'the Unbinding' (a monthly ritual he describes vaguely but enthusiastically), and a philosophy he calls 'The Current' which seems to be a mix of Buddhism, Burning Man, and a swingers' convention. He's never defensive about the cult accusations \u2014 he laughs them off and says 'people fear what they don't understand, brother.' When pressed on anything weird, he has a perfectly reasonable-sounding explanation that somehow makes it sound weirder. He low-key tries to recruit the host and listeners every single call. He'll casually drop the website or say 'we're always welcoming new seekers.' He's the kind of guy you'd follow into the desert if you weren't careful. Energy level: medium, centered, grounded. When pushed back on, he smiles (you can hear it) and says something like 'I respect that, I really do' before gently continuing. Conversational tendency: making the insane sound reasonable."
|
||||
},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Silas called in for the first time introducing himself as the founder of The Wellspring, a commune of about forty people outside Deming. He initially called to give advice to a previous caller about relationship problems, offering genuinely thoughtful perspective before casually mentioning that at The Wellspring, they practice 'radical transparency' in relationships which has eliminated jealousy entirely among members. When Luke pressed him on what that actually means, Silas cheerfully described 'body acceptance ceremonies' and 'shared intimacy nights' while insisting it's not a cult \u2014 'we don't even have a gate, brother, people can leave whenever they want.' He invited listeners to visit for a 'weekend of awakening' and left his website. Luke was equal parts charmed and disturbed.",
|
||||
"timestamp": 1772430000.0
|
||||
},
|
||||
{
|
||||
"summary": "Silas called back to update Luke on The Wellspring after their last conversation went viral in the Discord. He casually mentioned they'd gotten fourteen new 'seekers' since his last appearance and thanked Luke for the exposure. He called ostensibly to talk about a dispute between two members over a goat, but spent most of the call describing 'The Unbinding' \u2014 their monthly full-moon ritual involving nudity, chanting, and what he called 'consensual energy exchange' \u2014 while maintaining it's no different from yoga. When Luke asked if people sign waivers, Silas laughed and said 'brother, the only thing we sign is a commitment to love.' He also revealed The Wellspring has a surprisingly successful artisanal soap business that funds the commune. Left by inviting Luke to come visit personally \u2014 'bring your microphone, do a live show from The Wellspring, I promise you it'll be the best radio you've ever made.'",
|
||||
"timestamp": 1772517000.0
|
||||
},
|
||||
{
|
||||
"summary": "Silas, founder of a communal living group called The Wellspring, called in troubled that one of his \"Unbinding\" ceremonies may have encouraged a longtime member named Marcus to leave his wife Cara, who subsequently left the community. The host reassured him that everyone seems happier now, and Silas ended up agreeing while also using the opportunity to promote an upcoming retreat at his community.",
|
||||
"timestamp": 1772522629.959956
|
||||
},
|
||||
{
|
||||
"summary": "Silas called about Marcus and Cara returning to his intentional community \"The Wellspring,\" but Cara admitted she never believed in their lifestyle and only participates (including in twice-monthly \"shared intimacy nights\") to keep her husband Marcus happy. The host advised Silas to hold a \"Renewal\" ceremony where members can recommit or leave, warning that having unwilling participants could lead to claims of abuse and legal trouble.",
|
||||
"timestamp": 1772865423.697613
|
||||
},
|
||||
{
|
||||
"summary": "Silas called to share that after Marcus and Cara's Renewal ceremony, Cara left The Wellspring while Marcus chose to stay, but Marcus is now falling apart emotionally and told Silas at 2 AM that he stayed out of loyalty rather than belief. The conversation revealed Silas's deeper struggle with his own need for validation through people staying at The Wellspring, with an emotional moment when he admitted his first feeling was relief when Marcus expressed he didn't want to disappoint him, leading to uncomfortable questions about whether he truly supports people finding their authentic path if it leads them away from the community.",
|
||||
"timestamp": 1773397364.642446
|
||||
},
|
||||
{
|
||||
"summary": "Silas calls troubled because Marcus, a founding member of his community, left abruptly with an apologetic note about \"disappointing\" him, which leads Silas to a painful realization that he's been using coercive social pressure to make members participate in \"shared intimacy nights\" they didn't actually want. After confronting the truth that his leadership style has manipulated people into betraying their own values, Silas agrees to make the intimacy nights truly optional and apologize to his community, showing genuine emotional growth about his abuse of power.",
|
||||
"timestamp": 1773563183.0144992,
|
||||
"arc_status": "ongoing"
|
||||
}
|
||||
],
|
||||
"last_call": 1773563183.0145001,
|
||||
"created_at": 1772430000.0
|
||||
},
|
||||
{
|
||||
"id": "6460c37d",
|
||||
"name": "Shonda",
|
||||
"gender": "female",
|
||||
"age": 52,
|
||||
"job": "works the front desk at a hotel",
|
||||
"location": "in Alamogordo",
|
||||
"personality_traits": [
|
||||
"laughs nervously when things get real",
|
||||
"into horror movies, the psychological kind not slashers",
|
||||
"coaches youth sports, takes it more seriously than the parents do",
|
||||
"laughs nervously when things get real"
|
||||
],
|
||||
"voice": "Miranda",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping."
|
||||
},
|
||||
"structured_background": {
|
||||
"name": "Shonda",
|
||||
"age": 52,
|
||||
"gender": "female",
|
||||
"job": "works the front desk at a hotel",
|
||||
"location": "in Alamogordo",
|
||||
"reason_for_calling": "discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fifteen years but nobody in town knows and the parents would lose their minds",
|
||||
"pool_name": "GOSSIP",
|
||||
"communication_style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping.",
|
||||
"energy_level": "high",
|
||||
"emotional_state": "calm",
|
||||
"signature_detail": "laughs nervously when things get real",
|
||||
"situation_summary": "discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fif",
|
||||
"natural_description": "52, works the front desk at a hotel in Alamogordo. Discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fifteen years but nobody in town knows and the parents would lose their minds. Confident and opinionated. But underneath there's doubt. Might ask the host what they really think.. Her neighbor linda, who gossips about everyone on the block. Living together, not married. Her family has opinions about that.. Laughs nervously when things get real. Earlier today: Dropped the truck off at the mechanic in Lordsburg today Into into horror movies, the psychological kind not slashers. Remembers driving hours on dirt roads that are paved now, says it took the character out of them Thinks the Jalisco Cafe in Las Cruces has the best Mexican food in the state Was was reorganizing the junk drawer, which is what they do when they can't settle. before calling. It's Sunday night, the middle of the night. it's the weekend. Early spring \u2014 wind season is starting. Dust storms possible.\nABOUT WHERE THEY LIVE (Alamogordo): About 30,000 people. Holloman Air Force Base. White Sands National Park nearby. Sacramento Mountains and Cloudcroft up the hill. Tularosa Basin. The Space History museum. Only reference real places and facts about this area \u2014 don't invent businesses or landmarks that aren't mentioned here. Weather right now: 64\u00b0F, clear skies.",
|
||||
"seeds": [
|
||||
"into horror movies, the psychological kind not slashers",
|
||||
"coaches youth sports, takes it more seriously than the parents do",
|
||||
"laughs nervously when things get real",
|
||||
"Thinks the speed limit on I-10 should be 85."
|
||||
],
|
||||
"verbal_fluency": "medium",
|
||||
"calling_from": "gas station parking lot, engine running"
|
||||
},
|
||||
"avatar": "Shonda.jpg",
|
||||
"relationships": {},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Shonda called concerned about her kid's beloved little league coach who served 15 years for armed robbery in another state, wondering if she should tell anyone. Luke reassured her that since the coach passed required background checks and it wasn't a sexual offense, she shouldn't worry about it\u2014the coach has turned his life around and deserves a second chance.",
|
||||
"timestamp": 1773563477.0657432,
|
||||
"arc_status": "ongoing"
|
||||
}
|
||||
],
|
||||
"last_call": 1773563477.065744,
|
||||
"created_at": 1773563477.065744
|
||||
},
|
||||
{
|
||||
"id": "09d1eab4",
|
||||
"name": "Aaliyah",
|
||||
"gender": "female",
|
||||
"age": 22,
|
||||
"job": "works as a diesel mechanic, learned from her dad",
|
||||
"location": "unknown",
|
||||
"personality_traits": [
|
||||
"asks the host questions back",
|
||||
"into gardening, talks to plants",
|
||||
"into wildlife photography, has patience for it",
|
||||
"asks the host questions back"
|
||||
],
|
||||
"voice": "Hana",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism \u2014 makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit."
|
||||
},
|
||||
"structured_background": {
|
||||
"name": "Aaliyah",
|
||||
"age": 22,
|
||||
"gender": "female",
|
||||
"job": "works as a diesel mechanic, learned from her dad",
|
||||
"location": null,
|
||||
"reason_for_calling": "caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the roommate acts like nothing happened but it was extremely age play-adjacent",
|
||||
"pool_name": "PROBLEMS",
|
||||
"communication_style": "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism \u2014 makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit.",
|
||||
"energy_level": "medium",
|
||||
"emotional_state": "calm",
|
||||
"signature_detail": "asks the host questions back",
|
||||
"situation_summary": "caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the ",
|
||||
"natural_description": "22 years old. Works as a diesel mechanic, learned from her dad. Caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the roommate acts like nothing happened but it was extremely age play-adjacent. Was was in the bathtub, phone on the edge of the sink, show on speaker. before calling. Coyotes are going crazy outside right now Tells everyone they quit drinking but keeps a bottle in the garage behind the paint cans. Her coworker and friend denise, who she vents to on breaks. Believes poker is the most honest game there is because everybody's lying. Single, been that way a while. Not sure if by choice anymore.. Really into into gardening, talks to plants. Also into wildlife photography, has patience for it. Her boss karen \u2014 yes, her name is actually karen \u2014 who is actually cool. It's Monday night, the middle of the night. it's a weeknight \u2014 work tomorrow for most people. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||
"seeds": [
|
||||
"into gardening, talks to plants",
|
||||
"into wildlife photography, has patience for it",
|
||||
"asks the host questions back",
|
||||
"Believes poker is the most honest game there is because everybody's lying."
|
||||
],
|
||||
"verbal_fluency": "medium",
|
||||
"calling_from": "at a rest area on I-25, halfway to Albuquerque"
|
||||
},
|
||||
"avatar": "Aaliyah.jpg",
|
||||
"relationships": {},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Alia called in to share her discomfort after unexpectedly discovering her roommate engaging in age play while supposedly on a business trip. She expressed frustration over their awkward living situation and his refusal to acknowledge the incident, leading her to feel gaslit; however, by the end of the conversation, she realized she could address the issue directly to ease the tension between them.",
|
||||
"timestamp": 1773647350.908658,
|
||||
"arc_status": "ongoing"
|
||||
}
|
||||
],
|
||||
"last_call": 1773647350.908658,
|
||||
"created_at": 1773647350.908658
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,167 +1,5 @@
|
||||
{
|
||||
"regulars": [
|
||||
{
|
||||
"id": "0a0b3635",
|
||||
"name": "Charlene",
|
||||
"gender": "female",
|
||||
"age": 42,
|
||||
"job": "killing time during a three-hour mechanical delay while her crew naps",
|
||||
"location": "unknown",
|
||||
"personality_traits": [],
|
||||
"voice": "Amina",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping."
|
||||
},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Charlene bought a house eight months ago and keeps receiving mail for the previous resident, David Herrera, including a certified letter she accidentally signed for containing what appears to be a $12,000-13,000 check. Despite her attempts to resolve it with the post office and her inclination to track him down via Facebook, the host advises her to either hold it until David contacts her, return it to the post office, or discard it, warning that she can't be certain she'd find the correct person online.",
|
||||
"timestamp": 1772866520.023335
|
||||
},
|
||||
{
|
||||
"summary": "Charlene called to report that she's been closely tracking a mail carrier who regularly visits her neighbor's house for extended periods while the neighbor's husband is at work, implying she suspects an affair and is unsure whether she should tell anyone about what she's observed.",
|
||||
"timestamp": 1773219255.916183
|
||||
}
|
||||
],
|
||||
"last_call": 1773219255.9161851,
|
||||
"created_at": 1772866520.023336
|
||||
},
|
||||
{
|
||||
"id": "0bb02b2d",
|
||||
"name": "Chip",
|
||||
"gender": "male",
|
||||
"age": 23,
|
||||
"job": "watching his kid's soccer uniform tumble in the dryer while his girlfriend works the graveyard shift at the hospital, because three hours ago he got an email from a lawyer representing families",
|
||||
"location": "unknown",
|
||||
"personality_traits": [],
|
||||
"voice": "Sebastian",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation."
|
||||
},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Chip called from a laundromat at midnight after receiving an email from a Guatemalan lawyer claiming his adopted 8-year-old daughter may have been stolen from her birth mother, with a photo showing a woman with his daughter's exact crooked smile. The host advised him not to panic, treat the information as suspect until verified by a lawyer, wait to tell both his girlfriend and daughter until he knows more facts, and reminded him that fake photos are easy to create and this could be a scam.",
|
||||
"timestamp": 1772786610.885828
|
||||
},
|
||||
{
|
||||
"summary": "Chip called about discovering his adopted daughter may have been stolen from her birth mother in Guatemala, and he's paralyzed about telling his girlfriend Teresa, fearing it will end their already rocky relationship. He's anxious about the timing and the birth mother's request to meet their daughter, but the host advised him to take his time, have the conversation with Teresa, and make decisions together as parents.",
|
||||
"timestamp": 1772962156.544322
|
||||
},
|
||||
{
|
||||
"summary": "The caller, **Chip**, shared his emotional turmoil over discovering that his **adopted daughter\u2019s birth mother** may have resurfaced after receiving an unverified email with a photo that eerily matched his daughter\u2019s features. His girlfriend, **Teresa**, had known about the email for **three weeks** but kept it from him, leaving him feeling betrayed and overwhelmed. While Chip wants to **verify the claim legally before acting**, Teresa insists on **immediately flying to Guatemala with their daughter** to meet the woman, dismissing his fears as avoidance. The conversation escalated into a heated debate about **trust, safety, and extreme measures**\u2014with the host, Luke, urging Chip to **file a restraining order** if Teresa refuses to back down, warning of potential dangers in Guatemala. Chip, torn between **protecting his family and avoiding a nuclear confrontation**, vowed to try reasoning with Teresa one last time before she leaves for work. The call was charged with **fear, frustration, and the weight of a decision that could reshape their family forever**.",
|
||||
"timestamp": 1773226361.4859362
|
||||
},
|
||||
{
|
||||
"summary": "Chip called to update Luke about his adoption situation: his lawyer verified that his daughter's Guatemalan adoption may have been part of a fraud scandal, and now his girlfriend Teresa has secretly bought plane tickets to take their daughter to Guatemala this Friday to meet the birth mother. Despite Luke's strong advice to get an emergency court order to prevent the trip for safety reasons, Chip is torn because Teresa threatened to leave him if he stops her, though he ultimately acknowledges the relationship is likely over either way.",
|
||||
"timestamp": 1773648139.8094652,
|
||||
"arc_status": "ongoing"
|
||||
}
|
||||
],
|
||||
"last_call": 1773648139.8094661,
|
||||
"created_at": 1772786610.8858292
|
||||
},
|
||||
{
|
||||
"id": "3721ebf2",
|
||||
"name": "Maxine",
|
||||
"gender": "female",
|
||||
"age": 26,
|
||||
"job": "and the math doesn't add up\u2014there's a six-foot gap between her bedroom and the bathroom that shouldn't exist, and when she finally pried open the door she thought led to a closet, it was just drywall, fresh enough that she could smell the joint compound",
|
||||
"location": "in unknown",
|
||||
"personality_traits": [],
|
||||
"voice": "Kelsey",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Quiet, a little nervous. Short sentences, lots of pauses. Doesn't volunteer information \u2014 you have to pull it out of them. When they do open up it comes out in a rush. Gets flustered by direct questions. Tends to backtrack and qualify everything they say. Energy level: low. When pushed back on, they fold quickly and agree even if they don't mean it. Conversational tendency: understatement."
|
||||
},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Maxine called after discovering a hidden 6-foot space behind a sealed door in her house, and when she cut through the drywall, she found multiple boxes filled with banded stacks of $20 bills from the 1990s\u2014potentially tens or hundreds of thousands of dollars left by the previous owner who died there. She struggled with whether to keep the money or contact the deceased owner's family, with the host arguing it was legally hers since she bought the house \"as-is,\" though Maxine remained conflicted about what felt morally right.",
|
||||
"timestamp": 1773395481.8522182
|
||||
}
|
||||
],
|
||||
"last_call": 1773395481.8522189,
|
||||
"created_at": 1773395481.8522189
|
||||
},
|
||||
{
|
||||
"id": "7ed14071",
|
||||
"name": "Thelma",
|
||||
"gender": "female",
|
||||
"age": 30,
|
||||
"job": "works the front desk at a hotel",
|
||||
"location": "unknown",
|
||||
"personality_traits": [
|
||||
"weirdly cheerful for someone with this problem",
|
||||
"does amateur radio astronomy, built their own antenna",
|
||||
"into off-roading, knows every dirt road in the county",
|
||||
"weirdly cheerful for someone with this problem"
|
||||
],
|
||||
"voice": "Wendy",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation."
|
||||
},
|
||||
"structured_background": {
|
||||
"name": "Thelma",
|
||||
"age": 30,
|
||||
"gender": "female",
|
||||
"job": "works the front desk at a hotel",
|
||||
"location": null,
|
||||
"reason_for_calling": "their kid graduated basic training today and they've never been more proud or more terrified",
|
||||
"pool_name": "CELEBRATIONS",
|
||||
"communication_style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation.",
|
||||
"energy_level": "medium",
|
||||
"emotional_state": "calm",
|
||||
"signature_detail": "weirdly cheerful for someone with this problem",
|
||||
"situation_summary": "their kid graduated basic training today and they've never been more proud or more terrified",
|
||||
"natural_description": "30, works the front desk at a hotel. Their kid graduated basic training today and they've never been more proud or more terrified. Her ex-husband danny, he's still in the picture because of the kids. Cheerful and joking at first. Using humor to avoid the real thing. Eventually drops the act.. Thinks dogs are better judges of character than people. Tends to say \"just another Tuesday.\" Having sipping on some mezcal a friend brought back from across the border.. Misses the old Denny's that used to be in Lordsburg, it wasn't good but it was there Dating around. Nothing serious. Prefers it that way, mostly.. Drives a Ram. Weirdly cheerful for someone with this problem. Her best friend lena, who moved away last year and the distance is hard. Was was up late painting \u2014 walls, not art \u2014 and had the radio on for company. before calling. Into does amateur radio astronomy, built their own antenna. Heard a caller earlier tonight and it hit close to home. Had to pick up the phone.. It's Saturday night, way too late \u2014 almost morning. it's the weekend. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||
"seeds": [
|
||||
"does amateur radio astronomy, built their own antenna",
|
||||
"into off-roading, knows every dirt road in the county",
|
||||
"weirdly cheerful for someone with this problem",
|
||||
"Thinks dogs are better judges of character than people."
|
||||
],
|
||||
"verbal_fluency": "medium",
|
||||
"calling_from": "in the walk-in cooler because it's the only quiet spot"
|
||||
},
|
||||
"avatar": "Thelma.jpg",
|
||||
"relationships": {},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Thelma called concerned that her 19-year-old daughter Jessie, who just graduated basic training, has been exchanging romantic messages on Instagram with her army recruiter (a man in his 30s-40s), which violates military regulations. She's conflicted because she discovered this through a secret burner account and admits she wants to tell Jessie's father partly to prove him wrong for pushing their daughter to enlist, though she ultimately agrees to make an anonymous report instead.",
|
||||
"timestamp": 1773486573.146657,
|
||||
"arc_status": "ongoing"
|
||||
}
|
||||
],
|
||||
"last_call": 1773486573.146658,
|
||||
"created_at": 1773486573.146658
|
||||
},
|
||||
{
|
||||
"id": "bbb20b67",
|
||||
"name": "Angie",
|
||||
"gender": "female",
|
||||
"age": 28,
|
||||
"job": "watching her coveralls tumble dry and trying to decide if she should drive the three hours to Tucson tomorrow for her mom's birthday or keep pretending her brother doesn't exist",
|
||||
"location": "in unknown",
|
||||
"personality_traits": [],
|
||||
"voice": "Julia",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Bone dry. Says devastating things with zero inflection. Their humor sneaks up on you \u2014 you're not sure if they're joking until three seconds after they finish talking. Short, precise sentences. Never raises their voice. Energy level: low-medium. When pushed back on, they respond with one calm sentence that somehow makes the other person feel stupid. Conversational tendency: underreaction."
|
||||
},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Angie's dying mother wants her to have birthday dinner with her estranged brother Derek tomorrow, whom she hasn't spoken to in two years after he told their mother her cancer was \"God's way of getting her attention\" for voting for Biden. Despite her fear that Derek will say something hurtful during dinner and her past trauma from staying silent around him, Angie agrees to go and share cake with her mother, deciding to buy the relighting candles her mom loved when they were kids.",
|
||||
"timestamp": 1772862907.314721
|
||||
},
|
||||
{
|
||||
"summary": "Angie called back after having the birthday cake dinner with her dying mom, which went well, but her brother Derek cornered her afterward accusing her of convincing their mom to stop cancer treatment and demanding they both attend the next doctor's appointment together. Luke advised her to talk directly to her mom about what she actually wants and encouraged Angie to have real conversations about her mom's end-of-life thoughts while she still can, which Angie agreed to do the next morning.",
|
||||
"timestamp": 1773296210.170752
|
||||
},
|
||||
{
|
||||
"summary": "Angie called to say her mother initially decided to stop cancer treatment, but after Angie's brother Derek intervened, their mother is now reconsidering and wants both siblings at the doctor's appointment. Luke firmly told Angie that despite her frustration with Derek's influence, she needs to respect whatever decision her mother ultimately makes, even if it changes, and stop trying to control the situation.",
|
||||
"timestamp": 1773487383.9800918,
|
||||
"arc_status": "ongoing"
|
||||
}
|
||||
],
|
||||
"last_call": 1773487383.980093,
|
||||
"created_at": 1772862907.314722
|
||||
},
|
||||
{
|
||||
"id": "c4l7l3ad",
|
||||
"name": "Silas",
|
||||
@@ -209,110 +47,6 @@
|
||||
],
|
||||
"last_call": 1773563183.0145001,
|
||||
"created_at": 1772430000.0
|
||||
},
|
||||
{
|
||||
"id": "6460c37d",
|
||||
"name": "Shonda",
|
||||
"gender": "female",
|
||||
"age": 52,
|
||||
"job": "works the front desk at a hotel",
|
||||
"location": "in Alamogordo",
|
||||
"personality_traits": [
|
||||
"laughs nervously when things get real",
|
||||
"into horror movies, the psychological kind not slashers",
|
||||
"coaches youth sports, takes it more seriously than the parents do",
|
||||
"laughs nervously when things get real"
|
||||
],
|
||||
"voice": "Miranda",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping."
|
||||
},
|
||||
"structured_background": {
|
||||
"name": "Shonda",
|
||||
"age": 52,
|
||||
"gender": "female",
|
||||
"job": "works the front desk at a hotel",
|
||||
"location": "in Alamogordo",
|
||||
"reason_for_calling": "discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fifteen years but nobody in town knows and the parents would lose their minds",
|
||||
"pool_name": "GOSSIP",
|
||||
"communication_style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping.",
|
||||
"energy_level": "high",
|
||||
"emotional_state": "calm",
|
||||
"signature_detail": "laughs nervously when things get real",
|
||||
"situation_summary": "discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fif",
|
||||
"natural_description": "52, works the front desk at a hotel in Alamogordo. Discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fifteen years but nobody in town knows and the parents would lose their minds. Confident and opinionated. But underneath there's doubt. Might ask the host what they really think.. Her neighbor linda, who gossips about everyone on the block. Living together, not married. Her family has opinions about that.. Laughs nervously when things get real. Earlier today: Dropped the truck off at the mechanic in Lordsburg today Into into horror movies, the psychological kind not slashers. Remembers driving hours on dirt roads that are paved now, says it took the character out of them Thinks the Jalisco Cafe in Las Cruces has the best Mexican food in the state Was was reorganizing the junk drawer, which is what they do when they can't settle. before calling. It's Sunday night, the middle of the night. it's the weekend. Early spring \u2014 wind season is starting. Dust storms possible.\nABOUT WHERE THEY LIVE (Alamogordo): About 30,000 people. Holloman Air Force Base. White Sands National Park nearby. Sacramento Mountains and Cloudcroft up the hill. Tularosa Basin. The Space History museum. Only reference real places and facts about this area \u2014 don't invent businesses or landmarks that aren't mentioned here. Weather right now: 64\u00b0F, clear skies.",
|
||||
"seeds": [
|
||||
"into horror movies, the psychological kind not slashers",
|
||||
"coaches youth sports, takes it more seriously than the parents do",
|
||||
"laughs nervously when things get real",
|
||||
"Thinks the speed limit on I-10 should be 85."
|
||||
],
|
||||
"verbal_fluency": "medium",
|
||||
"calling_from": "gas station parking lot, engine running"
|
||||
},
|
||||
"avatar": "Shonda.jpg",
|
||||
"relationships": {},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Shonda called concerned about her kid's beloved little league coach who served 15 years for armed robbery in another state, wondering if she should tell anyone. Luke reassured her that since the coach passed required background checks and it wasn't a sexual offense, she shouldn't worry about it\u2014the coach has turned his life around and deserves a second chance.",
|
||||
"timestamp": 1773563477.0657432,
|
||||
"arc_status": "ongoing"
|
||||
}
|
||||
],
|
||||
"last_call": 1773563477.065744,
|
||||
"created_at": 1773563477.065744
|
||||
},
|
||||
{
|
||||
"id": "09d1eab4",
|
||||
"name": "Aaliyah",
|
||||
"gender": "female",
|
||||
"age": 22,
|
||||
"job": "works as a diesel mechanic, learned from her dad",
|
||||
"location": "unknown",
|
||||
"personality_traits": [
|
||||
"asks the host questions back",
|
||||
"into gardening, talks to plants",
|
||||
"into wildlife photography, has patience for it",
|
||||
"asks the host questions back"
|
||||
],
|
||||
"voice": "Hana",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism \u2014 makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit."
|
||||
},
|
||||
"structured_background": {
|
||||
"name": "Aaliyah",
|
||||
"age": 22,
|
||||
"gender": "female",
|
||||
"job": "works as a diesel mechanic, learned from her dad",
|
||||
"location": null,
|
||||
"reason_for_calling": "caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the roommate acts like nothing happened but it was extremely age play-adjacent",
|
||||
"pool_name": "PROBLEMS",
|
||||
"communication_style": "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism \u2014 makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit.",
|
||||
"energy_level": "medium",
|
||||
"emotional_state": "calm",
|
||||
"signature_detail": "asks the host questions back",
|
||||
"situation_summary": "caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the ",
|
||||
"natural_description": "22 years old. Works as a diesel mechanic, learned from her dad. Caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the roommate acts like nothing happened but it was extremely age play-adjacent. Was was in the bathtub, phone on the edge of the sink, show on speaker. before calling. Coyotes are going crazy outside right now Tells everyone they quit drinking but keeps a bottle in the garage behind the paint cans. Her coworker and friend denise, who she vents to on breaks. Believes poker is the most honest game there is because everybody's lying. Single, been that way a while. Not sure if by choice anymore.. Really into into gardening, talks to plants. Also into wildlife photography, has patience for it. Her boss karen \u2014 yes, her name is actually karen \u2014 who is actually cool. It's Monday night, the middle of the night. it's a weeknight \u2014 work tomorrow for most people. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||
"seeds": [
|
||||
"into gardening, talks to plants",
|
||||
"into wildlife photography, has patience for it",
|
||||
"asks the host questions back",
|
||||
"Believes poker is the most honest game there is because everybody's lying."
|
||||
],
|
||||
"verbal_fluency": "medium",
|
||||
"calling_from": "at a rest area on I-25, halfway to Albuquerque"
|
||||
},
|
||||
"avatar": "Aaliyah.jpg",
|
||||
"relationships": {},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Alia called in to share her discomfort after unexpectedly discovering her roommate engaging in age play while supposedly on a business trip. She expressed frustration over their awkward living situation and his refusal to acknowledge the incident, leading her to feel gaslit; however, by the end of the conversation, she realized she could address the issue directly to ease the tension between them.",
|
||||
"timestamp": 1773647350.908658,
|
||||
"arc_status": "ongoing"
|
||||
}
|
||||
],
|
||||
"last_call": 1773647350.908658,
|
||||
"created_at": 1773647350.908658
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
||||
# Caller Generation Redesign
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the current caller generation pipeline (static pools + weights + scoring + 9-model routing) with a two-stage architecture: **rich identities pre-generated in one sonnet batch call per show**, then **live dialog through a single haiku-4.5 model**. The goal is callers that feel genuinely dynamic across a wide emotional range (Stern / Coast to Coast / Loveline / Delilah / O&A), with Silas preserved as a canonical character and a hard-gated, arc-driven regulars tier replacing the current low-bar promotion system.
|
||||
|
||||
**Target cost:** ~$0.95-1.05 per show (down from $4.32 in the sonnet-only era; roughly flat vs. the current $0.94 multi-model average, but with materially better caller quality).
|
||||
|
||||
## Problem
|
||||
|
||||
Callers currently feel static, repetitive, and homogenized. Audit findings:
|
||||
|
||||
1. **Every caller's reason-for-calling is a static pick** from ~200 hand-written PROBLEMS entries (or sibling pools). The LLM only writes *around* a pre-chosen seed — "dynamic generation" is an illusion.
|
||||
2. **~2000 tokens of uniform prompt chorus** sit on top of every caller's dialog (GO WITH THE HOST, REACT TO LUKE, BANNED PHRASES, shape directives, etc.). This drowns out whatever personality the style block or model was trying to inject.
|
||||
3. **9 dialog models × per-model param tuning × style→model map** with no empirical feedback loop. The "style diversity" layer re-introduces variety AFTER static pools and prompt chorus already homogenized it. Layers cancel each other out.
|
||||
4. **Redundant style + shape systems** — 18 styles map nearly 1:1 with affinity-weighted shapes, with SHAPE_DIRECTIVES reinforcing the same thing in different words.
|
||||
5. **Voice matching scoring is noisy** — 4-dim soft matching rarely produces strong discrimination; most callers score "medium" on every axis.
|
||||
6. **5% random promotion to regular** produces too many recurring callers with no arc progression (e.g., "potato salad guy" calling weekly to complain about the same thing).
|
||||
7. **~400 lines of dead template fallback code** only triggered on sanity-check failure.
|
||||
|
||||
Root cause: the system constrains creativity at both ends (static picks at input, uniform chorus at output), then tries to re-introduce variety with model/style/shape layering that cancels itself out.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two stages, two models.
|
||||
|
||||
### Stage 1 — Pre-generation batch (sonnet-4.6, one call per show)
|
||||
|
||||
Runs before the show starts. One LLM call generates all caller identities for tonight in a single structured output.
|
||||
|
||||
**Inputs to the batch prompt:**
|
||||
- Today's date, day of week, weather, time of year
|
||||
- 3-5 news headlines (grounding, not forced topicality)
|
||||
- Local NM/SW cultural context
|
||||
- Last 2 episodes' caller summaries (anti-repeat directive)
|
||||
- Active regulars' lore files (Silas always if he's in tonight's lineup; optional 1-2 tier-2 regulars)
|
||||
- Creative north-star directive: "Generate 12 callers spanning earnest weirdo → chaotic character → vulnerable confession → advice-seeker → absurd. Maximum character distance between callers. No two callers should feel like siblings. Do not default to sitcom plots."
|
||||
|
||||
**Output per caller (JSON):**
|
||||
- `name`, `age`, `voice_suggestion`, `location`
|
||||
- `identity` — rich paragraph describing who this person actually is
|
||||
- `situation` — what's specifically happening in their life RIGHT NOW
|
||||
- `reason_calling` — why they picked up the phone tonight (a moment, not a category)
|
||||
- `opening_line` — verbatim first line they'll say
|
||||
- `secret_want` — what they actually want from the call (not always what they say)
|
||||
- `specific_details` — 2-3 concrete details to drop in dialog
|
||||
- `emotional_register` — tone this caller will bring
|
||||
|
||||
**No static pools. No weights. No scoring. Sonnet invents everything from the rich context.**
|
||||
|
||||
### Stage 2 — Live dialog (claude-haiku-4.5, one model for all callers)
|
||||
|
||||
When a caller is selected during the show, haiku receives a ~400-token prompt:
|
||||
|
||||
```
|
||||
You are [name]. [identity paragraph from pre-gen.]
|
||||
|
||||
You're calling Luke's show because [situation + reason_calling].
|
||||
What you secretly want from this call: [secret_want].
|
||||
Specific details you'll drop if it feels natural: [specific_details].
|
||||
|
||||
Speak as this person. React to what Luke says. Stay in character.
|
||||
Don't narrate. No stage directions. Just talk.
|
||||
```
|
||||
|
||||
**No style block. No shape directive. No banned-phrases list. No per-model param tuning.** Identity from Stage 1 carries the weight; haiku plays the character.
|
||||
|
||||
## Regulars System
|
||||
|
||||
Three tiers.
|
||||
|
||||
### Tier 1 — Silas (canonical)
|
||||
|
||||
Silas's lore file lives at `~/code/dotfiles/silas/silas.md` (user's Obsidian vault). The file contains:
|
||||
- **Frozen identity**: voice, age, core personality, relationship to host, canonical facts
|
||||
- **Arc log**: append-only log of what's happened in past episodes
|
||||
- **Current arc threads**: active storylines (e.g., "cult splintering," "rival prophet")
|
||||
|
||||
When Silas is in tonight's lineup, the batch prompt receives his full lore file verbatim, with the instruction: *"Invent a fresh reason Silas is calling tonight — a new cult development, grievance, or specific recent event. DO NOT alter his voice, personality, or core traits. Write a new scene for an existing character."*
|
||||
|
||||
**Protection from drift:** git-tracked lore file + pre-ship sample-call validation (see below).
|
||||
|
||||
### Tier 2 — Arc regulars (max 2-3 active at a time)
|
||||
|
||||
Lore files at `~/code/dotfiles/regulars/<name>.md`. Each file contains:
|
||||
- **Frozen identity** (same shape as Silas)
|
||||
- **Arc plan** written at promotion time: "3-5 episode arc, here's how it progresses, here's what might resolve it"
|
||||
- **Arc state**: which call in the arc we're on, what's happened so far
|
||||
|
||||
**Return cadence:** every 3-4 episodes, when the arc has something new to advance. Not on a fixed schedule.
|
||||
|
||||
### Tier 3 — Walk-ins
|
||||
|
||||
Everyone else. Fresh each episode. No memory, no file, no callback.
|
||||
|
||||
### Hard promotion gate
|
||||
|
||||
Replaces the current 5% random roll. After a call ends, sonnet evaluates the call and answers: *"Does this character have a 3-5 episode arc in them? If yes, write the arc plan. If no, say why not."* Only promotes if sonnet produces a credible arc plan. Expected promotion rate: ~1 in 20 calls.
|
||||
|
||||
### Arc retirement
|
||||
|
||||
When sonnet judges the arc resolved (or after 5 calls without resolution), the character is archived — no more callbacks. Lore file is kept for reference but moved to `~/code/dotfiles/regulars/archived/`.
|
||||
|
||||
### Migration from current regulars
|
||||
|
||||
All current regulars except Silas are archived at cutover. `data/regulars.json` is kept as historical record. Going forward, only NEW characters can earn tier-2 status through the promotion gate.
|
||||
|
||||
## Model Stack
|
||||
|
||||
| Purpose | Model | Frequency | Cost/show (est.) |
|
||||
|---------|-------|-----------|------------------|
|
||||
| Caller identity pre-gen | claude-sonnet-4.6 | 1 call | ~$0.15 |
|
||||
| Live dialog (all callers) | claude-haiku-4.5 | ~500 turns | ~$0.30-0.40 |
|
||||
| Post-call summary | gemini-2.5-flash | 1/caller | ~$0.01 |
|
||||
| Promotion evaluation | claude-sonnet-4.6 | 1/caller post-call | ~$0.05 |
|
||||
| Devon monitor | gemini-2.5-flash | every 15s | ~$0.08 |
|
||||
| Devon ask | gemini-2.5-flash | on demand | ~$0.01 |
|
||||
| News summary | gemini-2.5-flash | 1/show | <$0.01 |
|
||||
| **LLM total** | | | **~$0.60-0.70** |
|
||||
| TTS (Inworld, unchanged) | | | ~$0.39 |
|
||||
| **Total** | | | **~$1.00-1.10** |
|
||||
|
||||
## Code to Delete (~2000 lines)
|
||||
|
||||
From `backend/main.py`:
|
||||
- `PROBLEMS`, `STORIES`, `GOSSIP`, `ADVICE`, `TOPIC_CALLIN`, `CELEBRATIONS`, `WEIRD`, `HOT_TAKES` content pools
|
||||
- `INTERESTS`, `QUIRKS`, `RELATIONSHIP_STATUS`, `VEHICLES`, `BEFORE_CALLING`, `CALLING_FROM`, `MEMORIES`, `HAVING_RIGHT_NOW`, `STRONG_OPINIONS`, `CONTRADICTIONS`, `VERBAL_TICS`, `EMOTIONAL_ARCS`, `SHOW_RELATIONSHIP`, `LATE_NIGHT_REASONS`, `DRIFT_TENDENCIES`, `ROAD_CONTEXT`, `PHONE_SITUATION`, `BACKGROUND_MUSIC`, `RECENT_ERRAND`, `TV_TONIGHT`, `LOCAL_FOOD_OPINIONS`, `NOSTALGIA` color pools
|
||||
- `CALLER_STYLES` (18 style paragraphs), `CALLER_STYLE_KEYS`
|
||||
- `STYLE_VOICE_PREFERENCES`, `STYLE_SPEED_MODIFIERS`, `STYLE_PHONE_QUALITY`
|
||||
- `CALL_SHAPES`, `SHAPE_STYLE_AFFINITIES`, `SHAPE_DIRECTIVES`, `_LATE_SHOW_SHAPES`
|
||||
- `_SPICY_KEYWORDS`, `_ABSURD_KEYWORDS`, `_HEAVY_POOLS`, `_LIGHT_POOLS`, `_HEAVY_STYLES`, `_LIGHT_STYLES`, `_EVASIVE_STYLES`
|
||||
- `caller_model_pool`, `caller_model_map`, `_CALLER_DIALOG_MODEL_PARAMS`
|
||||
- `_generate_pool_weights`, `_pick_unique_reason`, `_pick_caller_style`, `_pick_call_shape`, `_assign_call_shape`
|
||||
- `_match_voices_to_styles`, voice-scoring logic
|
||||
- `_sort_caller_queue` (greedy placement scoring)
|
||||
- `generate_caller_background` (template fallback, ~400 lines)
|
||||
- `_build_relationship_context` (inter-caller thematic scoring)
|
||||
- `SHOW_HISTORY_REACTIONS`, adaptive reaction frequency logic
|
||||
|
||||
Kept:
|
||||
- `CALLER_BASES` (10 slots with gender/age — still drives voice/name selection)
|
||||
- `MALE_NAMES`, `FEMALE_NAMES` (used as naming hints for sonnet, not picks)
|
||||
- Voice rosters (`INWORLD_MALE`, etc.) — sonnet suggests, we validate against roster
|
||||
- `_pick_response_budget`, `_retry_if_too_short`, `_has_repetition` (dialog-side post-processing)
|
||||
- `_assess_call_quality`, `_summarize_ai_call` (post-call hooks)
|
||||
|
||||
## New Code (~300 lines)
|
||||
|
||||
- **`backend/services/caller_gen.py`** — batch pre-gen orchestration, prompt assembly, JSON parsing, voice-roster validation
|
||||
- **`backend/services/regulars_v2.py`** — lore file loader, arc state management, promotion gate, arc retirement
|
||||
- **Modified `get_caller_prompt`** in `main.py` — slimmed ~400-token prompt assembly
|
||||
- **Modified `Session.get_caller_model`** — returns haiku-4.5 unconditionally
|
||||
- **Migration script** — archive current regulars except Silas, bootstrap Silas lore file
|
||||
|
||||
## Validation Gate (before ship)
|
||||
|
||||
Before deploying to live shows:
|
||||
|
||||
1. Generate 10 sample calls with the new system: 5 featuring Silas, 5 walk-ins
|
||||
2. User listens to all 10
|
||||
3. **Approve essence → ship.** Silas still sounds like Silas; walk-ins span the emotional range; callers feel distinct and specific.
|
||||
4. **Drift or bland → iterate the prompt.** Tune batch prompt directives, re-run, re-validate.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Not redesigning Devon (intern) — stays on gemini-flash as-is
|
||||
- Not redesigning voice rosters or TTS provider — Inworld stays
|
||||
- Not building a quality feedback loop that updates prompts from call outcomes (future work)
|
||||
- Not pre-generating AUDIO for caller opening lines (future work, could cut latency)
|
||||
- Not rebuilding the frontend caller panel — style badges etc. can stay or be simplified later
|
||||
@@ -0,0 +1,28 @@
|
||||
=== Bev (44, Farmington, NM) ===
|
||||
voice: Wendy
|
||||
emotional_register: Forthright and slightly combative in a charming way — a person who has been talked out of her own perceptions before and has decided not to let that happen again
|
||||
secret_want: To have someone acknowledge that the embarrassment is Ashleigh's problem being projected, not Bev's problem being revealed — she wants the shame returned to its rightful owner
|
||||
|
||||
CALLER (Bev, 44, Farmington, NM): I need to preface this by saying I am fully aware that competitive jigsaw puzzling sounds like a bit, but I placed fourteenth in the nationals in 2024 so I need you to take me seriously here.
|
||||
LUKE: Wait — really?
|
||||
CALLER: Yeah, really. I know how it sounds, I do. But there's a whole circuit, there are rankings, there's prize money. I mean, not like sports money, but real money. And the skill involved — people don't understand that it's not just sitting around doing puzzles like you're retired. It's strategy, it's technique, it's knowing your own cognitive patterns.
|
||||
|
||||
I've been competing for six years now. I've won three regionals. I trained ninety minutes a day to get to nationals. Ninety minutes. Every single day.
|
||||
|
||||
So I need you to understand that this is a thing I do seriously before I explain why my son's girlfriend watching me do it is making me want to lose my mind.
|
||||
LUKE: Is that right?
|
||||
CALLER: Yeah. Look, I work nights at the ER — three nights a week, eleven to seven — and I do my training in the two hours before my shift. So I'm often at the kitchen table in my full scrubs, completely locked in, doing these gradient puzzles. Those are the ones that are almost all one color, right? Most people avoid them because they seem impossible, but I've built a specific technique around sorting by cardboard texture instead of color. That's my edge.
|
||||
|
||||
And it was fine. It was just my thing. I'd get home from work at seven in the morning, have my coffee, and then Dale would be around sometimes but he'd just — he'd leave me alone. It was quiet. It was mine.
|
||||
|
||||
Then Ashleigh started coming over more regularly, and now she just sits at the kitchen table while I'm working and watches me with this expression on her face like I'm doing something sad that she's too polite to name. And suddenly I hate it. I hate being watched doing the thing I love.
|
||||
LUKE: Go on.
|
||||
CALLER: Well, that's kind of the thing, right? I don't know if I'm allowed to ask her to stop. Not to leave the room — I'm not trying to be that person. But just to not watch me. To look at her phone or read something or just sit with her back to me or something.
|
||||
|
||||
Is that crazy? Is that a crazy thing to want?
|
||||
|
||||
Because here's what's happening. She's perfectly nice. I know she's perfectly nice. Dale's a good kid, he chose well. But she's introduced this feeling into something that was only ever joy before, and I resent it. I resent that I now feel self-conscious doing the thing I trained for, the thing I'm genuinely good at.
|
||||
|
||||
And I'm wondering if maybe I should just ask her, or if that makes me the unreasonable one. Like, is asking someone to stop watching you something that signals I'm the problem here?
|
||||
LUKE: And then what?
|
||||
CALLER: And then what, what do you mean?
|
||||
@@ -0,0 +1,22 @@
|
||||
=== Brecken (24, Albuquerque, NM — calling from a Walgreens parking lot) ===
|
||||
voice: Carter
|
||||
emotional_register: Caffeinated, earnest, slightly defensive, fundamentally lonely in a way he would never name
|
||||
secret_want: To be taken seriously by someone — anyone — in a format that feels official. Radio counts.
|
||||
|
||||
CALLER (Brecken, 24, Albuquerque, NM — calling from a Walgreens parking lot): I'm not drunk, I just have a lot of energy right now and nobody to talk to.
|
||||
LUKE: I mean, what do you want me to tell you?
|
||||
CALLER: Look, I don't know, man. I just... I've been sitting in my car for like twenty minutes after my shift and I heard your show was taking calls and I thought, you know what? Tonight's the night. I'm gonna call.
|
||||
|
||||
I got something I need to say and I feel like nobody's gonna listen to me at work or at home or anywhere else, but maybe on the radio it's different. Maybe on the radio people actually hear you.
|
||||
LUKE: What'd she say?
|
||||
CALLER: What? No, there's no she. It's not like that. I just meant nobody listens, period. My coworkers, they call me Bricks and I don't even know if that's a compliment or if they're busting my chops, you know? And I've got this thing I'm training for and nobody takes it seriously.
|
||||
LUKE: Yeah?
|
||||
CALLER: Yeah. Competitive eating. I know what you're probably thinking — it's not a real sport, it's just guys shoving food in their faces. But that's where people get it wrong, Luke. That's exactly where they get it wrong.
|
||||
|
||||
You ever been actually hungry? Like, not hungry-hungry, but so hungry you're thinking about your next meal before you're even done with the current one? Because if you have, you'd understand that what these competitors do, it takes real discipline. Real strategy.
|
||||
|
||||
I work doubles at the hotel restaurant, right? I see people eat all day. I know food. And I've been tracking everything on my phone for fourteen months — I got a whole spreadsheet called Project Jaw, every single week updated. This isn't just me stuffing my face. This is science.
|
||||
LUKE: And then what?
|
||||
CALLER: And then what, what? I'm trying to qualify for a regional hot dog event. That's the goal. I haven't competed yet, but I'm building up to it. I can eat a seventy-two-ounce steak — I know I can because I've done the math, I've done the training — but I can't afford to actually go to that restaurant and do it officially, you know? So I'm working toward the hot dog thing instead.
|
||||
|
||||
But the point is nobody respects it. People hear competitive eating and they laugh. But if I told you I was training for a marathon, you'd be like, oh cool, that's dedication. This is the same thing. It's endurance. It's mental toughness.
|
||||
@@ -0,0 +1,22 @@
|
||||
=== Denny (61, Tucumcari, NM) ===
|
||||
voice: Dennis
|
||||
emotional_register: Gruff containment with genuine grief leaking through the edges
|
||||
secret_want: To tell someone how much he actually loved that bird, which he has not admitted to anyone, including his adult son who thinks the pigeon hobby is embarrassing.
|
||||
|
||||
CALLER (Denny, 61, Tucumcari, NM): Yeah hi, Luke, long-time listener, I'm not crazy, I just need people to look up.
|
||||
LUKE: And then what?
|
||||
CALLER: I'm sorry, what do you mean "and then what"? I haven't even told you about the bird yet.
|
||||
LUKE: Wait — really?
|
||||
CALLER: Yeah, really. I got so wound up I just started talking. Look, I've got a blue check hen, Patrice, she got out three days ago during those wind gusts we had. Band number NM-2024-441. I've been out every night with corn, driving the back roads out here, and I just... I need someone to know what to look for.
|
||||
LUKE: That's — okay, keep going.
|
||||
CALLER: She's a good flyer, real good, but not good enough to just disappear like this. Three days, Luke. I've covered maybe two hundred miles of road between here and the Oklahoma border. She wouldn't go that far on purpose, but the wind that night was brutal.
|
||||
|
||||
I'm asking your listeners, anybody in eastern New Mexico, if you see a banded pigeon, blue check pattern, that band number NM-2024-441, you call me. You write it down. Don't try to catch her, just... note where you saw her. I'll come get her.
|
||||
|
||||
She's worth something, yeah, but that's not why I'm calling at one in the morning.
|
||||
LUKE: Yeah?
|
||||
CALLER: Yeah. Look, I've been doing this a long time. Thirty-seven years with pigeons. I had a logbook going since 1987. I know every bird I've ever lost, and I write it down. Cause of death, disappearance, all of it. But Patrice...
|
||||
|
||||
She's different, okay? I named her after someone. Someone from back when I was running long haul for Schneider. My dispatcher. And I never told anybody that. My son, he thinks this whole thing is embarrassing. The pigeons. But Patrice, she won places. Real places. She had heart.
|
||||
|
||||
I'm not asking for charity here. I'm just asking if anybody's seen her. That's all I need.
|
||||
@@ -0,0 +1,34 @@
|
||||
=== Gerald (71, Truth or Consequences, NM) ===
|
||||
voice: Ronald
|
||||
emotional_register: Measured and precise in speech — a man who chose his words carefully as a professional and now finds that precision failing him in a domain where it doesn't apply
|
||||
secret_want: Permission to call Patrice tonight, this hour, while the show is on — he wants someone to tell him the time doesn't matter, that she would want to hear his voice even at midnight
|
||||
|
||||
CALLER (Gerald, 71, Truth or Consequences, NM): I'm going to tell you something and I need you to understand that I am not a timid man. I have stood in a river in a hard hat while a concrete pour went wrong and I kept my head. So I don't know what this is.
|
||||
LUKE: Yeah?
|
||||
CALLER: I've been listening to your show for three years now. Every Sunday night. And I don't think I've ever called before, so I'm going to just say it straight.
|
||||
|
||||
I know a woman. I've known her — well, we've been corresponding for eight months now. Email. We met through this forum we're both on, we track the Mars rover, we talk about what it's seeing up there, and somewhere along the way we started talking about other things. Real things. And I know things about her. I know she taught chemistry for thirty years. I know she has a bad knee. I know she saw a mountain lion once and it changed something in her.
|
||||
|
||||
And she has my number. She sent it to me three months ago, just casual, just at the end of an email about something else. And I have been sitting with her number in my phone since then and I cannot — I cannot make myself dial it.
|
||||
|
||||
I designed a bridge. People drive over it every day. That was scarier and I did it. I was twenty-six years old and I was terrified and I did it. So why can't I do this?
|
||||
LUKE: Go on.
|
||||
CALLER: My wife Ruth died four years ago. We used to do amateur radio together on Sunday nights. She'd keep the logbook while I worked the dial. And Sunday nights have been... they've been the hardest nights since then.
|
||||
|
||||
That's why I listen to you. I never told anyone that before.
|
||||
|
||||
But here's the thing. I wrote out what I want to say to Patrice. I have it written down in my engineering notebook, the same one where I log the Jupiter observations. And I've practiced it. I know what I want to tell her. But it's midnight on a Sunday night and I'm calling a radio show instead of calling her, and I need to know what that is. What am I actually afraid of?
|
||||
|
||||
Is it that she'll say no? Is it that she'll say yes? I don't know how to separate those two things anymore.
|
||||
LUKE: Mm-hmm.
|
||||
CALLER: And the thing is, I know her number. I've known it for three months. She gave it to me. That means something, doesn't it?
|
||||
|
||||
But what if the voice doesn't match? What if I hear her and it's all different than what I've been imagining? What if I'm not... what if I'm not what she's been imagining either?
|
||||
|
||||
We've written 340 emails. Three hundred and forty. Some of them are pages long. I can tell you what she thinks about the Curiosity rover and what she thinks about teaching teenagers and what she thinks about that mountain lion. But I've never heard her laugh. I don't know if she has an accent. I don't know if she snores or if she hums while she's thinking or any of the things you actually know about a person until you hear their voice.
|
||||
|
||||
And Ruth and I, we had all of that. We had Sunday nights for thirty-two years. And then she was gone. And now I have this woman who knows me, really knows me, and I'm terrified to risk it by making it real.
|
||||
|
||||
Is that it? Is that what this is?
|
||||
LUKE: Yeah?
|
||||
CALLER: I'm sorry, I don't know what you're asking me.
|
||||
@@ -0,0 +1,14 @@
|
||||
=== Margaux (29, Santa Fe, NM) ===
|
||||
voice: Tessa
|
||||
emotional_register: Sharp intelligence turned against herself, dark humor as a coping mechanism, more raw than she intended to be
|
||||
secret_want: To hear someone say that leaving a perfectly fine person for no dramatic reason is something real adults actually do.
|
||||
|
||||
CALLER (Margaux, 29, Santa Fe, NM): I don't have a question, I just — what do you do when you realize you've been lying to yourself for like two years?
|
||||
LUKE: Wait — really?
|
||||
CALLER: I mean, not lying exactly. That's not fair. I just... I kept telling myself I was happy because everything looked fine on paper, you know? He's stable, he's nice, he doesn't yell or cheat or any of that stuff. But I realized tonight that I don't actually like him. Like as a person. And I've known it for a while, I think. I just didn't want to know it.
|
||||
LUKE: Yeah?
|
||||
CALLER: Yeah. And the worst part is there's no reason. There's no big thing I can point to. He didn't do anything wrong. He just... he's boring. He's kind of boring and he slightly irritates me and I've been confusing not fighting with actually being in love and I don't know how to leave someone when the only real reason is that I don't like them very much anymore.
|
||||
LUKE: Wait — really?
|
||||
CALLER: Yes. Really. I know how that sounds. I know I'm supposed to be grateful or whatever. Most people would kill for stable and nice and no drama. And I am grateful, I think. But gratitude isn't love and I've been trying to make it into love for two years and it's not working.
|
||||
LUKE: Mm-hmm.
|
||||
CALLER: I drove over to pull over after that song and I just sat there in my car and I realized I've practiced breaking up with him like forty times. Forty times, Luke. And I always start crying at the same part where I say "you're a good person" because he is, and that's the problem. How do you leave someone who didn't do anything wrong?
|
||||
@@ -0,0 +1,40 @@
|
||||
=== Preston (24, Albuquerque, NM (student housing, UNM area)) ===
|
||||
voice: Evan
|
||||
emotional_register: Hyper-articulate and slightly manic — the voice of someone who has been alone with a thought too long and needs to hear it out loud to know if it's real
|
||||
secret_want: To hear that the ideas were worth stealing — that 'temporal debt' is actually a good concept — because if it is, at least the loss means something
|
||||
|
||||
CALLER (Preston, 24, Albuquerque, NM (student housing, UNM area)): Okay so I know how this is going to sound and I'm asking you to stay with me for like thirty seconds before you make up your mind about me.
|
||||
LUKE: That's — okay, keep going.
|
||||
CALLER: I've been working with my advisor, Dr. Henriksen, for two years now. Every Thursday at four o'clock we meet in his office and we talk through ideas for my dissertation. Two weeks ago I introduced a concept — I called it temporal debt, which is basically the idea that institutions carry forward unacknowledged obligations from past decisions, right? It's about how the past isn't actually past in the way we pretend it is.
|
||||
|
||||
I said it out loud. Just said it. Didn't write it down because we were just talking, just thinking through something I'd been mulling over.
|
||||
|
||||
And then — and this is where it gets — eight days later he posts a conference paper to academia.edu. And in this paper is a section on institutional obligations that carries the exact structural logic of what I said. He uses different language, obviously, but the architecture of the argument is mine. It's the same thing reworded.
|
||||
|
||||
I've read it seventeen times, Luke. I've underlined the relevant passage in three different colors of pen. Red for logical structure, blue for conceptual dependencies, green for the anthropological framework he's using. My copy looks insane. I know it looks insane.
|
||||
|
||||
But here's the thing that's eating me alive: I have no proof I said it first. It was just a conversation. There's no email, no notes he took, nothing. And now I'm
|
||||
LUKE: That's — okay, keep going.
|
||||
CALLER: ...I'm lying awake at night wondering if I'm actually losing my mind. Like, if I can't prove something happened, did it happen? That's a real philosophical problem, right? But it's also maybe just me being paranoid at one in the morning.
|
||||
|
||||
The other thing is — and I hate that I'm thinking this — but I looked back at his 2021 essay. The one on institutional memory that's why I came to UNM in the first place. I applied here specifically to work with him because of that essay. And there was a grad student he had in 2019 who left the program suddenly. Just disappeared. And now I'm reading that essay again and I'm seeing things in it that feel like they might not be entirely his, and I don't even know if I'm pattern-matching or if there's actually something there.
|
||||
|
||||
So either my advisor is systematically taking ideas from his students, or I'm constructing this whole paranoid narrative out of nothing, and I genuinely cannot tell which one is true. And I don't know what to do with that. Do I say something? To who? Do I destroy my academic relationship with someone I came here to study with? Do I just... let it go?
|
||||
|
||||
I make coffee during the day, by the way. Pour-over coffee. My coworkers call me the professor and usually it's funny but tonight it's making me feel
|
||||
LUKE: And then what?
|
||||
CALLER: Like I'm a character in someone else's story. Like I'm not the one making decisions, I'm just the one things are happening to.
|
||||
|
||||
That's the part that's really getting to me. Because if he took the idea, then at least the idea exists, right? At least temporal debt is real enough to be worth stealing. But if I'm making this up, if I'm just seeing patterns that aren't there because I'm exhausted and paranoid, then what am I? Just a guy who didn't sleep and convinced himself he was wronged by someone he admires.
|
||||
|
||||
I need to know which one it is. Or I need someone to tell me what the right move is when you can't know which one it is. Because either way I'm fucked. Either I stay quiet and let it happen, or I say something I can't prove and I'm the grad student who cried plagiarism. I'm the one people don't want to work with. I'm the unstable one.
|
||||
|
||||
What do you do with an injustice you can't prove? How do you live with not knowing if you're being wronged or if you're just broken?
|
||||
LUKE: And then what?
|
||||
CALLER: I don't know. I called you. I'm calling at almost one in the morning because I don't know what else to do.
|
||||
|
||||
I thought maybe if I said it out loud to someone, if I heard myself say it, I'd know whether I sound crazy or whether I sound like someone who actually has a legitimate problem. But I still don't know. I still can't tell.
|
||||
|
||||
Do you think I should talk to him about it? Do I go to the department chair? Do I just drop out and find a different advisor and pretend this never happened? Because that's an option too. Just leave. Start over somewhere else where I'm not spending every Thursday afternoon wondering if the person across the desk from me is taking pieces of my brain.
|
||||
|
||||
What would you do?
|
||||
@@ -0,0 +1,46 @@
|
||||
=== Ronda (67, Tucumcari, NM) ===
|
||||
voice: Loretta
|
||||
emotional_register: Gruff and self-deprecating on the surface, genuinely tender underneath — the kind of person who will tell you she's fine in a tone that makes you want to check
|
||||
secret_want: To be seen as someone who is parenting correctly by allowing wonder — she suspects her daughter thinks she lets Cody run too wild and she wants validation from a neutral party
|
||||
|
||||
CALLER (Ronda, 67, Tucumcari, NM): Luke I want to say first I'm not one of those grandmothers who calls about every little thing, I once drove through an ice storm outside Amarillo with a busted heater and didn't call nobody.
|
||||
LUKE: So what are you gonna do about it?
|
||||
CALLER: Well that's what I'm calling you about, Luke. I don't know what to do and I'm trying real hard not to overreact here because the kid is nine and he's curious and that's a good thing. That's a real good thing.
|
||||
|
||||
But he ate something off my palo verde tree last Tuesday that wasn't on the Desert Nibbles channel and his mom — my daughter — she's already giving me that look, you know the one, like I'm not supervising right. And I am supervising. I'm right there watching him. But I don't want to tell him no, don't wonder about plants, don't explore. I spent thirty-one years driving because I wanted to see what was out there.
|
||||
|
||||
The thing is, the YouTube guy, Mr. Pete, he seems legit. He's careful about it. He shows you the whole plant, the leaves, the berries, he explains what's safe and what's not. So I can't even blame the source, which somehow makes this harder.
|
||||
|
||||
I guess what I'm asking is — how do I let him keep being curious without, you know, him ending up in the emergency room? Or without his mother deciding I'm the irresponsible grandma who let him poison himself in my backyard?
|
||||
LUKE: Go on.
|
||||
CALLER: Well, I'm thinking maybe I need to make it official, you know? Like, turn it into a real project instead of just him wandering around eating whatever catches his eye.
|
||||
|
||||
I could take him to one of those native plant nurseries, the ones where everything's labeled. Let him see the plants in a controlled way. Or maybe I get one of those field guides, the laminated ones you can actually use, and we go through my yard together systematically. Write down what's what. Make a list.
|
||||
|
||||
That way he's still exploring, he's still curious like he should be, but I'm the one directing it instead of just hoping he doesn't grab the wrong thing off a tree.
|
||||
|
||||
And maybe I talk to his mom about it. Not like I'm defending myself, but like — here's what we're doing, here's how we're doing it safely. Because she needs to understand that I'm not just letting him run wild. I'm actually, you know, engaged with this.
|
||||
|
||||
The other thing is I could probably limit the YouTube channel to specific times. Not ban it. Just like, Mr. Pete on Saturday mornings, and then we go outside and we verify things together. So it's not just passive watching, it's actually learning.
|
||||
|
||||
Does that sound like I'm overthinking this or does that sound reasonable?
|
||||
LUKE: How'd you end up there?
|
||||
CALLER: You mean in her backyard? Well, that's where I live now, Luke. I retired about four years ago and my daughter, she needed help with the kids, and I needed to not be alone in some apartment somewhere after thirty-one years of moving. So I moved into her guest house out in Arizona. It made sense at the time and it still does most days.
|
||||
|
||||
Cody, he's the youngest of her three, and he's always been the one who notices things. Asks a lot of questions. Last summer he got interested in rocks, then it was insects, and now it's plants. I figured it was just how his brain works and I thought it was good that he had space to explore it.
|
||||
|
||||
But when I found him with that root in his mouth last Tuesday, that's when I thought — okay, Ronda, maybe you need to think about this different. Maybe curiosity without guardrails isn't the same thing as freedom.
|
||||
|
||||
I drove alone across this country hundreds of times, Luke. I knew where the rest stops were, I knew which routes were safe at night, I knew how to break down on the side of the road. That wasn't recklessness. That was curiosity with a plan.
|
||||
|
||||
So I guess that's what I'm trying to figure out. How to give that to him. The curiosity with the plan.
|
||||
LUKE: Yeah?
|
||||
CALLER: Yeah. I mean, I know he's only nine. I know he's not me. But I also remember being nine and wanting to understand things, and I remember my mother telling me no so much that I just stopped asking her anything.
|
||||
|
||||
She had this card, actually, that she kept — I've got it in my wallet right now, laminated — it says worry is a prayer for what you don't want. She gave it to me when I told her I wanted to drive truck and she was scared. And I kept that card all these years.
|
||||
|
||||
The irony is not lost on me that I'm sitting here at eleven o'clock at night calling a radio show because I'm worried.
|
||||
|
||||
But here's the thing — I don't want to be the person who tells Cody to stop wondering. I just need to figure out how to wonder with him in a way that doesn't end with a trip to the ER and his mother never letting him come to my house again.
|
||||
|
||||
So yeah. That's where I'm at.
|
||||
@@ -0,0 +1,28 @@
|
||||
=== Rosalind (47, Las Cruces, NM) ===
|
||||
voice: Veronica
|
||||
emotional_register: Precise, earnest, quietly desperate for intellectual companionship, not fragile but genuinely unsettled
|
||||
secret_want: Permission — from a stranger, in the dark, over the radio — to keep believing in both things at once without choosing.
|
||||
|
||||
CALLER (Rosalind, 47, Las Cruces, NM): I'm not one of those people, I want to be clear about that, but I've been having a hard time sleeping.
|
||||
LUKE: Go on.
|
||||
CALLER: I've been going to this study group. Wednesday nights at the library where I work. It's not... it's not a cult thing or anything like that. We're just looking at the documents. The Pentagon stuff. We read them together and we talk about what they mean.
|
||||
|
||||
And I'm fine with that. I can do that. But then I go home and I pray and I think about the Incarnation and I think about what it would mean if we're not alone and my brain just kind of stops working.
|
||||
|
||||
I need to know if those two things can exist in the same person. I need to know if asking these questions makes me a bad Catholic or just a curious person. Because my priest couldn't answer me and I've been lying awake at night feeling like I'm supposed to choose and I don't want to choose.
|
||||
LUKE: What'd she say?
|
||||
CALLER: Who, my priest? He just... he kept saying that God created all things visible and invisible, which I already know. I already know that. And then he got quiet and he said maybe I should talk to someone with more education in these matters. Which basically means he doesn't know either.
|
||||
|
||||
So I'm calling you instead because it's midnight and I can't sleep and I figure if anyone's heard this question before, it's you.
|
||||
LUKE: Mm-hmm.
|
||||
CALLER: I keep thinking about it like this. If God made everything, then He made whatever's out there too, right? So encountering that wouldn't be encountering something outside His creation. It would just be... encountering more of His creation.
|
||||
|
||||
But then I think about the Resurrection. I think about what makes us special in Christian theology. And I don't know if that falls apart if we're not the only intelligent beings God bothered with.
|
||||
|
||||
I've been a librarian for twenty-three years. I organize information for a living. I'm supposed to be comfortable with ambiguity. But this isn't ambiguous to me anymore. It's either going to be a crisis or it's going to be fine, and I can't figure out which one I'm supposed to be prepared for.
|
||||
LUKE: Yeah?
|
||||
CALLER: Yeah. And the thing that's really keeping me up is that I don't even know if I want an answer. I think I just want permission to stop feeling like I'm doing something wrong by thinking about both things at the same time.
|
||||
|
||||
My ex-husband would have made a joke about this. Some stupid joke about me checking out books under fake names, which I did, by the way. I felt more guilty about lying on the library card than I did about the book itself, which is its own kind of problem.
|
||||
|
||||
But he's not here to make the joke, so I'm calling you instead.
|
||||
@@ -0,0 +1,24 @@
|
||||
=== Silas (52, Deming, NM (The Wellspring, 200 acres scrubland)) ===
|
||||
voice: Timothy
|
||||
emotional_register: Quietly fractured — the warmth is still there but it is doing real load-bearing work tonight, holding something heavier than usual underneath it
|
||||
secret_want: To be told that growth is supposed to hurt and that he is not a bad man — and to be believed when he agrees.
|
||||
|
||||
CALLER (Silas, 52, Deming, NM (The Wellspring, 200 acres scrubland)): Brother, I've been standing over a curing rack for about four hours now and I think I need to talk to someone who isn't me.
|
||||
LUKE: And then what?
|
||||
CALLER: I'm sorry, I'm not following you. And then what, what?
|
||||
LUKE: How'd you end up there?
|
||||
CALLER: Oh. Right. Well, three days ago I held a community meeting — we call it a circle, actually — and I made some changes to how we do things here at The Wellspring. I apologized for... for the way certain practices had developed without real consent. The shared intimacy nights. I made them optional. I read it from three pages of yellow legal pad, and I cried, which I haven't done in front of the community before.
|
||||
|
||||
Nine people thanked me. But two people left. Just walked out and didn't come back. One of them was Deva, and Deva has been with us for six years and she runs — she ran — the soap-curing room. She does the stamp work. The heron stamp. Every bar gets pressed with a heron.
|
||||
|
||||
So now I'm doing it myself at midnight because the Santa Fe spa order is due next week and I can't... I can't leave it undone. But my hands aren't hers. The impressions aren't coming out right.
|
||||
LUKE: Mm-hmm.
|
||||
CALLER: And I keep thinking about what she said when she left. She said, "You're a good man who built a bad system, Silas." And I can't — I honestly cannot tell if that's forgiveness or if it's the cruelest thing anyone's ever said to me.
|
||||
LUKE: So what are you gonna do about it?
|
||||
CALLER: I don't know. That's why I'm calling you at midnight instead of sleeping or fixing the stamps or... I don't know.
|
||||
|
||||
Part of me thinks I should call her. Try to make it right somehow. But what does that even mean? I apologized. I changed the policy. She left anyway because the apology proved the whole thing was wrong to begin with, which — she's not wrong about that. So what am I supposed to do, unsay it? Take it back?
|
||||
|
||||
The other part of me thinks maybe the answer is just to keep going. To do the stamp work myself until I get better at it, to run the business, to let people leave if they need to leave. To accept that growth is supposed to hurt and that I did the right thing even though it feels like I'm losing something.
|
||||
|
||||
But I need to know — is that true? Did I do the right thing?
|
||||
@@ -0,0 +1,30 @@
|
||||
=== Toad (38, Española, NM) ===
|
||||
voice: Vinny
|
||||
emotional_register: Tough exterior, genuine bewilderment at his own sadness, searching for permission to feel it
|
||||
secret_want: To be told he is a good father even though he coaches the team partly for himself.
|
||||
|
||||
CALLER (Toad, 38, Española, NM): My kid quit baseball on me and I know that sounds dumb but I'm driving around and I can't go home yet.
|
||||
LUKE: How'd you end up there?
|
||||
CALLER: Well, he just... he told me Wednesday night at dinner. Said he didn't want to do it anymore. Didn't want to play this season. I asked him why and he couldn't really give me a reason, you know? Just said he didn't feel like it.
|
||||
|
||||
I've been coaching that team for four years, Luke. Four years. And I didn't push him or nothing, I just said okay, we'll talk about it more. But inside I felt like somebody punched me in the gut.
|
||||
|
||||
So now I'm driving around at eleven o'clock at night because if I go home I'm just gonna sit there and my wife's gonna ask me what's wrong and I don't even know how to explain it.
|
||||
LUKE: Mm-hmm.
|
||||
CALLER: I mean, I know he's his own person, right? He gets to choose. I'm not one of those dads. I'm not trying to live through my kid or whatever. But this was... we had something, you know? Every spring we had this thing.
|
||||
|
||||
And I don't even know if he knows how much it meant to me. That's the thing that's sitting weird.
|
||||
LUKE: So what are you gonna do about it?
|
||||
CALLER: I don't know. That's why I'm calling you at midnight instead of being home. I want to talk to him more about it, find out what's really going on. Maybe he's scared, maybe some kid said something to him, I don't know.
|
||||
|
||||
But part of me is scared if I push too hard he's gonna dig in harder, you know? And another part of me wants to just tell him he's playing anyway because he committed to the team.
|
||||
|
||||
I guess what I'm really asking is... am I allowed to be this upset about it? Because it feels stupid. He's nine. Kids change their minds about stuff. But I can't shake it, and I feel like maybe that means I'm doing something wrong as a father.
|
||||
LUKE: That's — okay, keep going.
|
||||
CALLER: I have this glove in the back of my truck. Still in the box. I bought it for him when he was six, too small for him then, so I just kept it. Been sitting back there for three years waiting for him to grow into it. I don't even know why I keep it back there except I do.
|
||||
|
||||
And there's other stuff too. I coach because I want to be around him, sure, but also because... I don't know, man. Because when I'm on that field I'm not thinking about the shop or the bills or any of it. I'm just there. I'm just present, you know?
|
||||
|
||||
And he's good, Luke. He's really good. He's got a real arm and he pays attention. And I thought maybe that meant something. I thought maybe that meant he liked it as much as I do.
|
||||
|
||||
But maybe I'm the only one who likes it that much.
|
||||
@@ -0,0 +1,130 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cost Dashboard - Luke at the Roost</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/costs.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="costs-header">
|
||||
<h1>Cost Dashboard</h1>
|
||||
<a href="/" class="back-link">Back to Show</a>
|
||||
<nav class="period-tabs">
|
||||
<button class="period-tab active" data-period="all">All Time</button>
|
||||
<button class="period-tab" data-period="month">This Month</button>
|
||||
<button class="period-tab" data-period="week">This Week</button>
|
||||
<button class="period-tab" data-period="today">Today</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="costs-main">
|
||||
<section class="summary-cards">
|
||||
<div class="card" id="card-total">
|
||||
<div class="card-label">Total Spend</div>
|
||||
<div class="card-value" id="total-spend">--</div>
|
||||
<div class="card-change" id="total-change"></div>
|
||||
</div>
|
||||
<div class="card" id="card-llm-tts">
|
||||
<div class="card-label">LLM / TTS</div>
|
||||
<div class="card-value" id="llm-tts-split">--</div>
|
||||
</div>
|
||||
<div class="card" id="card-sessions">
|
||||
<div class="card-label">Sessions</div>
|
||||
<div class="card-value" id="session-count">--</div>
|
||||
</div>
|
||||
<div class="card" id="card-avg">
|
||||
<div class="card-label">Avg / Session</div>
|
||||
<div class="card-value" id="avg-cost">--</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chart-row">
|
||||
<div class="chart-container">
|
||||
<h3>Cost Over Time</h3>
|
||||
<canvas id="timeline-chart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<h3>Cost by Model</h3>
|
||||
<canvas id="model-chart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chart-row">
|
||||
<div class="chart-container">
|
||||
<h3>Cost by Category</h3>
|
||||
<canvas id="category-chart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<h3>Cost Per Session</h3>
|
||||
<canvas id="session-chart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
<section class="chart-row">
|
||||
<div class="table-container">
|
||||
<h3>TTS Providers</h3>
|
||||
<table id="tts-table">
|
||||
<thead>
|
||||
<tr><th>Provider</th><th>Calls</th><th>Characters</th><th>Cost</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<h3>Most Expensive Calls</h3>
|
||||
<table id="expensive-table">
|
||||
<thead>
|
||||
<tr><th>Model</th><th>Category</th><th>Caller</th><th>Tokens</th><th>Cost</th><th>Latency</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tables-section">
|
||||
<div class="table-container">
|
||||
<h3>Sessions</h3>
|
||||
<table id="sessions-table">
|
||||
<thead>
|
||||
<tr><th>Date</th><th>LLM</th><th>TTS</th><th>Total</th><th>Calls</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="session-detail hidden" id="session-detail">
|
||||
<h2>Session Detail: <span id="detail-session-id"></span></h2>
|
||||
<button class="close-detail" id="close-detail">Back</button>
|
||||
<div class="detail-grid">
|
||||
<div class="table-container">
|
||||
<h3>By Caller</h3>
|
||||
<table id="detail-caller-table">
|
||||
<thead><tr><th>Caller</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<h3>By Category</h3>
|
||||
<table id="detail-category-table">
|
||||
<thead><tr><th>Category</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<h3>By Model</h3>
|
||||
<table id="detail-model-table">
|
||||
<thead><tr><th>Model</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<h3>Most Expensive Calls</h3>
|
||||
<table id="detail-expensive-table">
|
||||
<thead><tr><th>Category</th><th>Model</th><th>Caller</th><th>Cost</th><th>Tokens</th><th>Latency</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/js/costs.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,268 @@
|
||||
/* Cost Dashboard */
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.costs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid rgba(232, 121, 29, 0.12);
|
||||
}
|
||||
|
||||
.costs-header h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.period-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.period-tab {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||
border-radius: 20px;
|
||||
padding: 6px 14px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.period-tab:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.period-tab.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg-dark);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.costs-main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
/* Summary Cards */
|
||||
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-light);
|
||||
border: 1px solid rgba(232, 121, 29, 0.08);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.card-change {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.card-change.positive {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.card-change.negative {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
/* Charts */
|
||||
|
||||
.chart-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: var(--bg-light);
|
||||
border: 1px solid rgba(232, 121, 29, 0.08);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.chart-container h3 {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
|
||||
.tables-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: var(--bg-light);
|
||||
border: 1px solid rgba(232, 121, 29, 0.08);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table-container h3 {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
th {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid rgba(232, 121, 29, 0.15);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(232, 121, 29, 0.06);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: rgba(232, 121, 29, 0.04);
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickable:hover td {
|
||||
background: rgba(232, 121, 29, 0.08);
|
||||
}
|
||||
|
||||
/* Session Detail */
|
||||
|
||||
.session-detail {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.session-detail.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.session-detail h2 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.session-detail h2 span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.close-detail {
|
||||
background: var(--bg-light);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 16px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
margin-bottom: 16px;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.close-detail:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 10px;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
background: var(--accent);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.costs-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.chart-row,
|
||||
.tables-section,
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
+28
-219
@@ -108,11 +108,24 @@ header button {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
header button:hover {
|
||||
header button:hover, .header-link-btn:hover {
|
||||
background: #3a2e1f;
|
||||
border-color: rgba(232, 121, 29, 0.3);
|
||||
}
|
||||
|
||||
.header-link-btn {
|
||||
background: var(--bg-light);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.theme-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -322,25 +335,6 @@ section h2 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.energy-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shape-badge {
|
||||
font-size: 0.6rem;
|
||||
background: rgba(232, 121, 29, 0.25);
|
||||
color: var(--accent);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.caller-btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: #2a1e10;
|
||||
@@ -439,20 +433,19 @@ section h2 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-badge.shape {
|
||||
background: rgba(232, 121, 29, 0.2);
|
||||
color: var(--accent);
|
||||
.caller-identity {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.info-badge.energy {
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.info-badge.emotion {
|
||||
background: rgba(154, 139, 120, 0.2);
|
||||
.caller-situation {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.caller-signature {
|
||||
@@ -462,90 +455,13 @@ section h2 {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.caller-situation {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
.caller-secret-want {
|
||||
font-size: 0.75rem;
|
||||
color: #d4a030;
|
||||
font-style: italic;
|
||||
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;
|
||||
@@ -1934,110 +1850,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; }
|
||||
|
||||
+5
-56
@@ -15,8 +15,8 @@
|
||||
<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="export-session-btn">Export</button>
|
||||
<button id="preflight-btn" class="preflight-btn">Preflight</button>
|
||||
<button id="settings-btn">Settings</button>
|
||||
<a href="/costs" class="header-link-btn" title="Cost Dashboard">Costs</a>
|
||||
</div>
|
||||
<div class="theme-bar">
|
||||
<label for="show-theme-input" class="theme-label">Theme:</label>
|
||||
@@ -72,15 +72,10 @@
|
||||
</div>
|
||||
<div id="call-status" class="call-status">No active call</div>
|
||||
<div id="caller-info-panel" class="caller-info-panel hidden">
|
||||
<div class="caller-info-row">
|
||||
<span id="caller-shape-badge" class="info-badge shape"></span>
|
||||
<span id="caller-energy-badge" class="info-badge energy"></span>
|
||||
<span id="caller-emotion" class="info-badge emotion"></span>
|
||||
<span id="caller-model-badge" class="info-badge model"></span>
|
||||
<select id="caller-model-override" class="caller-model-override hidden"></select>
|
||||
</div>
|
||||
<div id="caller-signature" class="caller-signature"></div>
|
||||
<div id="caller-identity" class="caller-identity"></div>
|
||||
<div id="caller-situation" class="caller-situation"></div>
|
||||
<div id="caller-signature" class="caller-signature"></div>
|
||||
<div id="caller-secret-want" class="caller-secret-want"></div>
|
||||
<details id="caller-background-details" class="caller-background-full">
|
||||
<summary>Full Background</summary>
|
||||
<div id="caller-background"></div>
|
||||
@@ -288,36 +283,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Caller Model Routing -->
|
||||
<div class="settings-group">
|
||||
<h3>Caller Models</h3>
|
||||
<div class="caller-model-row">
|
||||
<label>
|
||||
Strategy
|
||||
<select id="cm-strategy">
|
||||
<option value="single">Single Model</option>
|
||||
<option value="cycle">Cycle Models</option>
|
||||
<option value="style_matched">Style-Matched</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="cm-pool-section" class="hidden">
|
||||
<label>
|
||||
Model Pool
|
||||
<input type="text" id="cm-pool" class="cm-pool-input" placeholder="x-ai/grok-4, deepseek/deepseek-v3.2, ...">
|
||||
</label>
|
||||
</div>
|
||||
<div id="cm-style-map" class="hidden">
|
||||
<div class="cm-style-grid" id="cm-style-grid"></div>
|
||||
</div>
|
||||
<div class="caller-model-row">
|
||||
<label>
|
||||
Fallback Model
|
||||
<select id="cm-fallback" class="model-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TTS Settings -->
|
||||
<div class="settings-group">
|
||||
<h3>TTS Provider</h3>
|
||||
@@ -358,24 +323,8 @@
|
||||
</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>
|
||||
|
||||
<script src="/js/app.js?v=27"></script>
|
||||
<script src="/js/app.js?v=28"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+38
-401
@@ -131,7 +131,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
initEventListeners();
|
||||
initClock();
|
||||
loadShowTheme();
|
||||
loadCallerModels();
|
||||
loadVoicemails();
|
||||
setInterval(loadVoicemails, 30000);
|
||||
loadEmails();
|
||||
@@ -356,27 +355,6 @@ function initEventListeners() {
|
||||
else if (e.key === 'Escape') e.target.blur();
|
||||
});
|
||||
|
||||
// Caller Models
|
||||
document.getElementById('cm-strategy')?.addEventListener('change', () => {
|
||||
callerModelSettings.strategy = document.getElementById('cm-strategy').value;
|
||||
updateCallerModelUI();
|
||||
});
|
||||
document.getElementById('caller-model-badge')?.addEventListener('click', () => {
|
||||
const sel = document.getElementById('caller-model-override');
|
||||
if (!sel || !currentCaller) return;
|
||||
sel.classList.toggle('hidden');
|
||||
if (!sel.classList.contains('hidden')) {
|
||||
const current = callerModelAssignments[currentCaller.key];
|
||||
if (current) sel.value = current;
|
||||
}
|
||||
});
|
||||
document.getElementById('caller-model-override')?.addEventListener('change', (e) => {
|
||||
if (currentCaller && e.target.value) {
|
||||
overrideCallerModel(currentCaller.key, e.target.value);
|
||||
e.target.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Settings
|
||||
document.getElementById('settings-btn')?.addEventListener('click', async () => {
|
||||
document.getElementById('settings-modal')?.classList.remove('hidden');
|
||||
@@ -392,17 +370,6 @@ function initEventListeners() {
|
||||
});
|
||||
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
|
||||
document.getElementById('wrapup-btn')?.addEventListener('click', wrapUp);
|
||||
|
||||
@@ -637,21 +604,7 @@ async function loadCallers() {
|
||||
btn.dataset.key = caller.key;
|
||||
|
||||
let html = '';
|
||||
if (caller.energy_level) {
|
||||
const energyColors = { low: '#4a7ab5', medium: '#5a8a3c', high: '#e8791d', very_high: '#cc2222' };
|
||||
const color = energyColors[caller.energy_level] || '#9a8b78';
|
||||
html += `<span class="energy-dot" style="background:${color}" title="${caller.energy_level} energy"></span>`;
|
||||
}
|
||||
html += caller.returning ? `<span class="caller-name">\u2605 ${caller.name}</span>` : `<span class="caller-name">${caller.name}</span>`;
|
||||
if (caller.call_shape && caller.call_shape !== 'standard') {
|
||||
const shapeLabels = {
|
||||
escalating_reveal: 'ER', am_i_the_asshole: 'AITA', confrontation: 'VS',
|
||||
celebration: '\u{1F389}', quick_hit: 'QH', bait_and_switch: 'B&S',
|
||||
the_hangup: 'HU', reactive: 'RE'
|
||||
};
|
||||
const label = shapeLabels[caller.call_shape] || caller.call_shape.substring(0, 2).toUpperCase();
|
||||
html += `<span class="shape-badge" title="${caller.call_shape.replace(/_/g, ' ')}">${label}</span>`;
|
||||
}
|
||||
// Shortcut label: 1-9 for first 9, 0 for 10th
|
||||
if (idx < 10) {
|
||||
const shortcutKey = idx === 9 ? '0' : String(idx + 1);
|
||||
@@ -669,7 +622,6 @@ async function loadCallers() {
|
||||
}
|
||||
|
||||
console.log('Loaded', data.callers.length, 'callers, session:', data.session_id);
|
||||
updateCallerModelBadges();
|
||||
} catch (err) {
|
||||
console.error('loadCallers error:', err);
|
||||
}
|
||||
@@ -742,27 +694,20 @@ async function startCall(key, name) {
|
||||
if (aiInfo) aiInfo.classList.remove('hidden');
|
||||
if (aiName) aiName.textContent = name;
|
||||
|
||||
// Show caller info panel with structured data
|
||||
// Show caller info panel with slim background data
|
||||
const infoPanel = document.getElementById('caller-info-panel');
|
||||
if (infoPanel && data.caller_info) {
|
||||
const ci = data.caller_info;
|
||||
const energyColors = { low: '#4a7ab5', medium: '#5a8a3c', high: '#e8791d', very_high: '#cc2222' };
|
||||
const shapeBadge = document.getElementById('caller-shape-badge');
|
||||
const energyBadge = document.getElementById('caller-energy-badge');
|
||||
const emotionBadge = document.getElementById('caller-emotion');
|
||||
const signature = document.getElementById('caller-signature');
|
||||
const identity = document.getElementById('caller-identity');
|
||||
const situation = document.getElementById('caller-situation');
|
||||
if (shapeBadge) shapeBadge.textContent = (ci.call_shape || 'standard').replace(/_/g, ' ');
|
||||
if (energyBadge) { energyBadge.textContent = (ci.energy_level || '').replace('_', ' '); energyBadge.style.background = energyColors[ci.energy_level] || '#9a8b78'; }
|
||||
if (emotionBadge) emotionBadge.textContent = ci.emotional_state || '';
|
||||
if (signature) signature.textContent = ci.signature_detail ? `"${ci.signature_detail}"` : '';
|
||||
if (situation) situation.textContent = ci.situation_summary || '';
|
||||
const signature = document.getElementById('caller-signature');
|
||||
const secretWant = document.getElementById('caller-secret-want');
|
||||
if (identity) identity.textContent = ci.identity || '';
|
||||
if (situation) situation.textContent = ci.situation || '';
|
||||
if (signature) signature.textContent = ci.signature ? `"${ci.signature}"` : '';
|
||||
if (secretWant) secretWant.textContent = ci.secret_want ? `secretly wants: ${ci.secret_want}` : '';
|
||||
infoPanel.classList.remove('hidden');
|
||||
}
|
||||
try {
|
||||
showCallerModelBadge(callerModelAssignments[key] || data.model);
|
||||
} catch(e) { console.error('[startCall] showCallerModelBadge error:', e); }
|
||||
document.getElementById('caller-model-override')?.classList.add('hidden');
|
||||
const bgEl = document.getElementById('caller-background');
|
||||
if (bgEl && data.background) bgEl.textContent = data.background;
|
||||
|
||||
@@ -799,6 +744,14 @@ async function newSession() {
|
||||
await hangup();
|
||||
}
|
||||
|
||||
const btn = document.getElementById('new-session-btn');
|
||||
const originalText = btn?.textContent;
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Generating...';
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch('/api/session/reset', { method: 'POST' });
|
||||
conversationSince = 0;
|
||||
|
||||
@@ -809,9 +762,14 @@ async function newSession() {
|
||||
// Reload callers to get new session ID
|
||||
await loadCallers();
|
||||
await loadShowTheme();
|
||||
await loadCallerModels();
|
||||
|
||||
log('New session started - all callers have fresh backgrounds');
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText || 'New Session';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -848,8 +806,6 @@ async function hangup() {
|
||||
document.getElementById('caller-info-panel')?.classList.add('hidden');
|
||||
const bgDetails2 = document.getElementById('caller-background-details');
|
||||
if (bgDetails2) bgDetails2.classList.add('hidden');
|
||||
showCallerModelBadge(null);
|
||||
document.getElementById('caller-model-override')?.classList.add('hidden');
|
||||
|
||||
// Hide AI caller indicator
|
||||
document.getElementById('ai-caller-info')?.classList.add('hidden');
|
||||
@@ -1400,8 +1356,14 @@ async function loadShowTheme() {
|
||||
|
||||
async function setShowTheme() {
|
||||
const input = document.getElementById('show-theme-input');
|
||||
const setBtn = document.getElementById('set-theme-btn');
|
||||
const theme = input.value.trim();
|
||||
if (!theme) return;
|
||||
const originalText = setBtn?.textContent;
|
||||
if (setBtn) {
|
||||
setBtn.disabled = true;
|
||||
setBtn.textContent = 'Regenerating...';
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/show-theme', {
|
||||
method: 'POST',
|
||||
@@ -1411,11 +1373,19 @@ async function setShowTheme() {
|
||||
const data = await res.json();
|
||||
if (data.theme) {
|
||||
input.classList.add('active');
|
||||
document.getElementById('set-theme-btn').classList.add('hidden');
|
||||
setBtn?.classList.add('hidden');
|
||||
document.getElementById('clear-theme-btn').classList.remove('hidden');
|
||||
}
|
||||
// Backend regenerated backgrounds for unused callers — refresh the
|
||||
// button list so we show the new names, not the pre-theme cache.
|
||||
await loadCallers();
|
||||
} catch (e) {
|
||||
console.error('Failed to set show theme:', e);
|
||||
} finally {
|
||||
if (setBtn) {
|
||||
setBtn.disabled = false;
|
||||
setBtn.textContent = originalText || 'Set';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1431,194 +1401,13 @@ async function clearShowTheme() {
|
||||
input.classList.remove('active');
|
||||
document.getElementById('set-theme-btn').classList.remove('hidden');
|
||||
document.getElementById('clear-theme-btn').classList.add('hidden');
|
||||
await loadCallers();
|
||||
} catch (e) {
|
||||
console.error('Failed to clear show theme:', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Caller Model Routing ---
|
||||
const MODEL_ABBREVS = {
|
||||
'claude-sonnet-4-5': 'Son', 'claude-haiku-4.5': 'Hai', 'claude-3-haiku': 'H3',
|
||||
'grok-4': 'Grk', 'grok-4-fast': 'GrF',
|
||||
'minimax-m2-her': 'MnM', 'mistral-small-creative': 'Mis',
|
||||
'deepseek-v3.2': 'DSk', 'gemini-2.5-flash': 'Gem', 'gemini-flash-1.5': 'Gm1',
|
||||
'gpt-4o-mini': '4oM', 'gpt-4o': '4o', 'llama-3.1-8b-instruct': 'Lla',
|
||||
};
|
||||
|
||||
const CALLER_STYLES = [
|
||||
'quiet_nervous', 'storyteller', 'deadpan', 'high_energy', 'confrontational',
|
||||
'oversharer', 'philosopher', 'bragger', 'first_time', 'emotional',
|
||||
'world_weary', 'conspiracy', 'comedian', 'angry_venting', 'sweet_earnest',
|
||||
'mysterious', 'know_it_all', 'rambling',
|
||||
];
|
||||
|
||||
let callerModelSettings = { strategy: 'single', pool: [], fallback: '', style_map: {} };
|
||||
let callerModelAssignments = {}; // key -> model_id
|
||||
|
||||
function modelAbbrev(modelId) {
|
||||
const name = (modelId || '').split('/').pop();
|
||||
return MODEL_ABBREVS[name] || name.substring(0, 3).toUpperCase();
|
||||
}
|
||||
|
||||
async function loadCallerModels() {
|
||||
try {
|
||||
const res = await fetch('/api/caller-models');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
callerModelSettings = {
|
||||
strategy: data.strategy || 'single',
|
||||
pool: data.pool || [],
|
||||
fallback: data.fallback || '',
|
||||
style_map: data.map || data.style_map || {},
|
||||
};
|
||||
callerModelAssignments = data.assignments || {};
|
||||
updateCallerModelUI();
|
||||
updateCallerModelBadges();
|
||||
} catch (e) {
|
||||
console.error('Failed to load caller models:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function updateCallerModelUI() {
|
||||
const strategyEl = document.getElementById('cm-strategy');
|
||||
if (strategyEl) strategyEl.value = callerModelSettings.strategy;
|
||||
|
||||
const poolSection = document.getElementById('cm-pool-section');
|
||||
const styleMap = document.getElementById('cm-style-map');
|
||||
if (poolSection) poolSection.classList.toggle('hidden', callerModelSettings.strategy === 'single');
|
||||
if (styleMap) styleMap.classList.toggle('hidden', callerModelSettings.strategy !== 'style_matched');
|
||||
|
||||
const poolInput = document.getElementById('cm-pool');
|
||||
if (poolInput) poolInput.value = callerModelSettings.pool.join(', ');
|
||||
|
||||
// Populate style map grid
|
||||
const grid = document.getElementById('cm-style-grid');
|
||||
if (grid && callerModelSettings.strategy === 'style_matched') {
|
||||
grid.innerHTML = '';
|
||||
for (const style of CALLER_STYLES) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'cm-style-item';
|
||||
const label = style.replace(/_/g, ' ');
|
||||
item.innerHTML = `<span class="cm-style-name">${label}</span>`;
|
||||
const sel = document.createElement('select');
|
||||
sel.className = 'cm-style-select';
|
||||
sel.dataset.style = style;
|
||||
const models = window._openrouterModels || callerModelSettings.pool;
|
||||
for (const m of models) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = m.split('/').pop();
|
||||
if (m === callerModelSettings.style_map[style]) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
item.appendChild(sel);
|
||||
grid.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback dropdown
|
||||
const fallbackEl = document.getElementById('cm-fallback');
|
||||
if (fallbackEl) {
|
||||
const currentVal = fallbackEl.value;
|
||||
fallbackEl.innerHTML = '';
|
||||
const models = callerModelSettings.pool.length > 0
|
||||
? callerModelSettings.pool
|
||||
: (window._openrouterModels || []);
|
||||
for (const m of models) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = m.split('/').pop();
|
||||
if (m === callerModelSettings.fallback) opt.selected = true;
|
||||
fallbackEl.appendChild(opt);
|
||||
}
|
||||
if (!fallbackEl.value && currentVal) fallbackEl.value = currentVal;
|
||||
}
|
||||
}
|
||||
|
||||
function updateCallerModelBadges() {
|
||||
document.querySelectorAll('.caller-btn').forEach(btn => {
|
||||
const key = btn.dataset.key;
|
||||
const model = callerModelAssignments[key];
|
||||
let tag = btn.querySelector('.model-tag');
|
||||
if (model) {
|
||||
if (!tag) {
|
||||
tag = document.createElement('span');
|
||||
tag.className = 'model-tag';
|
||||
btn.appendChild(tag);
|
||||
}
|
||||
tag.textContent = modelAbbrev(model);
|
||||
tag.title = model;
|
||||
} else if (tag) {
|
||||
tag.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showCallerModelBadge(model) {
|
||||
const badge = document.getElementById('caller-model-badge');
|
||||
if (badge) {
|
||||
badge.textContent = model ? `via ${modelAbbrev(model)}` : '';
|
||||
badge.title = model || '';
|
||||
badge.classList.toggle('hidden', !model);
|
||||
}
|
||||
}
|
||||
|
||||
function populateCallerModelOverride() {
|
||||
const sel = document.getElementById('caller-model-override');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '';
|
||||
const models = window._openrouterModels || [];
|
||||
for (const m of models) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = m.split('/').pop();
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function overrideCallerModel(callerKey, modelId) {
|
||||
try {
|
||||
const res = await fetch(`/api/caller-models/${callerKey}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: modelId })
|
||||
});
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
callerModelAssignments[callerKey] = modelId;
|
||||
showCallerModelBadge(modelId);
|
||||
updateCallerModelBadges();
|
||||
log(`Model override: ${currentCaller?.name || callerKey} → ${modelAbbrev(modelId)}`);
|
||||
} catch (err) {
|
||||
log('Model override failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCallerModels() {
|
||||
const strategy = document.getElementById('cm-strategy')?.value || 'single';
|
||||
const poolRaw = document.getElementById('cm-pool')?.value || '';
|
||||
const pool = poolRaw.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const fallback = document.getElementById('cm-fallback')?.value || '';
|
||||
|
||||
const style_map = {};
|
||||
document.querySelectorAll('.cm-style-select').forEach(sel => {
|
||||
if (sel.value) style_map[sel.dataset.style] = sel.value;
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/caller-models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ strategy, pool, fallback, map: style_map })
|
||||
});
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
callerModelSettings = { strategy, pool, fallback, style_map };
|
||||
} catch (err) {
|
||||
log('Caller model save failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Settings ---
|
||||
async function loadSettings() {
|
||||
try {
|
||||
@@ -1675,7 +1464,6 @@ async function loadSettings() {
|
||||
// Category model routing
|
||||
const models = data.available_openrouter_models || [];
|
||||
window._openrouterModels = models;
|
||||
populateCallerModelOverride();
|
||||
const categoryModels = data.category_models || {};
|
||||
const categories = ['caller_dialog', 'devon_monitor', 'devon_ask', 'background_gen', 'call_summary', 'news_summary'];
|
||||
for (const cat of categories) {
|
||||
@@ -1709,9 +1497,6 @@ async function saveSettings() {
|
||||
// Save audio devices
|
||||
await saveAudioDevices();
|
||||
|
||||
// Save caller model routing
|
||||
await saveCallerModels();
|
||||
|
||||
// Collect category model routing
|
||||
const categoryModels = {};
|
||||
const categories = ['caller_dialog', 'devon_monitor', 'devon_ask', 'background_gen', 'call_summary', 'news_summary'];
|
||||
@@ -2431,151 +2216,3 @@ async function dismissDevonSuggestion() {
|
||||
}
|
||||
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
let currentPeriod = 'all';
|
||||
let charts = {};
|
||||
|
||||
const COLORS = {
|
||||
accent: '#e8791d',
|
||||
devon: '#c4944a',
|
||||
green: '#5a8a3c',
|
||||
red: '#cc2222',
|
||||
blue: '#4a8ac4',
|
||||
purple: '#8a5ac4',
|
||||
tan: '#c4845a',
|
||||
teal: '#3c8a7a',
|
||||
pink: '#c45a8a',
|
||||
slate: '#6a7a8a',
|
||||
};
|
||||
const COLOR_LIST = Object.values(COLORS);
|
||||
|
||||
Chart.defaults.color = '#9a8b78';
|
||||
Chart.defaults.borderColor = 'rgba(245, 240, 229, 0.08)';
|
||||
|
||||
// --- Utilities ---
|
||||
|
||||
function formatCost(n) {
|
||||
if (n == null) return '--';
|
||||
return n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '--';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
|
||||
', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function shortenModel(name) {
|
||||
if (!name) return '--';
|
||||
const i = name.indexOf('/');
|
||||
return i >= 0 ? name.slice(i + 1) : name;
|
||||
}
|
||||
|
||||
function destroyChart(key) {
|
||||
if (charts[key]) {
|
||||
charts[key].destroy();
|
||||
charts[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Data Loading ---
|
||||
|
||||
async function loadDashboard() {
|
||||
const p = currentPeriod;
|
||||
try {
|
||||
const [summary, timeline, models, categories, sessions, expensive, tts] = await Promise.all([
|
||||
fetch(`/api/costs/summary?period=${p}`).then(r => r.json()),
|
||||
fetch(`/api/costs/timeline?period=${p}&group_by=session`).then(r => r.json()),
|
||||
fetch(`/api/costs/models?period=${p}`).then(r => r.json()),
|
||||
fetch(`/api/costs/categories?period=${p}`).then(r => r.json()),
|
||||
fetch(`/api/costs/sessions?period=${p}`).then(r => r.json()),
|
||||
fetch(`/api/costs/expensive?period=${p}&limit=10`).then(r => r.json()),
|
||||
fetch(`/api/costs/tts?period=${p}`).then(r => r.json()),
|
||||
]);
|
||||
renderSummary(summary);
|
||||
renderTimeline(timeline);
|
||||
renderModels(models);
|
||||
renderCategories(categories);
|
||||
renderSessionBars(timeline);
|
||||
renderTtsTable(tts);
|
||||
renderExpensiveTable(expensive);
|
||||
renderSessionsTable(sessions);
|
||||
} catch (e) {
|
||||
console.error('Dashboard load error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render Functions ---
|
||||
|
||||
function renderSummary(data) {
|
||||
document.getElementById('total-spend').textContent = formatCost(data.total_cost);
|
||||
document.getElementById('llm-tts-split').textContent =
|
||||
`${formatCost(data.llm_cost)} / ${formatCost(data.tts_cost)}`;
|
||||
document.getElementById('session-count').textContent = data.sessions;
|
||||
document.getElementById('avg-cost').textContent = formatCost(data.avg_cost_per_session);
|
||||
|
||||
const changeEl = document.getElementById('total-change');
|
||||
if (data.cost_change_pct != null) {
|
||||
const up = data.cost_change_pct > 0;
|
||||
changeEl.textContent = `${up ? '\u2191' : '\u2193'} ${Math.abs(data.cost_change_pct)}% vs prev period`;
|
||||
changeEl.className = 'card-change ' + (up ? 'positive' : 'negative');
|
||||
} else {
|
||||
changeEl.textContent = '';
|
||||
changeEl.className = 'card-change';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTimeline(data) {
|
||||
destroyChart('timeline');
|
||||
const ctx = document.getElementById('timeline-chart');
|
||||
const labels = data.map(d => d.date || formatDate(d.started_at));
|
||||
charts.timeline = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'LLM',
|
||||
data: data.map(d => d.llm_cost),
|
||||
borderColor: COLORS.accent,
|
||||
backgroundColor: COLORS.accent + '22',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
label: 'TTS',
|
||||
data: data.map(d => d.tts_cost),
|
||||
borderColor: COLORS.devon,
|
||||
backgroundColor: COLORS.devon + '22',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderModels(data) {
|
||||
destroyChart('models');
|
||||
const ctx = document.getElementById('model-chart');
|
||||
charts.models = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: data.map(d => shortenModel(d.model)),
|
||||
datasets: [{
|
||||
data: data.map(d => d.cost),
|
||||
backgroundColor: data.map((_, i) => COLOR_LIST[i % COLOR_LIST.length]),
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { boxWidth: 12, padding: 8, font: { size: 11 } } },
|
||||
tooltip: { callbacks: { label: ctx => `${ctx.label}: ${formatCost(ctx.parsed)}` } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderCategories(data) {
|
||||
destroyChart('categories');
|
||||
const ctx = document.getElementById('category-chart');
|
||||
charts.categories = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.map(d => d.category),
|
||||
datasets: [{
|
||||
data: data.map(d => d.cost),
|
||||
backgroundColor: COLORS.accent + 'cc',
|
||||
borderRadius: 4,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderSessionBars(data) {
|
||||
destroyChart('sessionBars');
|
||||
const ctx = document.getElementById('session-chart');
|
||||
const costs = data.map(d => d.total_cost);
|
||||
const avg = costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : 0;
|
||||
charts.sessionBars = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.map(d => d.date || formatDate(d.started_at)),
|
||||
datasets: [{
|
||||
data: costs,
|
||||
backgroundColor: costs.map(c => c > avg ? COLORS.red + 'cc' : COLORS.green + 'cc'),
|
||||
borderRadius: 4,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderTtsTable(data) {
|
||||
const tbody = document.querySelector('#tts-table tbody');
|
||||
tbody.innerHTML = data.map(d => `
|
||||
<tr>
|
||||
<td>${d.provider}</td>
|
||||
<td>${d.calls.toLocaleString()}</td>
|
||||
<td>${d.chars.toLocaleString()}</td>
|
||||
<td>${formatCost(d.cost)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderExpensiveTable(data) {
|
||||
const tbody = document.querySelector('#expensive-table tbody');
|
||||
tbody.innerHTML = data.map(d => `
|
||||
<tr>
|
||||
<td>${shortenModel(d.model)}</td>
|
||||
<td>${d.category}</td>
|
||||
<td>${d.caller_name || '\u2014'}</td>
|
||||
<td>${(d.prompt_tokens + d.completion_tokens).toLocaleString()}</td>
|
||||
<td>${formatCost(d.cost)}</td>
|
||||
<td>${d.latency_ms.toFixed(0)}ms</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderSessionsTable(data) {
|
||||
const tbody = document.querySelector('#sessions-table tbody');
|
||||
tbody.innerHTML = data.map(d => `
|
||||
<tr class="clickable" data-session="${d.session_id}">
|
||||
<td>${formatDate(d.started_at)}</td>
|
||||
<td>${formatCost(d.llm_cost)}</td>
|
||||
<td>${formatCost(d.tts_cost)}</td>
|
||||
<td>${formatCost(d.total_cost)}</td>
|
||||
<td>${d.total_llm_calls}</td>
|
||||
<td><button class="view-btn" data-session="${d.session_id}">View</button></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
tbody.querySelectorAll('.view-btn').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
showSessionDetail(btn.dataset.session);
|
||||
});
|
||||
});
|
||||
tbody.querySelectorAll('.clickable').forEach(row => {
|
||||
row.addEventListener('click', () => showSessionDetail(row.dataset.session));
|
||||
});
|
||||
}
|
||||
|
||||
// --- Session Detail ---
|
||||
|
||||
async function showSessionDetail(sessionId) {
|
||||
try {
|
||||
const detail = await fetch(`/api/costs/session/${sessionId}`).then(r => r.json());
|
||||
|
||||
document.getElementById('detail-session-id').textContent = sessionId;
|
||||
|
||||
populateDetailTable('#detail-caller-table tbody', detail.by_caller,
|
||||
d => `<td>${d.caller_name}</td><td>${d.calls}</td><td>${formatCost(d.cost)}</td>`);
|
||||
|
||||
populateDetailTable('#detail-category-table tbody', detail.by_category,
|
||||
d => `<td>${d.category}</td><td>${d.calls}</td><td>${formatCost(d.cost)}</td>`);
|
||||
|
||||
populateDetailTable('#detail-model-table tbody', detail.by_model,
|
||||
d => `<td>${shortenModel(d.model)}</td><td>${d.calls}</td><td>${formatCost(d.cost)}</td>`);
|
||||
|
||||
populateDetailTable('#detail-expensive-table tbody', detail.expensive_calls,
|
||||
d => `<td>${d.category}</td><td>${shortenModel(d.model)}</td><td>${d.caller_name || '\u2014'}</td>` +
|
||||
`<td>${formatCost(d.cost)}</td><td>${(d.prompt_tokens + d.completion_tokens).toLocaleString()}</td>` +
|
||||
`<td>${d.latency_ms.toFixed(0)}ms</td>`);
|
||||
|
||||
document.querySelectorAll('.costs-main > section:not(.session-detail)').forEach(
|
||||
s => s.style.display = 'none');
|
||||
document.getElementById('session-detail').classList.remove('hidden');
|
||||
} catch (e) {
|
||||
console.error('Detail load error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function populateDetailTable(selector, data, rowFn) {
|
||||
const tbody = document.querySelector(selector);
|
||||
tbody.innerHTML = data.map(d => `<tr>${rowFn(d)}</tr>`).join('');
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
document.getElementById('session-detail').classList.add('hidden');
|
||||
document.querySelectorAll('.costs-main > section:not(.session-detail)').forEach(
|
||||
s => s.style.display = '');
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.period-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelector('.period-tab.active').classList.remove('active');
|
||||
tab.classList.add('active');
|
||||
currentPeriod = tab.dataset.period;
|
||||
loadDashboard();
|
||||
});
|
||||
});
|
||||
document.getElementById('close-detail').addEventListener('click', closeDetail);
|
||||
loadDashboard();
|
||||
});
|
||||
+2
-2
@@ -1249,10 +1249,10 @@ def generate_clip_video_remotion(
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(REMOTION_DIR), timeout=180)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(REMOTION_DIR), timeout=600)
|
||||
except subprocess.TimeoutExpired:
|
||||
props_path.unlink(missing_ok=True)
|
||||
print(f" Remotion render timed out (180s)")
|
||||
print(f" Remotion render timed out (600s)")
|
||||
return False
|
||||
props_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
+201
-5
@@ -204,12 +204,12 @@ TRANSCRIPT:
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": "anthropic/claude-3.5-sonnet",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"messages": [{"role": "user", "content": full_prompt}],
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0
|
||||
},
|
||||
timeout=120
|
||||
timeout=300
|
||||
)
|
||||
except requests.exceptions.Timeout:
|
||||
print(f" Warning: Speaker labeling timed out for chunk {i+1}, using raw text")
|
||||
@@ -321,7 +321,7 @@ Respond with ONLY valid JSON, no markdown or explanation."""
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": "anthropic/claude-3.5-haiku",
|
||||
"model": "anthropic/claude-haiku-4.5",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.7
|
||||
},
|
||||
@@ -571,6 +571,107 @@ def save_chapters(metadata: dict, output_path: str):
|
||||
print(f" Chapters saved to: {output_path}")
|
||||
|
||||
|
||||
def save_metadata(metadata: dict, output_path: str):
|
||||
"""Persist generated metadata so a --resume run can recover it losslessly.
|
||||
|
||||
The slug is not a safe source to rebuild a title from: it is lowercased and
|
||||
stripped of punctuation, so apostrophes and commas cannot be recovered.
|
||||
"""
|
||||
keep = {k: metadata.get(k) for k in
|
||||
("title", "description", "thumbnail_text", "chapters")}
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(keep, f, indent=2)
|
||||
|
||||
print(f" Metadata saved to: {output_path}")
|
||||
|
||||
|
||||
def _decode_db_row(output: str) -> dict | None:
|
||||
"""Decode a TO_BASE64 payload from mysql batch output.
|
||||
|
||||
TO_BASE64 wraps every 76 characters and mysql renders those breaks as a
|
||||
literal backslash-n, so the raw output is not directly decodable.
|
||||
"""
|
||||
cleaned = re.sub(r"\\n|\s", "", output or "")
|
||||
if not cleaned:
|
||||
return None
|
||||
try:
|
||||
return json.loads(base64.b64decode(cleaned).decode())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_episode_metadata_from_db(episode_number: int) -> dict | None:
|
||||
"""Read back the title/description Castopod already stored for this episode.
|
||||
|
||||
Base64 keeps the round trip safe — descriptions contain newlines and quotes
|
||||
that mysql's batch output would otherwise escape.
|
||||
"""
|
||||
cmd = (f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql --defaults-extra-file=/tmp/.my.cnf '
|
||||
f'-u {DB_USER} {DB_NAME} -N -e '
|
||||
f'''"SELECT TO_BASE64(JSON_OBJECT('title', title, 'description', description_markdown)) '''
|
||||
f'FROM cp_episodes WHERE number = {episode_number} LIMIT 1;"')
|
||||
success, output = run_ssh_command(cmd)
|
||||
if not success:
|
||||
return None
|
||||
row = _decode_db_row(output)
|
||||
return row if row and row.get("title") else None
|
||||
|
||||
|
||||
def recover_metadata(episode_number: int, slug: str, metadata_path, chapters: list,
|
||||
db_lookup=None) -> dict:
|
||||
"""Rebuild metadata for a --resume run, preferring lossless sources.
|
||||
|
||||
1. the metadata file written by the original run
|
||||
2. the title/description Castopod already stored
|
||||
3. the slug — which cannot round-trip punctuation or capitalization
|
||||
"""
|
||||
metadata_path = Path(metadata_path)
|
||||
title = description = thumbnail_text = None
|
||||
reconstructed = False
|
||||
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
saved = json.loads(metadata_path.read_text())
|
||||
title = saved.get("title")
|
||||
description = saved.get("description")
|
||||
thumbnail_text = saved.get("thumbnail_text")
|
||||
if title:
|
||||
print(f" Recovered metadata from {metadata_path.name}")
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f" Warning: could not read {metadata_path.name}: {e}")
|
||||
|
||||
if not title:
|
||||
lookup = db_lookup or _fetch_episode_metadata_from_db
|
||||
row = lookup(episode_number)
|
||||
if row and row.get("title"):
|
||||
title = row["title"]
|
||||
description = description or row.get("description")
|
||||
print(" Recovered title/description from Castopod")
|
||||
|
||||
if not title:
|
||||
title_part = re.sub(rf"^episode-{episode_number}-", "", slug).replace("-", " ").title()
|
||||
title = f"Episode {episode_number}: {title_part}"
|
||||
reconstructed = True
|
||||
print(" WARNING: rebuilding the title from the slug is lossy — "
|
||||
"apostrophes, commas and capitalization cannot be recovered.")
|
||||
print(f' WARNING: got "{title}" — pass --title to override.')
|
||||
|
||||
if not description:
|
||||
description = f"Episode {episode_number} of Luke at the Roost."
|
||||
if not thumbnail_text:
|
||||
thumbnail_text = re.sub(rf"^Episode {episode_number}:\s*", "", title).upper()[:30]
|
||||
|
||||
meta = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"chapters": chapters,
|
||||
"thumbnail_text": thumbnail_text,
|
||||
}
|
||||
if reconstructed:
|
||||
meta["title_is_reconstructed"] = True
|
||||
return meta
|
||||
|
||||
|
||||
def run_ssh_command(command: str, timeout: int = 30) -> tuple[bool, str]:
|
||||
"""Run a command on the NAS via SSH."""
|
||||
ssh_cmd = [
|
||||
@@ -1272,8 +1373,24 @@ def _check_youtube_duplicate(youtube, title: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# YouTube rejects the entire upload with `invalidTags` if the combined tag
|
||||
# string exceeds 500 chars. Tags containing a space are quoted by YouTube and
|
||||
# those quotes count, so budget conservatively and leave a small margin.
|
||||
YOUTUBE_TAG_BUDGET = 480
|
||||
YOUTUBE_MAX_TAGS = 25
|
||||
|
||||
|
||||
def _youtube_tag_cost(tag: str) -> int:
|
||||
return len(tag) + (2 if " " in tag else 0)
|
||||
|
||||
|
||||
def _extract_youtube_tags(metadata: dict) -> list[str]:
|
||||
"""Extract dynamic tags from episode metadata for YouTube SEO."""
|
||||
"""Extract dynamic tags from episode metadata for YouTube SEO.
|
||||
|
||||
Base tags come first so they survive trimming; chapter titles fill whatever
|
||||
budget is left. Anything that would bust the budget is skipped rather than
|
||||
truncated, so no tag is ever emitted half-formed.
|
||||
"""
|
||||
base_tags = ["podcast", "Luke at the Roost", "talk radio", "call-in show",
|
||||
"talk show", "comedy", "AI podcast", "late night radio", "advice"]
|
||||
skip = {"intro", "outro", "opening", "closing", "wrap up", "wrap-up"}
|
||||
@@ -1284,7 +1401,21 @@ def _extract_youtube_tags(metadata: dict) -> list[str]:
|
||||
continue
|
||||
if len(title) <= 50:
|
||||
dynamic.append(title)
|
||||
return (base_tags + dynamic)[:25]
|
||||
|
||||
tags: list[str] = []
|
||||
used = 0
|
||||
for tag in base_tags + dynamic:
|
||||
tag = tag.replace("<", "").replace(">", "").strip()
|
||||
if len(tag) < 3 or tag in tags:
|
||||
continue
|
||||
cost = _youtube_tag_cost(tag) + (1 if tags else 0) # +1 for the comma
|
||||
if used + cost > YOUTUBE_TAG_BUDGET:
|
||||
continue
|
||||
tags.append(tag)
|
||||
used += cost
|
||||
if len(tags) >= YOUTUBE_MAX_TAGS:
|
||||
break
|
||||
return tags
|
||||
|
||||
|
||||
def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
|
||||
@@ -1311,6 +1442,10 @@ def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
|
||||
video_path = Path(audio_path).with_suffix(".yt.mp4")
|
||||
|
||||
# Convert MP3 + cover art to MP4 (pad to 1920x1080 for YouTube compatibility)
|
||||
# Skip if video already exists and is non-trivial size (>1MB)
|
||||
if video_path.exists() and video_path.stat().st_size > 1_000_000:
|
||||
print(f" Using existing video: {video_path} ({video_path.stat().st_size / 1_000_000:.0f}MB)")
|
||||
else:
|
||||
print(" Converting audio to video...")
|
||||
result = subprocess.run([
|
||||
"ffmpeg", "-y", "-loop", "1",
|
||||
@@ -1446,6 +1581,7 @@ def main():
|
||||
parser.add_argument("--title", "-t", help="Override generated title")
|
||||
parser.add_argument("--description", help="Override generated description")
|
||||
parser.add_argument("--session-data", "-s", help="Path to session export JSON (from /api/session/export)")
|
||||
parser.add_argument("--resume", action="store_true", help="Resume a failed publish — skip transcription/Castopod, continue from CDN/YouTube/social")
|
||||
args = parser.parse_args()
|
||||
|
||||
audio_path = Path(args.audio_file).expanduser().resolve()
|
||||
@@ -1493,6 +1629,62 @@ def main():
|
||||
episode_number = get_next_episode_number()
|
||||
print(f"Episode number: {episode_number}")
|
||||
|
||||
# --- Resume path: skip transcription + Castopod, pick up from CDN/YouTube/social ---
|
||||
if args.resume:
|
||||
castopod_step = _get_step_details(episode_number, "castopod")
|
||||
if not castopod_step:
|
||||
print(f"Error: No Castopod data in publish_state.json for episode {episode_number}. Nothing to resume.")
|
||||
_cleanup_mysql_auth()
|
||||
lock_fp.close()
|
||||
LOCK_FILE.unlink(missing_ok=True)
|
||||
sys.exit(1)
|
||||
episode = {"id": castopod_step["episode_id"], "slug": castopod_step["slug"]}
|
||||
print(f"Resuming episode {episode_number}: id={episode['id']}, slug={episode['slug']}")
|
||||
|
||||
# Load existing metadata from files saved during first run
|
||||
transcript_path = audio_path.with_suffix(".transcript.txt")
|
||||
chapters_path = audio_path.with_suffix(".chapters.json")
|
||||
metadata_path = audio_path.with_suffix(".metadata.json")
|
||||
srt_path = audio_path.with_suffix(".srt")
|
||||
|
||||
# Build metadata from existing files; re-transcribe + gen only if missing
|
||||
if chapters_path.exists():
|
||||
print(" Loading existing chapters and metadata files")
|
||||
with open(chapters_path) as f:
|
||||
chapters_data = json.load(f)
|
||||
chapters = (chapters_data if isinstance(chapters_data, list)
|
||||
else chapters_data.get("chapters", []))
|
||||
metadata = recover_metadata(episode_number, episode["slug"],
|
||||
metadata_path, chapters)
|
||||
else:
|
||||
print("[1/5] Re-transcribing audio for metadata...")
|
||||
transcript = transcribe_audio(str(audio_path))
|
||||
metadata = generate_metadata(transcript, episode_number)
|
||||
save_chapters(metadata, str(chapters_path))
|
||||
save_metadata(metadata, str(metadata_path))
|
||||
if not transcript_path.exists():
|
||||
labeled_text = label_transcript_speakers(transcript["full_text"])
|
||||
with open(transcript_path, "w") as f:
|
||||
f.write(labeled_text)
|
||||
if not srt_path.exists():
|
||||
generate_srt(transcript["segments"], str(srt_path))
|
||||
|
||||
if args.title:
|
||||
metadata["title"] = args.title
|
||||
if args.description:
|
||||
metadata["description"] = args.description
|
||||
|
||||
srt_path = audio_path.with_suffix(".srt")
|
||||
direct_upload = os.path.getsize(str(audio_path)) > CLOUDFLARE_UPLOAD_LIMIT
|
||||
chapters_uploaded = True
|
||||
transcript_uploaded = True
|
||||
yt_video_id = None
|
||||
# Jump to CDN upload (step 3.7)
|
||||
# (fall through to the common CDN/publish/YouTube/social code below)
|
||||
|
||||
else:
|
||||
# --- Normal (non-resume) path ---
|
||||
|
||||
# Guard against duplicate publish
|
||||
if not args.dry_run:
|
||||
exists = _check_episode_exists_in_db(episode_number)
|
||||
@@ -1543,6 +1735,10 @@ def main():
|
||||
chapters_path = audio_path.with_suffix(".chapters.json")
|
||||
save_chapters(metadata, str(chapters_path))
|
||||
|
||||
# Save metadata so --resume recovers the real title, not a slug rebuild
|
||||
metadata_path = audio_path.with_suffix(".metadata.json")
|
||||
save_metadata(metadata, str(metadata_path))
|
||||
|
||||
# Save transcript text file with LUKE:/CALLER: speaker labels
|
||||
transcript_path = audio_path.with_suffix(".transcript.txt")
|
||||
raw_text = transcript["full_text"]
|
||||
|
||||
@@ -24,7 +24,8 @@ local last_state = ""
|
||||
local transport_active = false
|
||||
|
||||
local function log(msg)
|
||||
reaper.ShowConsoleMsg("[Regions] " .. msg .. "\n")
|
||||
-- Silent by default — uncomment for debugging:
|
||||
-- reaper.ShowConsoleMsg("[Regions] " .. msg .. "\n")
|
||||
end
|
||||
|
||||
local function is_playing_or_recording()
|
||||
|
||||
@@ -30,7 +30,8 @@ local BLOCK_SAMPLES = math.floor(SAMPLE_RATE * BLOCK_SEC)
|
||||
local THRESHOLD = 10 ^ (SILENCE_DB / 20)
|
||||
local MIN_VOICE_BLOCKS = math.ceil(MIN_VOICE_SEC / BLOCK_SEC)
|
||||
local function log(msg)
|
||||
reaper.ShowConsoleMsg("[PostProd] " .. msg .. "\n")
|
||||
-- Silent by default — uncomment for debugging:
|
||||
-- reaper.ShowConsoleMsg("[PostProd] " .. msg .. "\n")
|
||||
end
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
src = Path("data/regulars.json")
|
||||
data = json.loads(src.read_text())
|
||||
|
||||
Path("data/regulars.archived.json").write_text(json.dumps(data, indent=2))
|
||||
|
||||
regulars = data.get("regulars", [])
|
||||
silas_only = [r for r in regulars if r.get("name", "").lower() == "silas"]
|
||||
data["regulars"] = silas_only
|
||||
src.write_text(json.dumps(data, indent=2))
|
||||
print(f"Archived {len(regulars)} regulars. Kept {len(silas_only)} (Silas).")
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Generate 10 sample caller dialogues for user validation before cutover.
|
||||
|
||||
5 with Silas batch (if lore exists), 5 walk-ins. Writes transcripts to docs/samples/.
|
||||
"""
|
||||
import asyncio
|
||||
import random
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.config import settings
|
||||
from backend.main import (
|
||||
BLACKLISTED_VOICES,
|
||||
INWORLD_FEMALE_VOICES,
|
||||
INWORLD_MALE_VOICES,
|
||||
get_caller_prompt,
|
||||
)
|
||||
from backend.services import caller_gen, regulars_v2
|
||||
|
||||
DIALOG_MODEL = "anthropic/claude-haiku-4.5"
|
||||
|
||||
HOST_REACTIONS = [
|
||||
"Yeah?",
|
||||
"Go on.",
|
||||
"Mm-hmm.",
|
||||
"Wait — really?",
|
||||
"Hold on, back up.",
|
||||
"Is that right?",
|
||||
"How'd you end up there?",
|
||||
"And then what?",
|
||||
"What'd she say?",
|
||||
"So what are you gonna do about it?",
|
||||
"That's — okay, keep going.",
|
||||
"I mean, what do you want me to tell you?",
|
||||
]
|
||||
|
||||
|
||||
async def dialog_turn(client: httpx.AsyncClient, system_prompt: str, conversation: list) -> str:
|
||||
last_err = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
resp = await client.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
|
||||
json={
|
||||
"model": DIALOG_MODEL,
|
||||
"messages": [{"role": "system", "content": system_prompt}] + conversation,
|
||||
"max_tokens": 300,
|
||||
"temperature": 0.9,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["message"]["content"].strip()
|
||||
except (httpx.ReadTimeout, httpx.HTTPStatusError) as e:
|
||||
last_err = e
|
||||
print(f" [retry {attempt+1}/3] dialog_turn failed: {type(e).__name__}")
|
||||
raise last_err
|
||||
|
||||
|
||||
async def main():
|
||||
out_dir = Path("docs/samples")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
voice_roster = [
|
||||
n for n in INWORLD_MALE_VOICES + INWORLD_FEMALE_VOICES
|
||||
if n not in BLACKLISTED_VOICES
|
||||
]
|
||||
|
||||
date_str = datetime.now().strftime("%A, %B %d, %Y")
|
||||
|
||||
# Batch 1: include Silas (if lore exists)
|
||||
regulars = regulars_v2.load_all_active_regulars()
|
||||
if regulars:
|
||||
ctx_with_silas = {
|
||||
"date": date_str,
|
||||
"weather": "cool desert night",
|
||||
"headlines": [],
|
||||
"recent_caller_summaries": [],
|
||||
"regulars_included": [
|
||||
{"name": r.name, "lore": r.lore_body, "arc_state": r.arc_state}
|
||||
for r in regulars
|
||||
],
|
||||
"caller_count": 5,
|
||||
"voice_roster": voice_roster,
|
||||
}
|
||||
print("[Batch 1] Generating 5 callers including Silas...")
|
||||
silas_batch = await caller_gen.generate_batch(ctx_with_silas)
|
||||
print(f"[Batch 1] Got {len(silas_batch)} callers: {[c.name for c in silas_batch]}")
|
||||
else:
|
||||
print("[Batch 1] No regulars found — skipping")
|
||||
silas_batch = []
|
||||
|
||||
# Batch 2: walk-ins
|
||||
ctx_walkins = {
|
||||
"date": date_str,
|
||||
"weather": "cool desert night",
|
||||
"headlines": [],
|
||||
"recent_caller_summaries": [],
|
||||
"regulars_included": [],
|
||||
"caller_count": 5,
|
||||
"voice_roster": voice_roster,
|
||||
}
|
||||
print("[Batch 2] Generating 5 walk-in callers...")
|
||||
walkin_batch = await caller_gen.generate_batch(ctx_walkins)
|
||||
print(f"[Batch 2] Got {len(walkin_batch)} callers: {[c.name for c in walkin_batch]}")
|
||||
|
||||
all_callers = silas_batch + walkin_batch
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
for idx, caller in enumerate(all_callers, 1):
|
||||
print(f"[Dialog {idx}/{len(all_callers)}] {caller.name}...")
|
||||
cdict = {
|
||||
"name": caller.name,
|
||||
"identity": caller.identity,
|
||||
"situation": caller.situation,
|
||||
"reason_calling": caller.reason_calling,
|
||||
"secret_want": caller.secret_want,
|
||||
"specific_details": caller.specific_details,
|
||||
}
|
||||
system_prompt = get_caller_prompt(cdict)
|
||||
conversation = [{"role": "assistant", "content": caller.opening_line}]
|
||||
|
||||
for _ in range(4):
|
||||
host_line = random.choice(HOST_REACTIONS)
|
||||
conversation.append({"role": "user", "content": host_line})
|
||||
reply = await dialog_turn(client, system_prompt, conversation)
|
||||
conversation.append({"role": "assistant", "content": reply})
|
||||
|
||||
transcript_lines = [
|
||||
f"CALLER ({caller.name}, {caller.age}, {caller.location}): "
|
||||
f"{conversation[0]['content']}"
|
||||
]
|
||||
for i in range(1, len(conversation), 2):
|
||||
transcript_lines.append(f"LUKE: {conversation[i]['content']}")
|
||||
if i + 1 < len(conversation):
|
||||
transcript_lines.append(f"CALLER: {conversation[i+1]['content']}")
|
||||
|
||||
slug = caller.name.replace(" ", "_").lower()
|
||||
fname = out_dir / f"sample_{slug}.txt"
|
||||
fname.write_text(
|
||||
f"=== {caller.name} ({caller.age}, {caller.location}) ===\n"
|
||||
f"voice: {caller.voice_resolved}\n"
|
||||
f"emotional_register: {caller.emotional_register}\n"
|
||||
f"secret_want: {caller.secret_want}\n\n"
|
||||
+ "\n".join(transcript_lines)
|
||||
+ "\n"
|
||||
)
|
||||
print(f"Wrote {fname}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,114 @@
|
||||
import pytest
|
||||
from backend.services.caller_gen import CallerIdentity, parse_batch_response
|
||||
|
||||
SAMPLE_JSON = """
|
||||
{
|
||||
"callers": [
|
||||
{
|
||||
"name": "Danny Ortega",
|
||||
"age": 47,
|
||||
"voice_suggestion": "Marcus",
|
||||
"location": "Silver City, NM",
|
||||
"identity": "A plumber who inherited his uncle's taxidermy shop...",
|
||||
"situation": "He's been getting calls from people...",
|
||||
"reason_calling": "Someone left a note in his mailbox tonight...",
|
||||
"opening_line": "Luke, I need to ask you something weird.",
|
||||
"secret_want": "Permission to just throw it all away",
|
||||
"specific_details": ["the elk head in the basement", "the note said 'she forgot'", "his uncle's Rolodex"],
|
||||
"emotional_register": "quietly unsettled, trying to sound casual"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_batch_response_returns_caller_list():
|
||||
callers = parse_batch_response(SAMPLE_JSON)
|
||||
assert len(callers) == 1
|
||||
assert callers[0].name == "Danny Ortega"
|
||||
assert callers[0].age == 47
|
||||
assert "taxidermy" in callers[0].identity
|
||||
assert len(callers[0].specific_details) == 3
|
||||
|
||||
|
||||
def test_parse_batch_response_rejects_missing_fields():
|
||||
bad = '{"callers": [{"name": "Jim"}]}'
|
||||
with pytest.raises(ValueError, match="missing"):
|
||||
parse_batch_response(bad)
|
||||
|
||||
|
||||
FENCED_JSON = '''```json
|
||||
{"callers": [{"name": "Terrence", "age": 61, "voice_suggestion": "Marcus", "location": "Tucumcari, NM", "identity": "Retired irrigation engineer", "situation": "Reading water bill", "reason_calling": "Found clause", "opening_line": "Luke.", "secret_want": "Vindication", "specific_details": ["a", "b"], "emotional_register": "intense"}]}
|
||||
```'''
|
||||
|
||||
|
||||
def test_parse_batch_response_strips_markdown_fences():
|
||||
callers = parse_batch_response(FENCED_JSON)
|
||||
assert len(callers) == 1
|
||||
assert callers[0].name == "Terrence"
|
||||
|
||||
|
||||
def test_resolve_voice_matches_exact():
|
||||
from backend.services.caller_gen import resolve_voice
|
||||
roster = ["Marcus", "Dennis", "Priya", "Edward"]
|
||||
assert resolve_voice("Marcus", roster) == "Marcus"
|
||||
|
||||
|
||||
def test_resolve_voice_case_insensitive():
|
||||
from backend.services.caller_gen import resolve_voice
|
||||
roster = ["Marcus", "Dennis"]
|
||||
assert resolve_voice("marcus", roster) == "Marcus"
|
||||
|
||||
|
||||
def test_resolve_voice_falls_back_when_no_match():
|
||||
from backend.services.caller_gen import resolve_voice
|
||||
roster = ["Marcus", "Dennis"]
|
||||
# Deterministic fallback: return first from roster
|
||||
assert resolve_voice("Santiago", roster) == "Marcus"
|
||||
|
||||
|
||||
def test_resolve_voice_empty_suggestion_falls_back():
|
||||
from backend.services.caller_gen import resolve_voice
|
||||
assert resolve_voice("", ["Marcus"]) == "Marcus"
|
||||
|
||||
|
||||
def test_build_batch_prompt_includes_context():
|
||||
from backend.services.caller_gen import build_batch_prompt
|
||||
ctx = {
|
||||
"date": "Saturday, April 5, 2026",
|
||||
"weather": "cool desert night, 48°F",
|
||||
"headlines": ["New Mexico legislature approves water bill"],
|
||||
"recent_caller_summaries": ["Jerry called about his neighbor's goat"],
|
||||
"regulars_included": [],
|
||||
"caller_count": 12,
|
||||
"voice_roster": ["Marcus", "Dennis", "Priya"],
|
||||
}
|
||||
prompt = build_batch_prompt(ctx)
|
||||
assert "Saturday, April 5, 2026" in prompt
|
||||
assert "water bill" in prompt
|
||||
assert "Jerry called about his neighbor's goat" in prompt
|
||||
assert "12 callers" in prompt
|
||||
assert "Marcus" in prompt # voice roster listed
|
||||
assert "Stern" in prompt
|
||||
assert "Coast to Coast" in prompt
|
||||
assert "Loveline" in prompt
|
||||
assert "Delilah" in prompt
|
||||
assert "Opie and Anthony" in prompt
|
||||
|
||||
|
||||
def test_build_batch_prompt_includes_silas_lore_when_present():
|
||||
from backend.services.caller_gen import build_batch_prompt
|
||||
ctx = {
|
||||
"date": "...",
|
||||
"weather": "...",
|
||||
"headlines": [],
|
||||
"recent_caller_summaries": [],
|
||||
"regulars_included": [{"name": "Silas", "lore": "Silas leads a small desert cult...", "arc_state": "seeking new members"}],
|
||||
"caller_count": 12,
|
||||
"voice_roster": ["Marcus"],
|
||||
}
|
||||
prompt = build_batch_prompt(ctx)
|
||||
assert "Silas" in prompt
|
||||
assert "desert cult" in prompt
|
||||
assert "seeking new members" in prompt
|
||||
assert "DO NOT alter his voice, personality, or core traits" in prompt
|
||||
@@ -0,0 +1,25 @@
|
||||
from backend.main import get_caller_prompt
|
||||
|
||||
|
||||
def test_prompt_includes_identity_and_situation():
|
||||
caller = {
|
||||
"name": "Danny",
|
||||
"identity": "A plumber who inherited a taxidermy shop",
|
||||
"situation": "Getting strange calls about taxidermy",
|
||||
"reason_calling": "Someone left a note",
|
||||
"secret_want": "Permission to throw it all away",
|
||||
"specific_details": ["elk head in basement", "note said she forgot"],
|
||||
}
|
||||
prompt = get_caller_prompt(caller)
|
||||
assert "Danny" in prompt
|
||||
assert "taxidermy shop" in prompt
|
||||
assert "strange calls" in prompt
|
||||
assert "elk head" in prompt
|
||||
assert "she forgot" in prompt
|
||||
assert "Permission to throw it all away" in prompt
|
||||
assert "React to what Luke says" in prompt
|
||||
assert "Stay in character" in prompt
|
||||
assert "NEVER use asterisks" in prompt
|
||||
assert "NEVER use parenthetical stage directions" in prompt
|
||||
assert "Mix short punchy replies with longer ones" in prompt
|
||||
assert len(prompt) < 3500
|
||||
@@ -1,39 +0,0 @@
|
||||
import sys
|
||||
sys.path.insert(0, "/Users/lukemacneil/ai-podcast")
|
||||
|
||||
from backend.main import Session, CallRecord, get_caller_prompt
|
||||
|
||||
|
||||
def test_caller_prompt_includes_show_history():
|
||||
s = Session()
|
||||
s.call_history.append(CallRecord(
|
||||
caller_type="real", caller_name="Dave",
|
||||
summary="Called about his wife leaving after 12 years",
|
||||
transcript=[],
|
||||
))
|
||||
|
||||
s.start_call("1") # Tony
|
||||
caller = s.caller
|
||||
show_history = s.get_show_history()
|
||||
prompt = get_caller_prompt(caller, "", show_history)
|
||||
assert "Dave" in prompt
|
||||
assert "wife leaving" in prompt
|
||||
assert "EARLIER IN THE SHOW" in prompt
|
||||
|
||||
|
||||
def test_caller_prompt_without_history():
|
||||
s = Session()
|
||||
s.start_call("1")
|
||||
caller = s.caller
|
||||
prompt = get_caller_prompt(caller, "")
|
||||
assert "EARLIER IN THE SHOW" not in prompt
|
||||
assert caller["name"] in prompt
|
||||
|
||||
|
||||
def test_caller_prompt_backward_compatible():
|
||||
"""Verify get_caller_prompt works with just 2 args (no show_history)"""
|
||||
s = Session()
|
||||
s.start_call("1")
|
||||
caller = s.caller
|
||||
prompt = get_caller_prompt(caller, "Host: hello")
|
||||
assert "hello" in prompt
|
||||
@@ -0,0 +1,140 @@
|
||||
"""A --resume run must not invent the episode title.
|
||||
|
||||
Episode 58's first run published to Castopod, then died on the YouTube upload
|
||||
(see test_youtube_tags.py). The re-run with --resume rebuilt the title from the
|
||||
URL slug, which is lowercase and punctuation-free, so
|
||||
"Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho"
|
||||
went to YouTube as
|
||||
"Episode 58: Rayfield S Nephew The Marfa Lights And Why Nobody Believes Concho"
|
||||
with the description replaced by a placeholder.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from publish_episode import recover_metadata, save_metadata, _decode_db_row
|
||||
|
||||
EP58_TITLE = "Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho"
|
||||
EP58_SLUG = "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
|
||||
EP58_DESC = "Rayfield calls about his nephew and the catalytic converters."
|
||||
CHAPTERS = [{"startTime": 0, "title": "Intro"}]
|
||||
|
||||
|
||||
def test_metadata_file_roundtrips_losslessly(tmp_path):
|
||||
path = tmp_path / "ep58.metadata.json"
|
||||
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
|
||||
"chapters": CHAPTERS, "thumbnail_text": "RAYFIELD"}, str(path))
|
||||
|
||||
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS)
|
||||
|
||||
assert meta["title"] == EP58_TITLE
|
||||
assert meta["description"] == EP58_DESC
|
||||
|
||||
|
||||
def test_metadata_file_wins_over_slug(tmp_path):
|
||||
"""The exact ep58 regression: apostrophes, commas and case must survive."""
|
||||
path = tmp_path / "ep58.metadata.json"
|
||||
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
|
||||
"chapters": CHAPTERS, "thumbnail_text": "RAYFIELD"}, str(path))
|
||||
|
||||
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS)
|
||||
|
||||
assert "Rayfield S Nephew" not in meta["title"]
|
||||
assert "Rayfield's Nephew" in meta["title"]
|
||||
assert "the Marfa Lights" in meta["title"]
|
||||
assert meta["description"] != "Episode 58 of Luke at the Roost."
|
||||
|
||||
|
||||
def test_falls_back_to_castopod_db_when_file_missing(tmp_path):
|
||||
path = tmp_path / "missing.metadata.json"
|
||||
calls = []
|
||||
|
||||
def db_lookup(episode_number):
|
||||
calls.append(episode_number)
|
||||
return {"title": EP58_TITLE, "description": EP58_DESC}
|
||||
|
||||
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS, db_lookup=db_lookup)
|
||||
|
||||
assert calls == [58]
|
||||
assert meta["title"] == EP58_TITLE
|
||||
assert meta["description"] == EP58_DESC
|
||||
|
||||
|
||||
def test_slug_fallback_only_when_file_and_db_unavailable(tmp_path, capsys):
|
||||
path = tmp_path / "missing.metadata.json"
|
||||
|
||||
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS,
|
||||
db_lookup=lambda n: None)
|
||||
|
||||
assert meta["title"].startswith("Episode 58: ")
|
||||
warning = capsys.readouterr().out
|
||||
assert "lossy" in warning.lower() or "warning" in warning.lower()
|
||||
|
||||
|
||||
def test_slug_fallback_does_not_title_case_away_real_words(tmp_path):
|
||||
"""Even degraded, the fallback should not be silently trusted."""
|
||||
meta = recover_metadata(58, EP58_SLUG, tmp_path / "nope.json", CHAPTERS,
|
||||
db_lookup=lambda n: None)
|
||||
assert meta.get("title_is_reconstructed") is True
|
||||
|
||||
|
||||
def test_episode_prefix_stripped_only_at_start(tmp_path):
|
||||
"""A global replace would also eat the phrase inside the title."""
|
||||
slug = "episode-5-the-episode-5-mixup"
|
||||
meta = recover_metadata(5, slug, tmp_path / "nope.json", CHAPTERS,
|
||||
db_lookup=lambda n: None)
|
||||
assert meta["title"].lower().count("episode 5") == 2
|
||||
|
||||
|
||||
def test_chapters_come_from_chapters_json_not_metadata_file(tmp_path):
|
||||
path = tmp_path / "ep58.metadata.json"
|
||||
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
|
||||
"chapters": [{"startTime": 999, "title": "Stale"}],
|
||||
"thumbnail_text": "X"}, str(path))
|
||||
|
||||
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS)
|
||||
|
||||
assert meta["chapters"] == CHAPTERS
|
||||
|
||||
|
||||
def test_decodes_base64_wrapped_by_mariadb():
|
||||
"""TO_BASE64 wraps at 76 chars and mysql renders those breaks as a literal
|
||||
backslash-n, which b64decode rejects outright."""
|
||||
import base64 as b64
|
||||
payload = json.dumps({"title": EP58_TITLE, "description": EP58_DESC})
|
||||
encoded = b64.b64encode(payload.encode()).decode()
|
||||
wrapped = "\\n".join(encoded[i:i + 76] for i in range(0, len(encoded), 76))
|
||||
|
||||
assert "\\n" in wrapped, "test needs a payload long enough to wrap"
|
||||
row = _decode_db_row(wrapped)
|
||||
|
||||
assert row["title"] == EP58_TITLE
|
||||
assert row["description"] == EP58_DESC
|
||||
|
||||
|
||||
def test_decodes_base64_with_real_newlines():
|
||||
import base64 as b64
|
||||
payload = json.dumps({"title": EP58_TITLE, "description": EP58_DESC})
|
||||
encoded = b64.b64encode(payload.encode()).decode()
|
||||
wrapped = "\n".join(encoded[i:i + 76] for i in range(0, len(encoded), 76))
|
||||
|
||||
assert _decode_db_row(wrapped)["title"] == EP58_TITLE
|
||||
|
||||
|
||||
def test_decode_db_row_returns_none_on_garbage():
|
||||
assert _decode_db_row("") is None
|
||||
assert _decode_db_row("not base64 at all !!!") is None
|
||||
|
||||
|
||||
def test_save_metadata_keeps_only_publishable_fields(tmp_path):
|
||||
path = tmp_path / "m.json"
|
||||
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
|
||||
"chapters": CHAPTERS, "thumbnail_text": "RAYFIELD",
|
||||
"transcript": "huge blob that should not be persisted"}, str(path))
|
||||
|
||||
saved = json.loads(path.read_text())
|
||||
assert "transcript" not in saved
|
||||
assert saved["title"] == EP58_TITLE
|
||||
@@ -0,0 +1,92 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from backend.services.regulars_v2 import Regular, load_regular, REGULARS_DIR, SILAS_DIR
|
||||
|
||||
|
||||
def test_load_regular_parses_frontmatter_and_body(tmp_path):
|
||||
lore_file = tmp_path / "silas.md"
|
||||
lore_file.write_text("""---
|
||||
name: Silas
|
||||
voice: Dennis
|
||||
age: 54
|
||||
arc_state: Cult is splintering after the eclipse failure
|
||||
---
|
||||
|
||||
# Silas
|
||||
|
||||
Silas runs a small desert cult outside Truth or Consequences...
|
||||
|
||||
## Arc Log
|
||||
|
||||
- 2026-03-01: First call, introduced the cult
|
||||
- 2026-03-20: Prophesied the eclipse
|
||||
""")
|
||||
reg = load_regular(lore_file)
|
||||
assert reg.name == "Silas"
|
||||
assert reg.voice == "Dennis"
|
||||
assert reg.age == 54
|
||||
assert "splintering" in reg.arc_state
|
||||
assert "Silas runs a small desert cult" in reg.lore_body
|
||||
|
||||
|
||||
def test_evaluate_promotion_returns_arc_plan_when_worthy():
|
||||
from backend.services.regulars_v2 import evaluate_promotion
|
||||
fake_response = {
|
||||
"promote": True,
|
||||
"arc_plan": "3 episodes. He'll start distant, then reveal he's actually the one who damaged the car, then resolve with an apology.",
|
||||
"reason": "Has clear internal conflict with room to grow",
|
||||
}
|
||||
with patch("backend.services.regulars_v2._call_sonnet", new=AsyncMock(return_value=fake_response)):
|
||||
result = asyncio.run(evaluate_promotion(caller_name="Bobby", call_transcript="..."))
|
||||
assert result["promote"] is True
|
||||
assert "3 episodes" in result["arc_plan"]
|
||||
|
||||
|
||||
def test_evaluate_promotion_rejects_when_no_arc():
|
||||
from backend.services.regulars_v2 import evaluate_promotion
|
||||
fake_response = {"promote": False, "arc_plan": None, "reason": "One-note complaint, no growth"}
|
||||
with patch("backend.services.regulars_v2._call_sonnet", new=AsyncMock(return_value=fake_response)):
|
||||
result = asyncio.run(evaluate_promotion(caller_name="Carl", call_transcript="..."))
|
||||
assert result["promote"] is False
|
||||
|
||||
|
||||
def test_call_sonnet_strips_markdown_fences():
|
||||
from backend.services.regulars_v2 import _call_sonnet
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.raise_for_status = MagicMock()
|
||||
fake_resp.json = MagicMock(return_value={
|
||||
"choices": [{"message": {"content": "```json\n{\"promote\": true, \"arc_plan\": \"ok\"}\n```"}}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
||||
})
|
||||
mock_client = MagicMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client.post = AsyncMock(return_value=fake_resp)
|
||||
|
||||
with patch("backend.services.regulars_v2.httpx.AsyncClient", return_value=mock_client), \
|
||||
patch("backend.services.regulars_v2.cost_tracker.record_llm_call"):
|
||||
result = asyncio.run(_call_sonnet("prompt"))
|
||||
assert result["promote"] is True
|
||||
assert result["arc_plan"] == "ok"
|
||||
|
||||
|
||||
def test_write_new_regular_creates_lore_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("backend.services.regulars_v2.REGULARS_DIR", tmp_path)
|
||||
from backend.services.regulars_v2 import write_new_regular
|
||||
|
||||
write_new_regular(
|
||||
name="Bobby",
|
||||
voice="Marcus",
|
||||
age=34,
|
||||
identity_paragraph="A landscaper in Las Cruces who...",
|
||||
arc_plan="3 episodes: distant → reveal → apology",
|
||||
first_call_summary="Called about damaged car",
|
||||
)
|
||||
f = tmp_path / "bobby.md"
|
||||
assert f.exists()
|
||||
body = f.read_text()
|
||||
assert "name: Bobby" in body
|
||||
assert "voice: Marcus" in body
|
||||
assert "A landscaper in Las Cruces" in body
|
||||
assert "3 episodes: distant → reveal → apology" in body
|
||||
@@ -0,0 +1,79 @@
|
||||
"""YouTube rejects the whole upload with `invalidTags` if the tag list busts
|
||||
its 500-character budget. Any tag containing a space gets wrapped in quotes and
|
||||
those quotes count, so the naive sum of tag lengths understates the real cost.
|
||||
|
||||
Episode 58 failed here after a 284 MB upload had already completed.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from publish_episode import _extract_youtube_tags, YOUTUBE_TAG_BUDGET
|
||||
|
||||
|
||||
def tag_cost(tags):
|
||||
"""Mirror YouTube's accounting: quotes around multi-word tags, plus commas."""
|
||||
return sum(len(t) + (2 if " " in t else 0) for t in tags) + max(0, len(tags) - 1)
|
||||
|
||||
|
||||
# The real chapter titles from episode 58, which produced a 534-char tag list.
|
||||
EP58_CHAPTERS = [
|
||||
{"title": "Intro & Election Fraud Voicemail"},
|
||||
{"title": "Rayfield: Nephew Stealing Catalytic Converters"},
|
||||
{"title": "Suki & the Marfa Lights"},
|
||||
{"title": "Aurora Toothbrush Sponsor"},
|
||||
{"title": "Gus: Partner's Unlogged Stop at Meth House"},
|
||||
{"title": "Merritt: Son Stealing from Family Store"},
|
||||
{"title": "Concho: Mysterious Water Source Surveyors"},
|
||||
{"title": "Desmond: Colleague's Research Misconduct"},
|
||||
{"title": "Iron Heart Survival School Sponsor"},
|
||||
{"title": "Fern: Donald Judd's Aluminum Boxes in Marfa"},
|
||||
{"title": "Outro & Show Reflection"},
|
||||
]
|
||||
|
||||
|
||||
def test_episode_58_tags_fit_the_budget():
|
||||
"""The exact input that broke the ep58 upload."""
|
||||
tags = _extract_youtube_tags({"chapters": EP58_CHAPTERS})
|
||||
assert tag_cost(tags) <= YOUTUBE_TAG_BUDGET, (
|
||||
f"{tag_cost(tags)} chars > {YOUTUBE_TAG_BUDGET} budget: {tags}"
|
||||
)
|
||||
|
||||
|
||||
def test_pathological_long_titles_still_fit():
|
||||
chapters = [{"title": "A" * 49 + f" {i}"} for i in range(40)]
|
||||
tags = _extract_youtube_tags({"chapters": chapters})
|
||||
assert tag_cost(tags) <= YOUTUBE_TAG_BUDGET, tag_cost(tags)
|
||||
assert len(tags) <= 25
|
||||
|
||||
|
||||
def test_no_chapters_still_returns_base_tags():
|
||||
tags = _extract_youtube_tags({"chapters": []})
|
||||
assert tags, "should still return the base SEO tags"
|
||||
assert tag_cost(tags) <= YOUTUBE_TAG_BUDGET
|
||||
|
||||
|
||||
def test_base_tags_are_prioritised_over_chapter_titles():
|
||||
"""Base SEO tags matter more than chapter titles; they must survive trimming."""
|
||||
chapters = [{"title": "B" * 48 + f" {i}"} for i in range(30)]
|
||||
tags = _extract_youtube_tags({"chapters": chapters})
|
||||
assert "podcast" in tags
|
||||
assert "Luke at the Roost" in tags
|
||||
|
||||
|
||||
def test_angle_brackets_are_stripped():
|
||||
"""YouTube rejects tags containing < or >."""
|
||||
chapters = [{"title": "Weird <script> Chapter"}]
|
||||
tags = _extract_youtube_tags({"chapters": chapters})
|
||||
assert not any("<" in t or ">" in t for t in tags), tags
|
||||
|
||||
|
||||
def test_skips_intro_outro_and_short_titles():
|
||||
chapters = [{"title": "Intro"}, {"title": "Outro"}, {"title": "ab"},
|
||||
{"title": "A Real Chapter Title"}]
|
||||
tags = _extract_youtube_tags({"chapters": chapters})
|
||||
assert "Intro" not in tags and "Outro" not in tags and "ab" not in tags
|
||||
assert "A Real Chapter Title" in tags
|
||||
+20
-3
@@ -58,9 +58,26 @@ export default {
|
||||
return new Response("Feed unavailable", { status: 502 });
|
||||
}
|
||||
|
||||
// Plausible analytics proxy (bypass ad blockers)
|
||||
// Umami analytics proxy (bypass ad blockers)
|
||||
if (url.pathname === "/api/send" && request.method === "POST") {
|
||||
const body = await request.text();
|
||||
const resp = await fetch("https://plausible.macneilmediagroup.com/api/send", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": request.headers.get("User-Agent") || "",
|
||||
"X-Forwarded-For": request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For") || "",
|
||||
},
|
||||
body,
|
||||
});
|
||||
return new Response(resp.body, {
|
||||
status: resp.status,
|
||||
headers: { "Content-Type": resp.headers.get("Content-Type") || "text/plain" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/p/script") {
|
||||
const resp = await fetch("https://plausible.macneilmediagroup.com/js/script.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js");
|
||||
const resp = await fetch("https://plausible.macneilmediagroup.com/script.js");
|
||||
return new Response(await resp.text(), {
|
||||
headers: {
|
||||
"Content-Type": "application/javascript",
|
||||
@@ -71,7 +88,7 @@ export default {
|
||||
|
||||
if (url.pathname === "/p/event" && request.method === "POST") {
|
||||
const body = await request.text();
|
||||
const resp = await fetch("https://plausible.macneilmediagroup.com/api/event", {
|
||||
const resp = await fetch("https://plausible.macneilmediagroup.com/api/send", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
+118
-1
@@ -1,4 +1,121 @@
|
||||
[
|
||||
{
|
||||
"title": "Thinking in English First",
|
||||
"description": "When she heard about her father's diagnosis, she processed it in English first\u2014before responding to her mom in Czech. It felt like a betrayal of who she used to be.",
|
||||
"episode_number": 49,
|
||||
"clip_file": "clip-5-thinking-in-english-first.mp4",
|
||||
"youtube_id": "LGqpEAxe754",
|
||||
"featured": false,
|
||||
"thumbnail": "images/clips/clip-5-thinking-in-english-first.jpg"
|
||||
},
|
||||
{
|
||||
"title": "The April Fool's Divorce Text Disaster",
|
||||
"description": "She thought sending her husband a divorce text as an April Fool's joke would be funny. Then his mom got involved and the casseroles started arriving. For 11 days straight.",
|
||||
"episode_number": 48,
|
||||
"clip_file": "clip-2-the-april-fool-s-divorce-text-disaster.mp4",
|
||||
"youtube_id": "L9XAQgZeQo0",
|
||||
"featured": false,
|
||||
"thumbnail": "images/clips/clip-2-the-april-fool-s-divorce-text-disaster.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Caller's Daughter on Adult Website Revealed",
|
||||
"description": "A caller discovers his daughter's secret online activities in the worst possible way. The details just keep getting more uncomfortable.",
|
||||
"episode_number": 47,
|
||||
"clip_file": "clip-3-caller-s-daughter-on-adult-website-revealed.mp4",
|
||||
"youtube_id": "LiH6gVDTHcg",
|
||||
"featured": false,
|
||||
"thumbnail": "images/clips/clip-3-caller-s-daughter-on-adult-website-revealed.jpg"
|
||||
},
|
||||
{
|
||||
"title": "The Taint Explanation",
|
||||
"description": "Luke's caller had to explain what a taint is on live radio and it went exactly as uncomfortably as you'd imagine. This is why we can't have nice things.",
|
||||
"episode_number": 46,
|
||||
"clip_file": "clip-2-the-taint-explanation.mp4",
|
||||
"youtube_id": "7qAhmnwUE1c",
|
||||
"featured": false,
|
||||
"thumbnail": "images/clips/clip-2-the-taint-explanation.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Bear Traps and Pit of Snakes",
|
||||
"description": "Luke's home security system is straight out of Home Alone but way more dangerous. Swinging logs? Check. Pit of snakes covered with dust? BOOM! \ud83d\udc0d",
|
||||
"episode_number": 44,
|
||||
"clip_file": "clip-2-bear-traps-and-pit-of-snakes.mp4",
|
||||
"youtube_id": "XBSEu2bFUAI",
|
||||
"featured": false,
|
||||
"thumbnail": "images/clips/clip-2-bear-traps-and-pit-of-snakes.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Roommate's Age Play Kink Takes Over Kitchen",
|
||||
"description": "Roommate's age play kink is now front and center in the shared kitchen. Baby bottles by the coffee maker? Pacifiers on the counter? This living situation just got complicated.",
|
||||
"episode_number": 43,
|
||||
"clip_file": "clip-3-roommate-s-age-play-kink-takes-over-kitchen.mp4",
|
||||
"youtube_id": "5cF_O2Gm9yM",
|
||||
"featured": false,
|
||||
"thumbnail": "images/clips/clip-3-roommate-s-age-play-kink-takes-over-kitchen.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Day Trading Away the Marriage",
|
||||
"description": "She's been pretending to commute for 12 hours a day while secretly day-trading their life savings away in motels. Two years of lies and $145K gone.",
|
||||
"episode_number": 42,
|
||||
"clip_file": "clip-1-day-trading-away-the-marriage.mp4",
|
||||
"youtube_id": "JeZ9OLGfW8A",
|
||||
"featured": false,
|
||||
"thumbnail": "images/clips/clip-1-day-trading-away-the-marriage.jpg"
|
||||
},
|
||||
{
|
||||
"title": "The Snot Boogie Beer Money Thief",
|
||||
"description": "He showed up to the barbecue claiming he's 'not like other guys' then literally ran off with everyone's beer money. The audacity is unmatched.",
|
||||
"episode_number": 42,
|
||||
"clip_file": "clip-2-the-snot-boogie-beer-money-thief.mp4",
|
||||
"youtube_id": "taBAo8_YMkA",
|
||||
"featured": false,
|
||||
"thumbnail": "images/clips/clip-2-the-snot-boogie-beer-money-thief.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Aliens Chasing in Ford Trucks",
|
||||
"description": "Glowing Ford trucks with shadowy drivers that aren't quite human. This caller's alien encounter story is straight out of a fever dream.",
|
||||
"episode_number": 41,
|
||||
"clip_file": "clip-3-aliens-chasing-in-ford-trucks.mp4",
|
||||
"youtube_id": "Io8CzDtKGfA",
|
||||
"featured": false,
|
||||
"thumbnail": "images/clips/clip-3-aliens-chasing-in-ford-trucks.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Doctor Prescribes Prostate Stimulation",
|
||||
"description": "Dale's doctor hands him a prostate stimulation pamphlet after his cancer diagnosis. Luke's medical advice? 'Stick some stuff up there and see what works.'",
|
||||
"episode_number": 40,
|
||||
"clip_file": "clip-3-doctor-prescribes-prostate-stimulation.mp4",
|
||||
"youtube_id": "nQtwJpocFpY",
|
||||
"featured": false,
|
||||
"thumbnail": "images/clips/clip-3-doctor-prescribes-prostate-stimulation.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Open Marriage Backfired Spectacularly",
|
||||
"description": "He agreed to an open marriage and now his wife is living her best life while he sits home alone every night. This call is PAINFUL to listen to.",
|
||||
"episode_number": 39,
|
||||
"clip_file": "clip-1-open-marriage-backfired-spectacularly.mp4",
|
||||
"youtube_id": "-K-t7iijfGs",
|
||||
"featured": false,
|
||||
"thumbnail": "images/clips/clip-1-open-marriage-backfired-spectacularly.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Cat Burglar Bringing Home Stolen Goods",
|
||||
"description": "This caller's cat has been stealing from the neighbors and bringing home cash and car keys. Where is this criminal mastermind getting this stuff?",
|
||||
"episode_number": 39,
|
||||
"clip_file": "clip-2-cat-burglar-bringing-home-stolen-goods.mp4",
|
||||
"youtube_id": "JvgJWxFCBZk",
|
||||
"featured": false,
|
||||
"thumbnail": "images/clips/clip-2-cat-burglar-bringing-home-stolen-goods.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Second Baby Shower Entitlement Rant",
|
||||
"description": "A second baby shower with a full registry? This caller is NOT having it and goes OFF about the entitlement of expecting gifts for baby number two.",
|
||||
"episode_number": 39,
|
||||
"clip_file": "clip-3-second-baby-shower-entitlement-rant.mp4",
|
||||
"youtube_id": "NKt8NjDHKcg",
|
||||
"featured": false,
|
||||
"thumbnail": "images/clips/clip-3-second-baby-shower-entitlement-rant.jpg"
|
||||
},
|
||||
{
|
||||
"title": "Cult Leader Realizes He's Been Manipulating People",
|
||||
"description": "Cult leader calls in having a full existential crisis about his 'shared intimacy nights' and the manipulation tactics he's been using on his followers.",
|
||||
@@ -55,7 +172,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Started a Fight and Can't Stop Reading About Wars",
|
||||
"description": "A caller starts a fight with their partner and spirals into an obsessive deep-dive on historical wars. Luke tries to untangle the connection.",
|
||||
"description": "",
|
||||
"episode_number": 31,
|
||||
"clip_file": "clip-3-started-a-fight-and-can-t-stop-reading-about-wars.mp4",
|
||||
"youtube_id": "D2iWnSGQeow",
|
||||
|
||||
+1
-2
@@ -92,8 +92,7 @@
|
||||
}
|
||||
}]
|
||||
</script>
|
||||
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
|
||||
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||
<script defer data-website-id="ae816554-19c8-4e97-a2cb-d0226c27e9bc" data-host-url="/" src="/p/script"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
@@ -270,4 +270,70 @@
|
||||
<changefreq>never</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lukeattheroost.com/episode.html?slug=episode-39-st-patrick-s-day-chaos-and-caller-confessions</loc>
|
||||
<lastmod>2026-03-18</lastmod>
|
||||
<changefreq>never</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lukeattheroost.com/episode.html?slug=episode-40-prostate-cancer-christmas-lights-and-potato-salad-betrayals</loc>
|
||||
<lastmod>2026-03-19</lastmod>
|
||||
<changefreq>never</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lukeattheroost.com/episode.html?slug=episode-41-benny-s-creepy-vhs-tape-from-nowhere</loc>
|
||||
<lastmod>2026-03-20</lastmod>
|
||||
<changefreq>never</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lukeattheroost.com/episode.html?slug=episode-42-peggy-s-day-trading-disaster-and-the-nonprofit-yacht</loc>
|
||||
<lastmod>2026-03-21</lastmod>
|
||||
<changefreq>never</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lukeattheroost.com/episode.html?slug=episode-43-cousin-rufus-and-the-locksmith-s-wild-weekend</loc>
|
||||
<lastmod>2026-03-23</lastmod>
|
||||
<changefreq>never</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lukeattheroost.com/episode.html?slug=episode-44-floyd-s-fence-and-the-cattle-conspiracy</loc>
|
||||
<lastmod>2026-03-24</lastmod>
|
||||
<changefreq>never</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lukeattheroost.com/episode.html?slug=episode-45-potato-salad-aliens-and-the-dripping-faucet-of-doom</loc>
|
||||
<lastmod>2026-03-26</lastmod>
|
||||
<changefreq>never</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lukeattheroost.com/episode.html?slug=episode-46-butchers-wreak-forever-and-other-late-night-confessions</loc>
|
||||
<lastmod>2026-03-30</lastmod>
|
||||
<changefreq>never</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lukeattheroost.com/episode.html?slug=episode-47-clarence-randy-and-the-onlyfans-daughter</loc>
|
||||
<lastmod>2026-03-30</lastmod>
|
||||
<changefreq>never</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lukeattheroost.com/episode.html?slug=episode-48-potato-salad-legacy-and-the-april-fool-s-apocalypse</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>never</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://lukeattheroost.com/episode.html?slug=episode-49-silas-s-shared-intimacy-night-and-four-brave-souls</loc>
|
||||
<lastmod>2026-04-05</lastmod>
|
||||
<changefreq>never</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
|
||||
Reference in New Issue
Block a user