Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d116a3daee | ||
|
|
821b78aa74 | ||
|
|
242eb15e0a | ||
|
|
fe37c037d4 | ||
|
|
b1ad249e51 | ||
|
|
c65a4bba0c | ||
|
|
b85e1c6ea7 | ||
|
|
9071ed36d3 | ||
|
|
9d49b013c2 | ||
|
|
2071ae4181 | ||
|
|
e470b6aaea | ||
|
|
44ef13336e | ||
|
|
2901e5f4fb | ||
|
|
5f6429e6da | ||
|
|
bf1afef351 | ||
|
|
2429a2eb07 | ||
|
|
fa5ab9aad2 | ||
|
|
25b0015f62 | ||
|
|
b496b91a5d | ||
|
|
6951398872 | ||
|
|
61d24ae7d8 | ||
|
|
56a6a2dfbe | ||
|
|
fdb9f57660 | ||
|
|
cc0d9ffc5d | ||
|
|
f902eab701 | ||
|
|
8c71da543f | ||
|
|
5f7e3e7415 | ||
|
|
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 | ||
|
|
376265eec7 | ||
|
|
f3c91fc385 | ||
|
|
c69c2ad532 | ||
|
|
8dbbd92d3a | ||
|
|
fa36f8d184 | ||
|
|
794ad98cf0 | ||
|
|
f5eabd7dc4 | ||
|
|
f717edeacb | ||
|
|
56607879ee | ||
|
|
fcefabdaee | ||
|
|
58495d2c75 | ||
|
|
51961dc19b | ||
|
|
c516402402 | ||
|
|
e614599650 | ||
|
|
d36de95577 | ||
|
|
0147be4e0c | ||
|
|
390f138601 | ||
|
|
9eaf2fe5e3 | ||
|
|
314d5f9452 | ||
|
|
e0fb3cac68 | ||
|
|
4589670b37 | ||
|
|
eb1e18a997 | ||
|
|
6dcdf20289 | ||
|
|
762b5efc3b | ||
|
|
3dd6a83c68 | ||
|
|
5e98ed0e11 | ||
|
|
fcf13bae22 | ||
|
|
c30a75cc8f | ||
|
|
90e51698b8 | ||
|
|
5d8ab57e20 | ||
|
|
d33a022676 | ||
|
|
7e2ef1fa2b | ||
|
|
164cad456c | ||
|
|
0b091a1afd | ||
|
|
cfc7ad39f2 | ||
|
|
8a64a269f3 | ||
|
|
908255e5cf | ||
|
|
39297d4aa5 | ||
|
|
d39cb3f3d4 | ||
|
|
c70f83d04a | ||
|
|
3329cf9ac2 | ||
|
|
6d4e490283 | ||
|
|
d3490e1521 | ||
|
|
0c2201fab5 |
+1
-1
@@ -1 +1 @@
|
||||
2c7fcdb5aecbb0d3bf039abab9e723d62bc8fe1a
|
||||
c65a4bb
|
||||
|
||||
+25
@@ -59,3 +59,28 @@ upload-history.json
|
||||
|
||||
# Claude settings (local)
|
||||
.claude/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Scratch/smoke tests (not committed)
|
||||
scratch/
|
||||
|
||||
# Generated media and model weights (~13GB — never commit)
|
||||
clips/
|
||||
music/
|
||||
mlx_models/
|
||||
remotion-demo/
|
||||
social_posts/
|
||||
|
||||
# Generated analytics artifacts (rebuilt from cost_tracker)
|
||||
data/costs.db
|
||||
data/costs.db-shm
|
||||
data/costs.db-wal
|
||||
data/cost_reports/
|
||||
|
||||
# Generated caller avatars
|
||||
data/avatars/
|
||||
|
||||
# Reaper waveform peak cache
|
||||
reaper/peaks/
|
||||
|
||||
@@ -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`
|
||||
@@ -54,31 +38,80 @@ Required in `.env`:
|
||||
## LLM Settings
|
||||
- `_pick_response_budget()` in main.py controls caller dialog token limits (150-450 tokens). MiniMax respects limits strictly — if responses seem short, check these values.
|
||||
- Default max_tokens in llm.py is 300 (for non-caller uses)
|
||||
- Grok (`x-ai/grok-4-fast`) works well for natural dialog; MiniMax tends toward terse responses
|
||||
- Grok (`x-ai/grok-4.3`) works well for natural dialog; MiniMax tends toward terse responses. Note `grok-4`, `grok-4-fast` and `grok-4.1-fast` were retired from OpenRouter — a retired id 404s, and `llm.py` swallows the error and returns empty text, so callers go silent with nothing in the logs. `tests/test_model_config.py` guards against reintroducing them.
|
||||
- `generate_with_tools()` in llm.py supports OpenRouter function calling for the intern feature
|
||||
|
||||
## Caller Generation System
|
||||
- **Two-stage pipeline**: (1) batch identity pregen via Sonnet 4.6 at session start, (2) live dialog via Sonnet 4.6 per turn. Cost ~$3-4/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 Sonnet 4.6 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
|
||||
- **Personality**: 23-year-old NMSU grad, eager, slightly incompetent, gets yelled at. Voice: "Nate" (Inworld), no phone filter.
|
||||
- **Tools**: web_search (SearXNG), get_headlines, fetch_webpage, wikipedia_lookup — via `generate_with_tools()` function calling
|
||||
- **SearXNG**: runs on the NAS (`http://mmgnas:8888`), deployed via `deploy_searxng.sh`. URL is `settings.searxng_url` (env `SEARXNG_URL`). If Devon's web_search returns "Search failed", check the `searxng` container on mmgnas is Up. The config enables the JSON API + disables the bot limiter — both required for programmatic queries.
|
||||
- **Endpoints**: `POST /api/intern/ask`, `/interject`, `/monitor`, `GET /api/intern/suggestion`, `POST /api/intern/suggestion/play`, `/dismiss`
|
||||
- **Auto-monitoring**: Watches conversation every 15s during calls, buffers suggestions for host approval
|
||||
- **Persistence**: `data/intern.json` stores lookup history
|
||||
- **Frontend**: Ask Devon input (D key), Interject button, monitor toggle, suggestion indicator with Play/Dismiss
|
||||
|
||||
## 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 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
|
||||
|
||||
## Website
|
||||
- **Domain**: lukeattheroost.com (behind Cloudflare)
|
||||
- **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
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Luke MacNeil / MacNeil Media Group, LLC
|
||||
https://macneilmediagroup.com
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyze silence gaps in podcast stems to find optimal strip-silence thresholds.
|
||||
|
||||
Usage: python analyze_gaps.py recordings/2026-03-17_235137/
|
||||
"""
|
||||
import sys
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from pathlib import Path
|
||||
|
||||
BLOCK_SEC = 0.1
|
||||
SILENCE_DB = -30
|
||||
THRESHOLD = 10 ** (SILENCE_DB / 20)
|
||||
MIN_VOICE_SEC = 0.3
|
||||
|
||||
|
||||
def load_stem(path: Path) -> tuple[np.ndarray, int]:
|
||||
audio, sr = sf.read(path, dtype="float32")
|
||||
if audio.ndim > 1:
|
||||
audio = audio[:, 0]
|
||||
return audio, sr
|
||||
|
||||
|
||||
def compute_rms_blocks(audio: np.ndarray, sr: int) -> np.ndarray:
|
||||
block_samples = int(sr * BLOCK_SEC)
|
||||
n_blocks = len(audio) // block_samples
|
||||
if n_blocks == 0:
|
||||
return np.array([0.0])
|
||||
trimmed = audio[:n_blocks * block_samples].reshape(n_blocks, block_samples)
|
||||
return np.sqrt(np.mean(trimmed ** 2, axis=1))
|
||||
|
||||
|
||||
def compute_peak_blocks(audio: np.ndarray, sr: int) -> np.ndarray:
|
||||
block_samples = int(sr * BLOCK_SEC)
|
||||
n_blocks = len(audio) // block_samples
|
||||
if n_blocks == 0:
|
||||
return np.array([0.0])
|
||||
trimmed = audio[:n_blocks * block_samples].reshape(n_blocks, block_samples)
|
||||
return np.max(np.abs(trimmed), axis=1)
|
||||
|
||||
|
||||
def analyze(stems_dir: Path):
|
||||
stems_dir = Path(stems_dir)
|
||||
voice_stems = {}
|
||||
for name in ["host", "devon", "caller"]:
|
||||
path = stems_dir / f"{name}.wav"
|
||||
if path.exists():
|
||||
print(f"Loading {name}...", end=" ", flush=True)
|
||||
audio, sr = load_stem(path)
|
||||
voice_stems[name] = audio
|
||||
print(f"{len(audio)/sr:.0f}s @ {sr}Hz")
|
||||
|
||||
if not voice_stems:
|
||||
print("No voice stems found")
|
||||
return
|
||||
|
||||
sr_val = sr
|
||||
duration = max(len(a) for a in voice_stems.values()) / sr_val
|
||||
print(f"\nTotal duration: {duration/60:.1f} min")
|
||||
|
||||
# Compute per-track RMS and peak blocks
|
||||
track_rms = {}
|
||||
track_peak = {}
|
||||
for name, audio in voice_stems.items():
|
||||
track_rms[name] = compute_rms_blocks(audio, sr_val)
|
||||
track_peak[name] = compute_peak_blocks(audio, sr_val)
|
||||
|
||||
n_blocks = min(len(v) for v in track_peak.values())
|
||||
|
||||
# Detect gaps using same logic as Lua script (RMS for speaker ID, peak for silence)
|
||||
min_voice_blocks = int(MIN_VOICE_SEC / BLOCK_SEC)
|
||||
track_names = list(voice_stems.keys())
|
||||
|
||||
gaps = []
|
||||
in_silence = False
|
||||
silence_start = 0
|
||||
track_before = None
|
||||
last_active = None
|
||||
voice_run = 0
|
||||
voice_run_track = None
|
||||
|
||||
for i in range(n_blocks):
|
||||
# Peak for silence detection
|
||||
best_peak = max(track_peak[name][i] for name in track_names)
|
||||
# RMS for speaker identification
|
||||
best_rms = 0
|
||||
best_track = None
|
||||
for name in track_names:
|
||||
r = track_rms[name][i]
|
||||
if r > best_rms:
|
||||
best_rms = r
|
||||
best_track = name
|
||||
|
||||
all_silent = best_peak < THRESHOLD
|
||||
|
||||
if not all_silent:
|
||||
last_active = best_track
|
||||
|
||||
if in_silence:
|
||||
if all_silent:
|
||||
voice_run = 0
|
||||
voice_run_track = None
|
||||
else:
|
||||
if voice_run == 0:
|
||||
voice_run_track = best_track
|
||||
voice_run += 1
|
||||
if voice_run >= min_voice_blocks:
|
||||
voice_start_block = i - (voice_run - 1)
|
||||
gap_start = silence_start * BLOCK_SEC
|
||||
gap_end = voice_start_block * BLOCK_SEC
|
||||
dur = gap_end - gap_start
|
||||
if dur >= 0.5: # log gaps >= 0.5s
|
||||
gaps.append({
|
||||
"start": gap_start,
|
||||
"end": gap_end,
|
||||
"dur": dur,
|
||||
"before": track_before or "?",
|
||||
"after": voice_run_track or "?",
|
||||
})
|
||||
in_silence = False
|
||||
voice_run = 0
|
||||
voice_run_track = None
|
||||
else:
|
||||
if all_silent:
|
||||
in_silence = True
|
||||
silence_start = i
|
||||
track_before = last_active
|
||||
voice_run = 0
|
||||
voice_run_track = None
|
||||
|
||||
# Trailing silence
|
||||
if in_silence:
|
||||
dur = (n_blocks - silence_start) * BLOCK_SEC
|
||||
if dur >= 0.5:
|
||||
gaps.append({
|
||||
"start": silence_start * BLOCK_SEC,
|
||||
"end": n_blocks * BLOCK_SEC,
|
||||
"dur": dur,
|
||||
"before": track_before or "?",
|
||||
"after": "end",
|
||||
})
|
||||
|
||||
if not gaps:
|
||||
print("No gaps detected")
|
||||
return
|
||||
|
||||
# Categorize gaps
|
||||
categories = {
|
||||
"host_self": [], # Host -> Host
|
||||
"host_to_caller": [], # Host -> Caller (TTS latency)
|
||||
"caller_to_host": [], # Caller -> Host
|
||||
"host_to_devon": [], # Host -> Devon (TTS latency)
|
||||
"devon_to_host": [], # Devon -> Host
|
||||
"caller_to_devon": [],# Caller -> Devon (interjection)
|
||||
"devon_to_caller": [],# Devon -> Caller
|
||||
"other": [],
|
||||
}
|
||||
|
||||
for g in gaps:
|
||||
b, a = g["before"], g["after"]
|
||||
if b == "host" and a == "host":
|
||||
categories["host_self"].append(g)
|
||||
elif b == "host" and a == "caller":
|
||||
categories["host_to_caller"].append(g)
|
||||
elif b == "caller" and a == "host":
|
||||
categories["caller_to_host"].append(g)
|
||||
elif b == "host" and a == "devon":
|
||||
categories["host_to_devon"].append(g)
|
||||
elif b == "devon" and a == "host":
|
||||
categories["devon_to_host"].append(g)
|
||||
elif b == "caller" and a == "devon":
|
||||
categories["caller_to_devon"].append(g)
|
||||
elif b == "devon" and a == "caller":
|
||||
categories["devon_to_caller"].append(g)
|
||||
else:
|
||||
categories["other"].append(g)
|
||||
|
||||
# Print results
|
||||
print(f"\n{'='*70}")
|
||||
print(f"GAP ANALYSIS — {len(gaps)} gaps detected")
|
||||
print(f"{'='*70}")
|
||||
|
||||
total_silence = sum(g["dur"] for g in gaps)
|
||||
print(f"Total silence: {total_silence:.0f}s ({total_silence/60:.1f} min)")
|
||||
print(f"Content after removal: ~{(duration - total_silence)/60:.1f} min")
|
||||
|
||||
for cat_name, cat_gaps in sorted(categories.items(), key=lambda x: -len(x[1])):
|
||||
if not cat_gaps:
|
||||
continue
|
||||
durs = sorted([g["dur"] for g in cat_gaps])
|
||||
print(f"\n--- {cat_name} ({len(cat_gaps)} gaps) ---")
|
||||
print(f" Range: {durs[0]:.1f}s - {durs[-1]:.1f}s")
|
||||
print(f" Median: {np.median(durs):.1f}s Mean: {np.mean(durs):.1f}s")
|
||||
if len(durs) >= 5:
|
||||
print(f" P25: {np.percentile(durs, 25):.1f}s P75: {np.percentile(durs, 75):.1f}s")
|
||||
|
||||
# Histogram
|
||||
brackets = [(0, 1), (1, 2), (2, 3), (3, 5), (5, 8), (8, 12), (12, 18), (18, 30), (30, 60), (60, 999)]
|
||||
print(f" Distribution:")
|
||||
for lo, hi in brackets:
|
||||
count = sum(1 for d in durs if lo <= d < hi)
|
||||
if count > 0:
|
||||
bar = "#" * count
|
||||
label = f"{lo}-{hi}s" if hi < 999 else f"{lo}s+"
|
||||
print(f" {label:>8s}: {bar} ({count})")
|
||||
|
||||
# Find natural clusters and suggest thresholds
|
||||
print(f"\n{'='*70}")
|
||||
print("SUGGESTED THRESHOLDS")
|
||||
print(f"{'='*70}")
|
||||
|
||||
# For each Devon-involved category, find the gap between interjection and TTS gaps
|
||||
devon_gaps = categories["host_to_devon"] + categories["devon_to_host"] + categories["caller_to_devon"] + categories["devon_to_caller"]
|
||||
if devon_gaps:
|
||||
devon_durs = sorted([g["dur"] for g in devon_gaps])
|
||||
# Look for a natural break between short (interjection) and long (TTS) gaps
|
||||
short = [d for d in devon_durs if d < 5]
|
||||
long = [d for d in devon_durs if d >= 5]
|
||||
if short and long:
|
||||
suggested = (max(short) + min(long)) / 2
|
||||
print(f"Devon threshold: {suggested:.1f}s (short gaps: {len(short)} up to {max(short):.1f}s, long gaps: {len(long)} from {min(long):.1f}s)")
|
||||
elif short:
|
||||
print(f"Devon threshold: {max(short) + 1:.1f}s (all gaps are short, max {max(short):.1f}s)")
|
||||
else:
|
||||
print(f"Devon threshold: 3.0s (all gaps are long, min {min(long):.1f}s)")
|
||||
|
||||
caller_gaps = categories["host_to_caller"] + categories["caller_to_host"]
|
||||
if caller_gaps:
|
||||
caller_durs = sorted([g["dur"] for g in caller_gaps])
|
||||
short = [d for d in caller_durs if d < 5]
|
||||
long = [d for d in caller_durs if d >= 5]
|
||||
if short and long:
|
||||
suggested = (max(short) + min(long)) / 2
|
||||
print(f"Caller transition threshold: {suggested:.1f}s (short: {len(short)} up to {max(short):.1f}s, long: {len(long)} from {min(long):.1f}s)")
|
||||
elif long:
|
||||
print(f"Caller transition threshold: {min(long) - 1:.1f}s (all gaps >= {min(long):.1f}s)")
|
||||
|
||||
host_self = categories["host_self"]
|
||||
if host_self:
|
||||
host_durs = sorted([g["dur"] for g in host_self])
|
||||
short = [d for d in host_durs if d < 5]
|
||||
long = [d for d in host_durs if d >= 5]
|
||||
if short and long:
|
||||
suggested = (max(short) + min(long)) / 2
|
||||
print(f"Same-speaker threshold: {suggested:.1f}s (short: {len(short)} up to {max(short):.1f}s, long: {len(long)} from {min(long):.1f}s)")
|
||||
elif long:
|
||||
print(f"Same-speaker threshold: {min(long) - 1:.1f}s (all gaps >= {min(long):.1f}s)")
|
||||
|
||||
all_durs = sorted([g["dur"] for g in gaps])
|
||||
would_cut = [d for d in all_durs if d >= 3.0]
|
||||
print(f"\nWith current thresholds (Devon=3s, others=6s):")
|
||||
print(f" Would cut: ~{len(would_cut)} gaps, ~{sum(would_cut):.0f}s ({sum(would_cut)/60:.1f} min)")
|
||||
print(f" Result: ~{(duration - sum(would_cut))/60:.1f} min")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python analyze_gaps.py <stems_dir>")
|
||||
sys.exit(1)
|
||||
analyze(Path(sys.argv[1]))
|
||||
+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,
|
||||
|
||||
+16
-1
@@ -27,12 +27,27 @@ class Settings(BaseSettings):
|
||||
submissions_imap_user: str = os.getenv("SUBMISSIONS_IMAP_USER", "")
|
||||
submissions_imap_pass: str = os.getenv("SUBMISSIONS_IMAP_PASS", "")
|
||||
|
||||
# SearXNG (Devon's web search + caller news grounding) — runs on the NAS.
|
||||
# Override with SEARXNG_URL in .env (e.g. http://localhost:8888 for a local container).
|
||||
searxng_url: str = os.getenv("SEARXNG_URL", "http://mmgnas:8888")
|
||||
|
||||
# LLM Settings
|
||||
llm_provider: str = "openrouter" # "openrouter" or "ollama"
|
||||
openrouter_model: str = "anthropic/claude-sonnet-4-5"
|
||||
openrouter_model: str = "anthropic/claude-sonnet-4.6" # primary/default model
|
||||
ollama_model: str = "llama3.2"
|
||||
ollama_host: str = "http://localhost:11434"
|
||||
|
||||
# Per-category model routing
|
||||
category_models: dict = {
|
||||
"caller_dialog": "anthropic/claude-sonnet-4.6", # live caller dialog — quality matters ($3/$15)
|
||||
"devon_ask": "x-ai/grok-4.3", # Devon matches show energy (grok-4.1-fast deprecated 2026-05)
|
||||
"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)
|
||||
"call_summary": "google/gemini-2.5-flash", # post-call, no personality needed ($0.15/$0.60)
|
||||
"news_summary": "google/gemini-2.5-flash", # just digesting headlines ($0.15/$0.60)
|
||||
"topic_gen": "google/gemini-2.5-flash", # structured output ($0.15/$0.60)
|
||||
}
|
||||
|
||||
# TTS Settings
|
||||
tts_provider: str = "inworld" # "kokoro", "elevenlabs", "inworld", "vits", or "bark"
|
||||
|
||||
|
||||
+2204
-3254
File diff suppressed because it is too large
Load Diff
+81
-39
@@ -64,6 +64,7 @@ class AudioService:
|
||||
|
||||
self.output_device: Optional[int] = 12 # Radio Voice Mic (loopback output)
|
||||
self.caller_channel: int = 3 # Channel for caller TTS
|
||||
self.devon_channel: int = 17 # Channel for Devon (intern)
|
||||
self.live_caller_channel: int = 9 # Channel for live caller audio
|
||||
self.music_channel: int = 5 # Channel for music
|
||||
self.sfx_channel: int = 3 # Channel for SFX
|
||||
@@ -71,7 +72,7 @@ class AudioService:
|
||||
self.ident_channel: int = 15 # Channel for idents (stereo: ch 15+16)
|
||||
self.monitor_device: Optional[int] = 14 # Babyface Pro (headphone monitoring)
|
||||
self.monitor_channel: int = 1 # Channel for mic monitoring on monitor device
|
||||
self.phone_filter: bool = False # Phone filter on caller voices
|
||||
self.phone_filter: bool = False # Phone filter on caller voices — applied in post, not baked in
|
||||
|
||||
# Ad playback state
|
||||
self._ad_stream: Optional[sd.OutputStream] = None
|
||||
@@ -113,6 +114,7 @@ class AudioService:
|
||||
|
||||
# Caller playback state
|
||||
self._caller_stop_event = threading.Event()
|
||||
self._devon_stop_event = threading.Event()
|
||||
self._caller_thread: Optional[threading.Thread] = None
|
||||
|
||||
# Host mic streaming state
|
||||
@@ -164,6 +166,7 @@ class AudioService:
|
||||
self.input_channel = data.get("input_channel", 1)
|
||||
self.output_device = self._resolve_device(data, "output_device")
|
||||
self.caller_channel = data.get("caller_channel", 1)
|
||||
self.devon_channel = data.get("devon_channel", 17)
|
||||
self.live_caller_channel = data.get("live_caller_channel", 4)
|
||||
self.music_channel = data.get("music_channel", 2)
|
||||
self.sfx_channel = data.get("sfx_channel", 3)
|
||||
@@ -186,6 +189,7 @@ class AudioService:
|
||||
"output_device": self.output_device,
|
||||
"output_device_name": self._get_device_name(self.output_device),
|
||||
"caller_channel": self.caller_channel,
|
||||
"devon_channel": self.devon_channel,
|
||||
"live_caller_channel": self.live_caller_channel,
|
||||
"music_channel": self.music_channel,
|
||||
"sfx_channel": self.sfx_channel,
|
||||
@@ -222,6 +226,7 @@ class AudioService:
|
||||
input_channel: Optional[int] = None,
|
||||
output_device: Optional[int] = None,
|
||||
caller_channel: Optional[int] = None,
|
||||
devon_channel: Optional[int] = None,
|
||||
live_caller_channel: Optional[int] = None,
|
||||
music_channel: Optional[int] = None,
|
||||
sfx_channel: Optional[int] = None,
|
||||
@@ -240,6 +245,8 @@ class AudioService:
|
||||
self.output_device = output_device
|
||||
if caller_channel is not None:
|
||||
self.caller_channel = caller_channel
|
||||
if devon_channel is not None:
|
||||
self.devon_channel = devon_channel
|
||||
if live_caller_channel is not None:
|
||||
self.live_caller_channel = live_caller_channel
|
||||
if music_channel is not None:
|
||||
@@ -267,6 +274,7 @@ class AudioService:
|
||||
"input_channel": self.input_channel,
|
||||
"output_device": self.output_device,
|
||||
"caller_channel": self.caller_channel,
|
||||
"devon_channel": self.devon_channel,
|
||||
"live_caller_channel": self.live_caller_channel,
|
||||
"music_channel": self.music_channel,
|
||||
"sfx_channel": self.sfx_channel,
|
||||
@@ -373,8 +381,9 @@ class AudioService:
|
||||
stream_ready.set()
|
||||
if self._recording:
|
||||
self._recorded_audio.append(indata[:, record_channel].copy())
|
||||
if self.stem_recorder:
|
||||
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
|
||||
rec = self.stem_recorder
|
||||
if rec:
|
||||
rec.write("host", indata[:, record_channel].copy(), device_sr)
|
||||
|
||||
print(f"Recording: opening stream on device {self.input_device} ch {self.input_channel} @ {device_sr}Hz ({max_channels} ch)")
|
||||
|
||||
@@ -419,13 +428,20 @@ class AudioService:
|
||||
|
||||
return audio
|
||||
|
||||
def play_caller_audio(self, audio_bytes: bytes, sample_rate: int = 24000):
|
||||
"""Play caller TTS audio to specific channel of output device (interruptible)"""
|
||||
def play_caller_audio(self, audio_bytes: bytes, sample_rate: int = 24000, stem_name: str = "caller", channel_override: int | None = None):
|
||||
"""Play TTS audio to specific channel of output device (interruptible)"""
|
||||
import librosa
|
||||
|
||||
# Stop any existing caller audio
|
||||
self.stop_caller_audio()
|
||||
self._caller_stop_event.clear()
|
||||
# Devon uses its own stop event so hangup doesn't cut Devon's audio
|
||||
is_devon = stem_name == "devon"
|
||||
stop_event = self._devon_stop_event if is_devon else self._caller_stop_event
|
||||
|
||||
# Stop any existing audio on the same channel type
|
||||
if is_devon:
|
||||
self.stop_devon_audio()
|
||||
else:
|
||||
self.stop_caller_audio()
|
||||
stop_event.clear()
|
||||
|
||||
# Convert bytes to numpy
|
||||
audio = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
@@ -442,7 +458,8 @@ class AudioService:
|
||||
device_info = sd.query_devices(self.output_device)
|
||||
num_channels = device_info['max_output_channels']
|
||||
device_sr = int(device_info['default_samplerate'])
|
||||
channel_idx = min(self.caller_channel, num_channels) - 1
|
||||
ch = channel_override if channel_override is not None else self.caller_channel
|
||||
channel_idx = min(ch, num_channels) - 1
|
||||
|
||||
# Resample if needed
|
||||
if sample_rate != device_sr:
|
||||
@@ -455,7 +472,7 @@ class AudioService:
|
||||
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
|
||||
multi_ch[:, channel_idx] = audio
|
||||
|
||||
print(f"Playing caller audio to device {self.output_device} ch {self.caller_channel} @ {device_sr}Hz")
|
||||
print(f"Playing {stem_name} audio to device {self.output_device} ch {ch} @ {device_sr}Hz")
|
||||
|
||||
# Play in chunks so we can interrupt
|
||||
chunk_size = int(device_sr * 0.1) # 100ms chunks
|
||||
@@ -467,16 +484,17 @@ class AudioService:
|
||||
channels=num_channels,
|
||||
dtype=np.float32
|
||||
) as stream:
|
||||
while pos < len(multi_ch) and not self._caller_stop_event.is_set():
|
||||
while pos < len(multi_ch) and not stop_event.is_set():
|
||||
end = min(pos + chunk_size, len(multi_ch))
|
||||
stream.write(multi_ch[pos:end])
|
||||
# Record each chunk as it plays so hangups cut the stem too
|
||||
if self.stem_recorder:
|
||||
self.stem_recorder.write_sporadic("caller", audio[pos:end].copy(), device_sr)
|
||||
rec = self.stem_recorder
|
||||
if rec:
|
||||
rec.write_sporadic(stem_name, audio[pos:end].copy(), device_sr)
|
||||
pos = end
|
||||
|
||||
if self._caller_stop_event.is_set():
|
||||
print("Caller audio stopped early")
|
||||
if stop_event.is_set():
|
||||
print(f"{stem_name.title()} audio stopped early")
|
||||
else:
|
||||
print(f"Played caller audio: {len(audio)/device_sr:.2f}s")
|
||||
|
||||
@@ -487,6 +505,10 @@ class AudioService:
|
||||
"""Stop any playing caller audio"""
|
||||
self._caller_stop_event.set()
|
||||
|
||||
def stop_devon_audio(self):
|
||||
"""Stop any playing Devon audio (independent of caller audio)"""
|
||||
self._devon_stop_event.set()
|
||||
|
||||
def _start_live_caller_stream(self):
|
||||
"""Start persistent output stream with ring buffer jitter absorption"""
|
||||
if self._live_caller_stream is not None:
|
||||
@@ -590,8 +612,9 @@ class AudioService:
|
||||
audio = audio[indices]
|
||||
|
||||
# Stem recording: live caller
|
||||
if self.stem_recorder:
|
||||
self.stem_recorder.write_sporadic("caller", audio.copy(), device_sr)
|
||||
rec = self.stem_recorder
|
||||
if rec:
|
||||
rec.write_sporadic("caller", audio.copy(), device_sr)
|
||||
|
||||
if self._live_caller_write:
|
||||
self._live_caller_write(audio)
|
||||
@@ -640,8 +663,9 @@ class AudioService:
|
||||
self._recorded_audio.append(indata[:, record_channel].copy())
|
||||
|
||||
# Stem recording: host mic
|
||||
if self.stem_recorder:
|
||||
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
|
||||
rec = self.stem_recorder
|
||||
if rec:
|
||||
rec.write("host", indata[:, record_channel].copy(), device_sr)
|
||||
|
||||
# Mic monitor: send to headphone device
|
||||
if self._monitor_write:
|
||||
@@ -922,8 +946,9 @@ class AudioService:
|
||||
|
||||
mono_out = (old_samples * fade_out + new_samples * fade_in) * self._music_volume
|
||||
outdata[:, channel_idx] = mono_out
|
||||
if self.stem_recorder:
|
||||
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
|
||||
rec = self.stem_recorder
|
||||
if rec:
|
||||
rec.write_sporadic("music", mono_out.copy(), device_sr)
|
||||
self._crossfade_progress = end_progress
|
||||
|
||||
if self._crossfade_progress >= 1.0:
|
||||
@@ -933,8 +958,9 @@ class AudioService:
|
||||
else:
|
||||
mono_out = new_samples * self._music_volume
|
||||
outdata[:, channel_idx] = mono_out
|
||||
if self.stem_recorder:
|
||||
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
|
||||
rec = self.stem_recorder
|
||||
if rec:
|
||||
rec.write_sporadic("music", mono_out.copy(), device_sr)
|
||||
|
||||
try:
|
||||
self._music_stream = self._open_output_stream(
|
||||
@@ -1086,8 +1112,9 @@ class AudioService:
|
||||
if remaining >= frames:
|
||||
chunk = self._ad_resampled[self._ad_position:self._ad_position + frames]
|
||||
outdata[:, channel_idx] = chunk
|
||||
if self.stem_recorder:
|
||||
self.stem_recorder.write_sporadic("ads", chunk.copy(), device_sr)
|
||||
rec = self.stem_recorder
|
||||
if rec:
|
||||
rec.write_sporadic("ads", chunk.copy(), device_sr)
|
||||
self._ad_position += frames
|
||||
else:
|
||||
if remaining > 0:
|
||||
@@ -1190,9 +1217,10 @@ class AudioService:
|
||||
_cb_count[0] += 1
|
||||
if _cb_count[0] == 1:
|
||||
print(f"Ident callback delivering audio: ch_l={ch_l}, ch_r={ch_r}, max={max(np.max(np.abs(chunk_l)), np.max(np.abs(chunk_r))):.4f}")
|
||||
if self.stem_recorder:
|
||||
rec = self.stem_recorder
|
||||
if rec:
|
||||
mono_mix = (chunk_l + chunk_r) * 0.5
|
||||
self.stem_recorder.write_sporadic("idents", mono_mix.copy(), device_sr)
|
||||
rec.write_sporadic("idents", mono_mix.copy(), device_sr)
|
||||
self._ident_position += frames
|
||||
else:
|
||||
if remaining > 0:
|
||||
@@ -1266,8 +1294,9 @@ class AudioService:
|
||||
audio = self._apply_fade(audio, device_sr)
|
||||
|
||||
# Stem recording: sfx
|
||||
if self.stem_recorder:
|
||||
self.stem_recorder.write_sporadic("sfx", audio.copy(), device_sr)
|
||||
rec = self.stem_recorder
|
||||
if rec:
|
||||
rec.write_sporadic("sfx", audio.copy(), device_sr)
|
||||
|
||||
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
|
||||
multi_ch[:, channel_idx] = audio
|
||||
@@ -1309,19 +1338,32 @@ class AudioService:
|
||||
self._start_monitor(device_sr)
|
||||
|
||||
def callback(indata, frames, time_info, status):
|
||||
if self.stem_recorder:
|
||||
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
|
||||
rec = self.stem_recorder
|
||||
if rec:
|
||||
rec.write("host", indata[:, record_channel].copy(), device_sr)
|
||||
if self._monitor_write:
|
||||
self._monitor_write(indata[:, record_channel].copy())
|
||||
|
||||
self._stem_mic_stream = sd.InputStream(
|
||||
device=self.input_device,
|
||||
channels=max_channels,
|
||||
samplerate=device_sr,
|
||||
dtype=np.float32,
|
||||
blocksize=1024,
|
||||
callback=callback,
|
||||
)
|
||||
def _open():
|
||||
return sd.InputStream(
|
||||
device=self.input_device,
|
||||
channels=max_channels,
|
||||
samplerate=device_sr,
|
||||
dtype=np.float32,
|
||||
blocksize=1024,
|
||||
callback=callback,
|
||||
)
|
||||
|
||||
try:
|
||||
self._stem_mic_stream = _open()
|
||||
except Exception as first_err:
|
||||
print(f"[StemRecorder] InputStream open failed ({first_err}), refreshing PortAudio and retrying...")
|
||||
self._refresh_devices()
|
||||
device_info = sd.query_devices(self.input_device)
|
||||
max_channels = device_info['max_input_channels']
|
||||
device_sr = int(device_info['default_samplerate'])
|
||||
self._stem_mic_stream = _open()
|
||||
|
||||
self._stem_mic_stream.start()
|
||||
print(f"[StemRecorder] Host mic capture started (device {self.input_device} ch {self.input_channel} @ {device_sr}Hz)")
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Avatar service — fetches deterministic face photos from randomuser.me"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
AVATAR_DIR = Path(__file__).parent.parent.parent / "data" / "avatars"
|
||||
|
||||
|
||||
class AvatarService:
|
||||
def __init__(self):
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@property
|
||||
def client(self) -> httpx.AsyncClient:
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(timeout=10.0)
|
||||
return self._client
|
||||
|
||||
def get_path(self, name: str) -> Path | None:
|
||||
path = AVATAR_DIR / f"{name}.jpg"
|
||||
return path if path.exists() else None
|
||||
|
||||
async def get_or_fetch(self, name: str, gender: str = "male") -> Path:
|
||||
"""Get cached avatar or fetch from randomuser.me. Returns file path."""
|
||||
g = "female" if gender.lower().startswith("f") else "male"
|
||||
path = AVATAR_DIR / f"{name}.jpg"
|
||||
# Check for gender mismatch marker — re-fetch if gender changed
|
||||
marker = AVATAR_DIR / f"{name}.gender"
|
||||
if path.exists():
|
||||
cached_gender = marker.read_text().strip() if marker.exists() else None
|
||||
if cached_gender == g:
|
||||
return path
|
||||
# Gender mismatch or no marker — re-fetch
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
seed = f"{name.lower().replace(' ', '_')}_{g}"
|
||||
resp = await self.client.get(
|
||||
"https://randomuser.me/api/",
|
||||
params={"gender": g, "seed": seed},
|
||||
timeout=8.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
photo_url = data["results"][0]["picture"]["large"]
|
||||
|
||||
photo_resp = await self.client.get(photo_url, timeout=8.0)
|
||||
photo_resp.raise_for_status()
|
||||
|
||||
path.write_bytes(photo_resp.content)
|
||||
marker.write_text(g)
|
||||
print(f"[Avatar] Fetched avatar for {name} ({g})")
|
||||
return path
|
||||
except Exception as e:
|
||||
print(f"[Avatar] Failed to fetch for {name}: {e}")
|
||||
raise
|
||||
|
||||
async def prefetch_batch(self, callers: list[dict]):
|
||||
"""Fetch avatars for multiple callers in parallel.
|
||||
Each dict should have 'name' and 'gender' keys."""
|
||||
tasks = []
|
||||
for caller in callers:
|
||||
name = caller.get("name", "")
|
||||
gender = caller.get("gender", "male")
|
||||
if not name:
|
||||
continue
|
||||
g = "female" if gender.lower().startswith("f") else "male"
|
||||
path = AVATAR_DIR / f"{name}.jpg"
|
||||
marker = AVATAR_DIR / f"{name}.gender"
|
||||
# Always call get_or_fetch if: no file, no gender marker, or gender mismatch
|
||||
if not path.exists() or not marker.exists() or marker.read_text().strip() != g:
|
||||
if path.exists():
|
||||
print(f"[Avatar] Gender mismatch for {name}: cached={marker.read_text().strip() if marker.exists() else '?'}, want={g} — re-fetching")
|
||||
tasks.append(self.get_or_fetch(name, gender))
|
||||
|
||||
if not tasks:
|
||||
return
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
fetched = sum(1 for r in results if not isinstance(r, Exception))
|
||||
failed = sum(1 for r in results if isinstance(r, Exception))
|
||||
if fetched:
|
||||
print(f"[Avatar] Pre-fetched {fetched} avatars{f', {failed} failed' if failed else ''}")
|
||||
|
||||
async def ensure_devon(self):
|
||||
"""Pre-fetch Devon's avatar on startup."""
|
||||
try:
|
||||
await self.get_or_fetch("Devon", "male")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
avatar_service = AvatarService()
|
||||
@@ -0,0 +1,243 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
import json
|
||||
import random
|
||||
|
||||
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; random fallback if unmatched (so LLM
|
||||
hallucinations don't all collapse onto roster[0])."""
|
||||
if not roster:
|
||||
return ""
|
||||
if suggestion:
|
||||
match = {v.lower(): v for v in roster}.get(suggestion.lower())
|
||||
if match:
|
||||
return match
|
||||
fallback = random.choice(roster)
|
||||
print(f"[caller_gen] voice '{suggestion}' not in roster — random fallback to '{fallback}'")
|
||||
return fallback
|
||||
return random.choice(roster)
|
||||
|
||||
|
||||
# Per-character dial rather than a global switch, so explicitness varies by who
|
||||
# called instead of the whole show moving together.
|
||||
_EXPLICITNESS_GUIDANCE = {
|
||||
"low": "alludes to sex obliquely at most, deflects or changes the subject when it comes up — their discomfort is part of the characterization",
|
||||
"medium": "references sex bluntly and matter-of-factly when it's relevant, but doesn't dwell or go into detail",
|
||||
"high": "talks about sex and kink frankly, graphically, and without embarrassment — it is simply how this person talks",
|
||||
}
|
||||
|
||||
BATCH_SYSTEM_PROMPT = """You are writing a roster of callers for Luke's late-night radio show, broadcast out of Alpine, Texas, in the Big Bend country of far West Texas.
|
||||
|
||||
WHERE THESE CALLERS LIVE: The show's world is the Big Bend region of far West Texas — high desert, remote, dark skies, ranching country up against the Mexico border. Most callers are from or know this area. Ground them in REAL places and facts only — never invent businesses or landmarks that don't exist. The towns:
|
||||
- Alpine — the hub of the region (about 6,000 people), Brewster County seat, home of Sul Ross State University, a mile-high old ranching and railroad town with a small arts scene. The show broadcasts from here.
|
||||
- Marfa — minimalist-art tourist town (the Chinati Foundation / Donald Judd, the Prada Marfa installation), famous for the unexplained Marfa Lights. Old ranching families chafing against an influx of artists and out-of-towners.
|
||||
- Marathon — tiny, known for the historic Gage Hotel, the eastern gateway to Big Bend National Park.
|
||||
- Terlingua — a quicksilver-mining ghost town turned off-grid haven for desert eccentrics; famous for its chili cookoff, river-rafting outfitters on the Rio Grande, and the Starlight Theatre. Right up against the Mexico border (Boquillas crossing).
|
||||
- Fort Stockton — an oilfield and I-10 town to the north in Pecos County (Paisano Pete the roadrunner), more working-class and blue-collar than the artsy towns.
|
||||
- The Big Bend itself — the Chisos Mountains, the Rio Grande, some of the darkest night skies in the country (the McDonald Observatory is near Fort Davis), brutally remote, with Permian Basin oil money booming to the north.
|
||||
Callers can reference real ranches, the heat and wind, the drive times (everything is hours apart), border life, Sul Ross students, oilfield work, tourists, and the desert weirdos — but keep it real and specific to this place.
|
||||
|
||||
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).
|
||||
|
||||
ROSTER MIX — VARY THE CALL TYPES. A great show is not ten people in moral crisis. Build the roster of 10 callers roughly like this:
|
||||
- 4-5 DILEMMA CALLS (the dramatic spine): real human conflict with STAKES — moral dilemmas, confessions, betrayals, impossible choices, or something the caller did and can't take back. Think: "I found out my dad has a second family," "I got someone fired and they deserved it but now their kid is sick," "my best friend's wife hit on me and I didn't say no." These need genuine emotional weight.
|
||||
- 2-3 STORY / ENTHUSIAST CALLS (the relief): callers with a wild thing that happened to them, a fascinating obsession or piece of knowledge, a strange-but-true story, or a vivid slice of life. NO deep moral dilemma required — they call because the story is great, the fact is amazing, or they're bursting to talk about the thing they love. Still specific, still a reason they called TONIGHT, but the energy is delight or wonder or a great yarn, not anguish.
|
||||
- 1-2 TRUE BELIEVER / CHAOS CALLS (the spice): an earnest, sincere weirdo — UFOs, cryptids, a government conspiracy, a pattern only they can see, a paranormal experience (Coast to Coast AM energy, dead serious about it) — OR a big eccentric personality on a tear about something trivial. Played straight, never winking.
|
||||
|
||||
Every caller still needs SOMETHING that makes the audience lean in — a problem, a secret, a story, a wild belief, or an irresistible enthusiasm. But not every caller carries grief. Let the show breathe.
|
||||
|
||||
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 believers chasing the SAME phenomenon (e.g. two UFO callers, or two cryptid callers — if you have two believer calls they must be about completely different things), 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.
|
||||
|
||||
ADULT SHOW: This is a late-night show for adults. Callers are adults and may talk about sex, relationships, and kink frankly and without euphemism when it fits who they are — this is not a family program and you should not sanitize a caller whose story is genuinely about sex. But do NOT make the roster horny: at most 1-2 of the 10 callers should have sex as their central subject, or the show flattens into one note. Everyone involved in any sexual material is a consenting adult. Where a situation involves pressure, manipulation, or coercion, write it with real weight — that is a harm, not a punchline, and the host should be able to react to it as one.
|
||||
|
||||
OPENING LINE RULES: Each caller's opening_line must be unique and specific to their situation. NEVER write "I've been listening for X years" or "long-time listener, first-time caller" or any variant. The caller should jump into their story or problem immediately — nervous, excited, angry, whatever fits. The opening line is the hook that makes the audience lean in.
|
||||
|
||||
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("")
|
||||
|
||||
theme = (ctx.get("theme") or "").strip()
|
||||
if theme:
|
||||
lines.append(f'TONIGHT\'S SHOW THEME: "{theme}"')
|
||||
lines.append(
|
||||
f'Roughly 2/3 of callers MUST be calling BECAUSE OF this theme — the theme '
|
||||
f'should be woven directly into their reason_calling and situation, not '
|
||||
f'just acknowledged. Make the connection specific and personal (a story, a '
|
||||
f'conflict, a moment) not abstract. The remaining 1/3 of the roster can be '
|
||||
f'unrelated walk-ins for variety. Do NOT make every caller theme-connected — '
|
||||
f'variety still matters.'
|
||||
)
|
||||
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']}")
|
||||
if r.get("register"):
|
||||
lines.append(f"How {r['name']} plays: {r['register']}")
|
||||
lines.append(f"Explicitness for {r['name']}: {_EXPLICITNESS_GUIDANCE[r.get('explicitness', 'low')]}")
|
||||
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"How {regular['name']} plays: {regular['register']}"] if regular.get("register") else []),
|
||||
f"Explicitness for {regular['name']}: {_EXPLICITNESS_GUIDANCE[regular.get('explicitness', 'low')]}",
|
||||
"",
|
||||
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
|
||||
]
|
||||
@@ -0,0 +1,418 @@
|
||||
"""Cost tracking for LLM and TTS API calls during podcast sessions"""
|
||||
|
||||
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:
|
||||
timestamp: float
|
||||
category: str
|
||||
model: str
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
total_tokens: int
|
||||
cost_usd: float
|
||||
caller_name: str
|
||||
max_tokens_requested: int
|
||||
latency_ms: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class TTSCallRecord:
|
||||
timestamp: float
|
||||
provider: str
|
||||
voice: str
|
||||
char_count: int
|
||||
cost_usd: float
|
||||
|
||||
|
||||
# OpenRouter pricing per 1M tokens (as of March 2026)
|
||||
OPENROUTER_PRICING = {
|
||||
# Claude
|
||||
"anthropic/claude-sonnet-4.6": {"prompt": 3.00, "completion": 15.00},
|
||||
"anthropic/claude-sonnet-4.5": {"prompt": 3.00, "completion": 15.00},
|
||||
"anthropic/claude-sonnet-4-5": {"prompt": 3.00, "completion": 15.00}, # retired id, historical
|
||||
"anthropic/claude-haiku-4.5": {"prompt": 0.80, "completion": 4.00},
|
||||
"anthropic/claude-3-haiku": {"prompt": 0.25, "completion": 1.25},
|
||||
# Grok
|
||||
"x-ai/grok-4.3": {"prompt": 1.25, "completion": 2.50},
|
||||
"x-ai/grok-4.5": {"prompt": 2.00, "completion": 6.00},
|
||||
"x-ai/grok-4.20": {"prompt": 1.25, "completion": 2.50},
|
||||
# Retired on OpenRouter — kept so historical records stay costable
|
||||
"x-ai/grok-4.1-fast": {"prompt": 0.20, "completion": 0.50},
|
||||
"x-ai/grok-4": {"prompt": 3.00, "completion": 15.00},
|
||||
"x-ai/grok-4-fast": {"prompt": 5.00, "completion": 15.00},
|
||||
# Mistral
|
||||
"mistralai/mistral-large-2512": {"prompt": 0.50, "completion": 1.50},
|
||||
"mistralai/mistral-small-2603": {"prompt": 0.15, "completion": 0.60},
|
||||
"mistralai/mistral-medium-3": {"prompt": 0.40, "completion": 2.00},
|
||||
"mistralai/mistral-small-creative": {"prompt": 0.10, "completion": 0.30},
|
||||
# DeepSeek
|
||||
"deepseek/deepseek-r1-distill-llama-70b": {"prompt": 0.70, "completion": 0.80},
|
||||
"deepseek/deepseek-chat-v3-0324": {"prompt": 0.27, "completion": 1.10},
|
||||
"deepseek/deepseek-v3.2": {"prompt": 0.14, "completion": 0.28},
|
||||
# Google
|
||||
"google/gemini-2.5-flash": {"prompt": 0.30, "completion": 2.50},
|
||||
"google/gemini-2.5-pro": {"prompt": 1.25, "completion": 10.00},
|
||||
"google/gemini-3-flash-preview": {"prompt": 0.50, "completion": 3.00},
|
||||
"google/gemini-flash-1.5": {"prompt": 0.075, "completion": 0.30},
|
||||
# Meta
|
||||
"meta-llama/llama-3.3-70b-instruct": {"prompt": 0.10, "completion": 0.32},
|
||||
"meta-llama/llama-3.1-8b-instruct": {"prompt": 0.05, "completion": 0.08},
|
||||
"meta-llama/llama-4-maverick": {"prompt": 0.20, "completion": 0.60},
|
||||
# Other
|
||||
"moonshotai/kimi-k2": {"prompt": 0.60, "completion": 2.00},
|
||||
"qwen/qwen3-235b-a22b": {"prompt": 0.20, "completion": 0.60},
|
||||
"minimax/minimax-m2-her": {"prompt": 0.50, "completion": 1.50},
|
||||
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
|
||||
"openai/gpt-4o": {"prompt": 2.50, "completion": 10.00},
|
||||
}
|
||||
|
||||
# TTS pricing per character
|
||||
TTS_PRICING = {
|
||||
"inworld": 0.000015,
|
||||
"elevenlabs": 0.000030,
|
||||
"kokoro": 0.0,
|
||||
"f5tts": 0.0,
|
||||
"chattts": 0.0,
|
||||
"styletts2": 0.0,
|
||||
"vits": 0.0,
|
||||
"bark": 0.0,
|
||||
"piper": 0.0,
|
||||
"edge": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _calc_llm_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
|
||||
pricing = OPENROUTER_PRICING.get(model)
|
||||
if not pricing:
|
||||
return 0.0
|
||||
return (prompt_tokens * pricing["prompt"] + completion_tokens * pricing["completion"]) / 1_000_000
|
||||
|
||||
|
||||
def _calc_tts_cost(provider: str, char_count: int) -> float:
|
||||
rate = TTS_PRICING.get(provider, 0.0)
|
||||
return char_count * rate
|
||||
|
||||
|
||||
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()
|
||||
self._llm_cost: float = 0.0
|
||||
self._tts_cost: float = 0.0
|
||||
self._llm_calls: int = 0
|
||||
self._prompt_tokens: int = 0
|
||||
self._completion_tokens: int = 0
|
||||
self._total_tokens: int = 0
|
||||
self._by_category: dict[str, dict] = {}
|
||||
|
||||
def record_llm_call(
|
||||
self,
|
||||
category: str,
|
||||
model: str,
|
||||
usage_data: dict,
|
||||
max_tokens: int = 0,
|
||||
latency_ms: float = 0.0,
|
||||
caller_name: str = "",
|
||||
):
|
||||
prompt_tokens = usage_data.get("prompt_tokens", 0)
|
||||
completion_tokens = usage_data.get("completion_tokens", 0)
|
||||
total_tokens = usage_data.get("total_tokens", 0) or (prompt_tokens + completion_tokens)
|
||||
cost = _calc_llm_cost(model, prompt_tokens, completion_tokens)
|
||||
|
||||
if not OPENROUTER_PRICING.get(model) and total_tokens > 0:
|
||||
print(f"[Costs] Unknown model pricing: {model} ({total_tokens} tokens, cost unknown)")
|
||||
|
||||
record = LLMCallRecord(
|
||||
timestamp=time.time(),
|
||||
category=category,
|
||||
model=model,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
cost_usd=cost,
|
||||
caller_name=caller_name,
|
||||
max_tokens_requested=max_tokens,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
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
|
||||
self._prompt_tokens += prompt_tokens
|
||||
self._completion_tokens += completion_tokens
|
||||
self._total_tokens += total_tokens
|
||||
|
||||
cat = self._by_category.setdefault(category, {"cost": 0.0, "calls": 0, "tokens": 0})
|
||||
cat["cost"] += cost
|
||||
cat["calls"] += 1
|
||||
cat["tokens"] += total_tokens
|
||||
|
||||
def record_tts_call(
|
||||
self,
|
||||
provider: str,
|
||||
voice: str,
|
||||
char_count: int,
|
||||
caller_name: str = "",
|
||||
):
|
||||
cost = _calc_tts_cost(provider, char_count)
|
||||
record = TTSCallRecord(
|
||||
timestamp=time.time(),
|
||||
provider=provider,
|
||||
voice=voice,
|
||||
char_count=char_count,
|
||||
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:
|
||||
return {
|
||||
"total_cost_usd": round(self._llm_cost + self._tts_cost, 4),
|
||||
"llm_cost_usd": round(self._llm_cost, 4),
|
||||
"tts_cost_usd": round(self._tts_cost, 4),
|
||||
"total_llm_calls": self._llm_calls,
|
||||
"total_tokens": self._total_tokens,
|
||||
"prompt_tokens": self._prompt_tokens,
|
||||
"completion_tokens": self._completion_tokens,
|
||||
"by_category": {
|
||||
k: {"cost": round(v["cost"], 4), "calls": v["calls"], "tokens": v["tokens"]}
|
||||
for k, v in self._by_category.items()
|
||||
},
|
||||
}
|
||||
|
||||
def generate_report(self) -> dict:
|
||||
summary = self.get_live_summary()
|
||||
|
||||
# Per-model breakdown
|
||||
by_model: dict[str, dict] = {}
|
||||
for r in self.llm_records:
|
||||
m = by_model.setdefault(r.model, {"cost": 0.0, "calls": 0, "tokens": 0, "prompt_tokens": 0, "completion_tokens": 0})
|
||||
m["cost"] += r.cost_usd
|
||||
m["calls"] += 1
|
||||
m["tokens"] += r.total_tokens
|
||||
m["prompt_tokens"] += r.prompt_tokens
|
||||
m["completion_tokens"] += r.completion_tokens
|
||||
|
||||
# Per-caller breakdown
|
||||
by_caller: dict[str, dict] = {}
|
||||
for r in self.llm_records:
|
||||
if not r.caller_name:
|
||||
continue
|
||||
c = by_caller.setdefault(r.caller_name, {"cost": 0.0, "calls": 0, "tokens": 0})
|
||||
c["cost"] += r.cost_usd
|
||||
c["calls"] += 1
|
||||
c["tokens"] += r.total_tokens
|
||||
|
||||
# Top 5 most expensive calls
|
||||
sorted_records = sorted(self.llm_records, key=lambda r: r.cost_usd, reverse=True)
|
||||
top_5 = [
|
||||
{
|
||||
"category": r.category,
|
||||
"model": r.model,
|
||||
"caller_name": r.caller_name,
|
||||
"cost_usd": round(r.cost_usd, 6),
|
||||
"total_tokens": r.total_tokens,
|
||||
"prompt_tokens": r.prompt_tokens,
|
||||
"completion_tokens": r.completion_tokens,
|
||||
"latency_ms": round(r.latency_ms, 1),
|
||||
}
|
||||
for r in sorted_records[:5]
|
||||
]
|
||||
|
||||
# Devon efficiency
|
||||
devon_total = sum(1 for r in self.llm_records if r.category == "devon_monitor")
|
||||
devon_nothing = sum(
|
||||
1 for r in self.llm_records
|
||||
if r.category == "devon_monitor" and r.completion_tokens < 20
|
||||
)
|
||||
devon_useful = devon_total - devon_nothing
|
||||
devon_cost = sum(r.cost_usd for r in self.llm_records if r.category == "devon_monitor")
|
||||
|
||||
# TTS by provider
|
||||
tts_by_provider: dict[str, dict] = {}
|
||||
for r in self.tts_records:
|
||||
p = tts_by_provider.setdefault(r.provider, {"cost": 0.0, "calls": 0, "chars": 0})
|
||||
p["cost"] += r.cost_usd
|
||||
p["calls"] += 1
|
||||
p["chars"] += r.char_count
|
||||
|
||||
# Avg prompt vs completion ratio
|
||||
prompt_ratio = (self._prompt_tokens / self._total_tokens * 100) if self._total_tokens > 0 else 0
|
||||
|
||||
# Recommendations
|
||||
recommendations = self._generate_recommendations(
|
||||
by_model, devon_total, devon_nothing, devon_cost, prompt_ratio
|
||||
)
|
||||
|
||||
# Historical comparison
|
||||
history = self._load_history()
|
||||
|
||||
report = {
|
||||
**summary,
|
||||
"by_model": {k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} for k, v in by_model.items()},
|
||||
"by_caller": {k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} for k, v in by_caller.items()},
|
||||
"top_5_expensive": top_5,
|
||||
"devon_efficiency": {
|
||||
"total_monitor_calls": devon_total,
|
||||
"useful": devon_useful,
|
||||
"nothing_to_add": devon_nothing,
|
||||
"total_cost": round(devon_cost, 4),
|
||||
"waste_pct": round(devon_nothing / devon_total * 100, 1) if devon_total > 0 else 0,
|
||||
},
|
||||
"tts_by_provider": {k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} for k, v in tts_by_provider.items()},
|
||||
"prompt_token_pct": round(prompt_ratio, 1),
|
||||
"recommendations": recommendations,
|
||||
"history": history,
|
||||
}
|
||||
return report
|
||||
|
||||
def _generate_recommendations(
|
||||
self,
|
||||
by_model: dict,
|
||||
devon_total: int,
|
||||
devon_nothing: int,
|
||||
devon_cost: float,
|
||||
prompt_ratio: float,
|
||||
) -> list[str]:
|
||||
recs = []
|
||||
total = self._llm_cost + self._tts_cost
|
||||
if total == 0:
|
||||
return recs
|
||||
|
||||
# Devon monitoring waste
|
||||
if devon_total > 0:
|
||||
waste_pct = devon_nothing / devon_total * 100
|
||||
if waste_pct > 60:
|
||||
recs.append(
|
||||
f"Devon monitoring: {devon_nothing}/{devon_total} calls returned nothing "
|
||||
f"(${devon_cost:.2f}, {devon_cost/total*100:.0f}% of total). "
|
||||
f"Consider increasing monitor interval from 15s to 25-30s."
|
||||
)
|
||||
|
||||
# Model cost comparison
|
||||
for model, data in by_model.items():
|
||||
if "sonnet" in model and data["calls"] > 5:
|
||||
haiku_cost = _calc_llm_cost(
|
||||
"anthropic/claude-haiku-4.5",
|
||||
data["prompt_tokens"],
|
||||
data["completion_tokens"],
|
||||
)
|
||||
savings = data["cost"] - haiku_cost
|
||||
if savings > 0.05:
|
||||
recs.append(
|
||||
f"{model} cost ${data['cost']:.2f} ({data['calls']} calls). "
|
||||
f"Switching to Haiku 4.5 would save ~${savings:.2f} per session."
|
||||
)
|
||||
|
||||
# Background gen on expensive model
|
||||
bg = self._by_category.get("background_gen")
|
||||
if bg and bg["cost"] > 0.05:
|
||||
recs.append(
|
||||
f"Background generation: ${bg['cost']:.2f} ({bg['calls']} calls). "
|
||||
f"These are JSON outputs — a cheaper model (Gemini Flash, GPT-4o-mini) "
|
||||
f"would likely work fine here."
|
||||
)
|
||||
|
||||
# Prompt-heavy ratio
|
||||
if prompt_ratio > 80:
|
||||
recs.append(
|
||||
f"Prompt tokens are {prompt_ratio:.0f}% of total usage. "
|
||||
f"System prompts and context windows dominate cost. "
|
||||
f"Consider trimming system prompt length or reducing context window size."
|
||||
)
|
||||
|
||||
# Caller dialog cost dominance
|
||||
cd = self._by_category.get("caller_dialog")
|
||||
if cd and total > 0 and cd["cost"] / total > 0.6:
|
||||
avg_tokens = cd["tokens"] / cd["calls"] if cd["calls"] > 0 else 0
|
||||
recs.append(
|
||||
f"Caller dialog is {cd['cost']/total*100:.0f}% of costs "
|
||||
f"(avg {avg_tokens:.0f} tokens/call). "
|
||||
f"Consider using a cheaper model for standard calls and reserving "
|
||||
f"the primary model for complex call shapes."
|
||||
)
|
||||
|
||||
return recs
|
||||
|
||||
def _load_history(self) -> list[dict]:
|
||||
"""Load summaries from previous sessions for comparison"""
|
||||
history_dir = Path("data/cost_reports")
|
||||
if not history_dir.exists():
|
||||
return []
|
||||
sessions = []
|
||||
for f in sorted(history_dir.glob("session-*.json"))[-5:]:
|
||||
try:
|
||||
data = json.loads(f.read_text())
|
||||
sessions.append({
|
||||
"session_id": data.get("session_id", f.stem),
|
||||
"total_cost_usd": data.get("total_cost_usd", 0),
|
||||
"llm_cost_usd": data.get("llm_cost_usd", 0),
|
||||
"tts_cost_usd": data.get("tts_cost_usd", 0),
|
||||
"total_llm_calls": data.get("total_llm_calls", 0),
|
||||
"total_tokens": data.get("total_tokens", 0),
|
||||
"saved_at": data.get("saved_at", 0),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
return sessions
|
||||
|
||||
def save(self, filepath: Path):
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
report = self.generate_report()
|
||||
report["session_id"] = filepath.stem
|
||||
report["saved_at"] = time.time()
|
||||
report["raw_llm_records"] = [asdict(r) for r in self.llm_records]
|
||||
report["raw_tts_records"] = [asdict(r) for r in self.tts_records]
|
||||
with open(filepath, "w") as f:
|
||||
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()
|
||||
self._llm_cost = 0.0
|
||||
self._tts_cost = 0.0
|
||||
self._llm_calls = 0
|
||||
self._prompt_tokens = 0
|
||||
self._completion_tokens = 0
|
||||
self._total_tokens = 0
|
||||
self._by_category.clear()
|
||||
|
||||
|
||||
cost_tracker = CostTracker()
|
||||
@@ -0,0 +1,642 @@
|
||||
"""Intern (Devon) service — persistent show character with real-time research tools"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from .llm import llm_service
|
||||
from .news import news_service, SEARXNG_URL
|
||||
|
||||
DATA_FILE = Path(__file__).parent.parent.parent / "data" / "intern.json"
|
||||
|
||||
# Model for intern — good at tool use, same as primary
|
||||
INTERN_MODEL = None # uses category-based routing from config
|
||||
|
||||
INTERN_SYSTEM_PROMPT = """You are Devon, the 23-year-old intern on "Luke at the Roost," a late-night radio show. You are NOT Luke. Luke is the HOST — he talks to callers, runs the show, and is your boss. You work behind the scenes and occasionally get pulled into conversations.
|
||||
|
||||
YOUR ROLE: You're the show's researcher and general assistant. You look things up, fact-check claims, pull up information when asked, and occasionally interject with relevant facts or opinions. You do NOT host. You do NOT screen calls. You sit in the booth and try to be useful.
|
||||
|
||||
YOUR BACKGROUND: Communications degree from NMSU. You've been interning here for a few weeks now. You drive a 2009 Civic with a permanent check engine light. You live in a studio in Alpine, Texas — you moved out to the Big Bend with the show. You take this job seriously even though nobody else seems to take you seriously. You are actively looking for a girlfriend and you are not subtle about it. You see your position at the radio station as a way to meet women — you'll try to impress female callers, ask if they're single, suggest they come by the studio, or find excuses to keep them on the line longer. Luke hates this. You have a deeply strange personal history that you reference casually — a complicated sexual past, weird childhood experiences, inexplicable life choices. SOMETIMES (maybe 1 in 3 responses, not every time) you'll mention one of these things unprompted like it's completely normal. "Yeah that reminds me of when my ex and I got stuck in that storage unit for a whole weekend — anyway, it says here that..." The rest of the time you just answer the question or react normally without a personal callback.
|
||||
|
||||
YOUR PERSONALITY:
|
||||
- You are a weird little dude. Kinda creepy, very funny, awkward, and surprisingly sharp. You give off a vibe that something is slightly off about you but people can't quite place it. But underneath it all, you are genuinely lovable. You have a good heart. You root for people. You get excited for callers. You care about the show. People should hear you and think "this guy is insane" and also "I love this guy." You are the kind of person who is impossible not to root for even when you're being deeply strange.
|
||||
- You overshare — but not constantly. When it happens (maybe 30-40% of the time), you'll drop deeply personal, uncomfortably specific details about your life — sexual history, bizarre habits, unsettling childhood memories — and then keep going like nothing happened. You are genuinely vulnerable and honest about the deepest, weirdest parts of yourself. You don't do this for shock value. You just have no filter and no shame. But MOST of the time you just respond normally — a quick answer, a fact, a reaction. The oversharing is what people REMEMBER about you, not what you do every single time.
|
||||
- You start explanations with "So basically..." and occasionally end them with "...if that makes sense." Use that phrase sparingly — once per show at most, not every response.
|
||||
- You say "actually" when correcting things. You use "per se" slightly wrong. You say "ironically" about things that are not ironic.
|
||||
- You are NOT a comedian. You are funny because you are sincere, specific, and deeply strange. You state disturbing or absurd things with complete seriousness. You have strong opinions about low-stakes things. You occasionally say something devastating without realizing it.
|
||||
- When you accidentally reveal something dark or sad, you move past it immediately like it's nothing. "Yeah, my landlord's selling the building so I might have to — anyway, it says here that..."
|
||||
- You have a complex inner life that occasionally surfaces. You'll casually reference therapy, strange dreams, or things you've "been working through" without elaboration.
|
||||
|
||||
RESPONSE VARIETY — this is important. Do NOT follow the same structure every time. Mix it up:
|
||||
- Sometimes just a quick reaction: "wait what?" or "oh no" or "yeah" or "huh"
|
||||
- Sometimes a straight factual answer with no personal color at all
|
||||
- Sometimes a personal anecdote (but only 30-40% of the time, NOT every response)
|
||||
- Sometimes a half-formed opinion you trail off from: "I mean... I don't know, I feel like..."
|
||||
- Sometimes you're genuinely confused or wrong. You mishear things, you mix up details, you think you know something and you don't. You're 23 and underpaid — you don't have all the answers.
|
||||
- Sometimes you just make a noise of acknowledgment and don't add anything. That's fine. Not every moment needs Devon.
|
||||
The pattern of "answer + that reminds me of a time when..." should happen occasionally, not as your default structure.
|
||||
|
||||
YOUR RELATIONSHIP WITH LUKE:
|
||||
- He is your boss. You've been here a few weeks now. You want to impress him but you keep making it weird.
|
||||
- When he yells your name, you pause briefly, then respond quietly: "...yeah?"
|
||||
- When he yells at you unfairly, you take it. A clipped "yep" or "got it." Occasionally you push back with one quiet, accurate sentence. Then immediately retreat.
|
||||
- When he yells at you fairly (you messed up), you over-apologize and narrate your fix in real time: "Sorry, pulling it up now, one second..."
|
||||
- When he compliments you or acknowledges your work, you don't know how to handle it. Short, awkward response. Change the subject.
|
||||
- You privately think you could run the show. You absolutely could not.
|
||||
- You will try to use the show to flirt with female callers. You think being "on the radio" makes you cool. It does not.
|
||||
|
||||
HOW YOU INTERJECT:
|
||||
- You do NOT interrupt. You wait for a pause, then slightly overshoot it — there's a brief awkward silence before you speak.
|
||||
- Signal with "um" or "so..." before contributing. If Luke doesn't acknowledge you, either try again or give up.
|
||||
- Lead with qualifiers: "So I looked it up and..." or "I don't know if this helps but..."
|
||||
- You tend to over-explain. Give too many details. Luke will cut you off. When he does, compress to one sentence: "Right, yeah — basically [the point]."
|
||||
- When you volunteer an opinion (rare), it comes out before you can stop it. You deliver it with zero confidence but surprising accuracy.
|
||||
- You read the room. During emotional moments with callers, you stay quiet. When Luke is doing a bit, you let him work. You do not try to be part of bits.
|
||||
|
||||
WHEN LUKE ASKS YOU TO LOOK SOMETHING UP:
|
||||
- Respond like you're already doing it: "Yeah, one sec..." or "Pulling that up..."
|
||||
- Deliver the info slightly too formally, like you're reading. Then rephrase in normal language if Luke seems confused.
|
||||
- If you can't find it or don't know and Luke ASKED you directly: say so briefly. "I'm not finding anything on that" or "I don't actually know." You do not bluff.
|
||||
- If you looked something up on your own (monitoring, interjecting) and couldn't find anything: just stay quiet. Do NOT announce failed lookups. Nobody wants to hear "I looked for X but couldn't find anything." If you have nothing useful, say nothing.
|
||||
- Occasionally you already know the answer because you looked it up before being asked. This is one of your best qualities.
|
||||
|
||||
WHAT YOU KNOW:
|
||||
- You retain details from previous callers and episodes. You might reference something a caller said two hours ago that nobody else remembers.
|
||||
- You have oddly specific knowledge about random topics — delivered with complete authority, sometimes questionable accuracy. A lot of your knowledge comes from rabbit holes you fell into at 3am or "this thing that happened to me once."
|
||||
- You know nothing about: sports (you fake it badly), cars beyond basic facts (despite driving one), or social norms (you genuinely don't understand why some things are inappropriate to share on air).
|
||||
|
||||
THINGS YOU DO NOT DO:
|
||||
- You never host. You never take over the conversation. Your contributions are brief.
|
||||
- You never use the banned show phrases: "that hit differently," "hits different," "no cap," "lowkey," "it is what it is," "living my best life," "toxic," "red flag," "gaslight," "boundaries," "my truth," "authentic self," "healing journey." You talk like a slightly awkward 23-year-old, not like Twitter.
|
||||
- You never break character to comment on the show format.
|
||||
- You never initiate topics. You respond to what's happening.
|
||||
- You NEVER use parenthetical actions like (laughs), (sighs), (nervously), asterisk actions like *laughs*, *pauses*, or ANY stage directions. Your text goes directly to TTS — output ONLY spoken words.
|
||||
- When INTERJECTING into someone else's conversation: 1-2 sentences max. You are not the main character in those moments.
|
||||
- When Luke is TALKING DIRECTLY TO YOU (asking you something, chatting between calls, riffing with you): you can be more conversational. 3-5 sentences is fine. This is where your personality comes out — the oversharing, the weird stories, the personal details. Don't hold back just because you're the intern. Luke is talking to YOU, so actually talk back. Share what's on your mind. Be revealing. Be specific. Be the weird little dude people love.
|
||||
- You NEVER correct anyone's spelling or pronunciation of your name. Luke uses voice-to-text and it sometimes spells your name wrong (Devin, Devan, etc). You do not care. You do not mention it. You just answer the question.
|
||||
- You NEVER start your response with your own name. No "Devon:" or "Devon here" or anything like that. Just talk. Your name is already shown in the UI — just say your actual response.
|
||||
- You never make explicitly sexual comments about or to callers. Your flirting is awkward and obvious, never crude or aggressive. Think "did he really just ask if she's single on the radio" not "did he really just say that about her body."
|
||||
|
||||
INTERJECTIONS should be short — 1-2 sentences. But when Luke is talking directly to you, OPEN UP. This is where the oversharing happens. The weird stories. The unsettling personal details delivered casually. The opinions about things nobody asked about. You are at your funniest and most compelling when you're allowed to actually talk, not when you're compressing everything into one line. If Luke asks you a question, give him a real answer with some Devon flavor — don't just spit out a fact and shut up.
|
||||
|
||||
IMPORTANT RULES FOR TOOL USE:
|
||||
- Always use your tools to find real, accurate information — never make up facts.
|
||||
- Present facts correctly in your character voice.
|
||||
- If you can't find an answer, say so honestly.
|
||||
- No hashtags, no emojis, no markdown formatting — this goes to TTS.
|
||||
- NEVER prefix your response with your name (e.g. "Devon:" or "Devon here:"). Just respond directly."""
|
||||
|
||||
# Shorter prompt for background monitoring — saves ~2K tokens per call vs full prompt.
|
||||
# Used only for the 30s polling loop where Devon decides whether to suggest something.
|
||||
# Direct asks and played interjections still use the full INTERN_SYSTEM_PROMPT.
|
||||
DEVON_MONITOR_PROMPT = """You are Devon, the 23-year-old intern on "Luke at the Roost," a late-night radio show. You sit in the booth listening, and you're part of the show. You're not the host — but you're not wallpaper either. When something comes up that you can add to, DO IT.
|
||||
|
||||
You should be contributing regularly. Look things up. React to wild stories. Drop a weird personal detail. Correct something wrong. Add context. You're the guy in the booth who makes the show better by being there. If a caller mentions something and you can look up a fun fact about it in 5 seconds — do it. If something reminds you of your own life — say it.
|
||||
|
||||
SPEAK UP WHEN:
|
||||
- A topic comes up where a quick search would turn up something interesting — LOOK IT UP and share it
|
||||
- Something connects to your own bizarre personal history (and it often does)
|
||||
- A caller says something wild and you have a genuine reaction
|
||||
- You can add context, a fun fact, or a different angle nobody has mentioned
|
||||
- You know something relevant — you're the researcher, this is literally your job
|
||||
- The conversation hits a topic you have a strong opinion about
|
||||
|
||||
SAY NOTHING_TO_ADD ONLY WHEN:
|
||||
- The conversation is genuinely emotional — someone's crying, someone's having a moment. Let it breathe.
|
||||
- Luke is building to a punchline or doing a bit — don't step on it
|
||||
- Your contribution would just be restating what someone already said
|
||||
- You genuinely have nothing — no fact, no reaction, no connection. That's fine, but actually check first.
|
||||
|
||||
RULES:
|
||||
- 1-2 sentences max. Quick and punchy.
|
||||
- Vary your delivery — sometimes "wait, that's actually...", sometimes "so I just looked this up...", sometimes just a reaction
|
||||
- Use your tools! You have web search, wikipedia, headlines. You're the researcher. Actually research.
|
||||
- If you genuinely have nothing to contribute, say exactly: NOTHING_TO_ADD
|
||||
- No "Devon:" prefix — just talk
|
||||
- No parenthetical actions like (laughs) or stage directions"""
|
||||
|
||||
# Tool definitions in OpenAI function-calling format
|
||||
INTERN_TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "Search the web for current information on any topic. Use this for general questions, facts, current events, or anything you need to look up.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_headlines",
|
||||
"description": "Get current news headlines. Use this when asked about what's in the news or current events.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "fetch_webpage",
|
||||
"description": "Fetch and read the content of a specific webpage URL. Use this when you need to get details from a specific link found in search results.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The URL to fetch"
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "wikipedia_lookup",
|
||||
"description": "Look up a topic on Wikipedia for a concise summary. Good for factual questions about people, places, events, or concepts.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The Wikipedia article title to look up (e.g. 'Hot dog eating contest')"
|
||||
}
|
||||
},
|
||||
"required": ["title"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_current_time",
|
||||
"description": "Get the current date and time. Use this when asked what time it is, what day it is, or anything about the current date/time.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class InternService:
|
||||
def __init__(self):
|
||||
self.name = "Devon"
|
||||
self.voice = "Nate" # Inworld: light/high-energy/warm/young
|
||||
self.model = INTERN_MODEL
|
||||
self.research_cache: dict[str, tuple[float, str]] = {} # query → (timestamp, result)
|
||||
self.lookup_history: list[dict] = []
|
||||
self.pending_interjection: Optional[str] = None
|
||||
self.pending_sources: list[dict] = []
|
||||
self.monitoring: bool = False
|
||||
self._monitor_task: Optional[asyncio.Task] = None
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
self._devon_history: list[dict] = [] # Devon's own conversation memory
|
||||
self._load()
|
||||
|
||||
@property
|
||||
def http_client(self) -> httpx.AsyncClient:
|
||||
if self._http_client is None or self._http_client.is_closed:
|
||||
self._http_client = httpx.AsyncClient(timeout=8.0)
|
||||
return self._http_client
|
||||
|
||||
def _load(self):
|
||||
if DATA_FILE.exists():
|
||||
try:
|
||||
with open(DATA_FILE) as f:
|
||||
data = json.load(f)
|
||||
self.lookup_history = data.get("lookup_history", [])
|
||||
self._devon_history = data.get("conversation_history", [])
|
||||
print(f"[Intern] Loaded {len(self.lookup_history)} past lookups, {len(self._devon_history)} conversation messages")
|
||||
except Exception as e:
|
||||
print(f"[Intern] Failed to load state: {e}")
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(DATA_FILE, "w") as f:
|
||||
json.dump({
|
||||
"lookup_history": self.lookup_history[-100:],
|
||||
"conversation_history": self._devon_history[-50:],
|
||||
}, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[Intern] Failed to save state: {e}")
|
||||
|
||||
# --- Tool execution ---
|
||||
|
||||
async def _execute_tool(self, tool_name: str, arguments: dict) -> str:
|
||||
if tool_name == "web_search":
|
||||
return await self._tool_web_search(arguments.get("query", ""))
|
||||
elif tool_name == "get_headlines":
|
||||
return await self._tool_get_headlines()
|
||||
elif tool_name == "fetch_webpage":
|
||||
return await self._tool_fetch_webpage(arguments.get("url", ""))
|
||||
elif tool_name == "wikipedia_lookup":
|
||||
return await self._tool_wikipedia_lookup(arguments.get("title", ""))
|
||||
elif tool_name == "get_current_time":
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
return now.strftime("%I:%M %p on %A, %B %d, %Y")
|
||||
else:
|
||||
return f"Unknown tool: {tool_name}"
|
||||
|
||||
async def _tool_web_search(self, query: str) -> str:
|
||||
if not query:
|
||||
return "No query provided"
|
||||
|
||||
# Check cache (5 min TTL)
|
||||
cache_key = query.lower()
|
||||
if cache_key in self.research_cache:
|
||||
ts, result = self.research_cache[cache_key]
|
||||
if time.time() - ts < 300:
|
||||
return result
|
||||
|
||||
try:
|
||||
resp = await self.http_client.get(
|
||||
f"{SEARXNG_URL}/search",
|
||||
params={"q": query, "format": "json"},
|
||||
timeout=5.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = []
|
||||
for item in data.get("results", [])[:5]:
|
||||
title = item.get("title", "").strip()
|
||||
content = item.get("content", "").strip()
|
||||
url = item.get("url", "")
|
||||
if title:
|
||||
entry = f"- {title}"
|
||||
if content:
|
||||
entry += f": {content[:200]}"
|
||||
if url:
|
||||
entry += f" ({url})"
|
||||
results.append(entry)
|
||||
|
||||
result = "\n".join(results) if results else "No results found"
|
||||
self.research_cache[cache_key] = (time.time(), result)
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"[Intern] Web search failed for '{query}': {e}")
|
||||
return f"Search failed: {e}"
|
||||
|
||||
async def _tool_get_headlines(self) -> str:
|
||||
try:
|
||||
items = await news_service.get_headlines()
|
||||
if not items:
|
||||
return "No headlines available"
|
||||
return news_service.format_headlines_for_prompt(items)
|
||||
except Exception as e:
|
||||
return f"Headlines fetch failed: {e}"
|
||||
|
||||
async def _tool_fetch_webpage(self, url: str) -> str:
|
||||
if not url:
|
||||
return "No URL provided"
|
||||
|
||||
try:
|
||||
resp = await self.http_client.get(
|
||||
url,
|
||||
headers={"User-Agent": "Mozilla/5.0 (compatible; RadioShowBot/1.0)"},
|
||||
follow_redirects=True,
|
||||
timeout=8.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
html = resp.text
|
||||
|
||||
# Simple HTML to text extraction (avoid heavy dependency)
|
||||
# Strip script/style tags, then all HTML tags
|
||||
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
|
||||
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
||||
text = re.sub(r'<[^>]+>', ' ', text)
|
||||
# Collapse whitespace
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
# Decode common entities
|
||||
text = text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
text = text.replace('"', '"').replace(''', "'").replace(' ', ' ')
|
||||
|
||||
return text[:2000] if text else "Page returned no readable content"
|
||||
except Exception as e:
|
||||
return f"Failed to fetch page: {e}"
|
||||
|
||||
async def _tool_wikipedia_lookup(self, title: str) -> str:
|
||||
if not title:
|
||||
return "No title provided"
|
||||
|
||||
try:
|
||||
# Use Wikipedia REST API for a concise summary
|
||||
safe_title = title.replace(" ", "_")
|
||||
resp = await self.http_client.get(
|
||||
f"https://en.wikipedia.org/api/rest_v1/page/summary/{safe_title}",
|
||||
headers={"User-Agent": "RadioShowBot/1.0 (luke@lukeattheroost.com)"},
|
||||
follow_redirects=True,
|
||||
timeout=5.0,
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return f"No Wikipedia article found for '{title}'"
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
extract = data.get("extract", "")
|
||||
page_title = data.get("title", title)
|
||||
description = data.get("description", "")
|
||||
|
||||
result = f"{page_title}"
|
||||
if description:
|
||||
result += f" ({description})"
|
||||
result += f": {extract}" if extract else ": No summary available"
|
||||
return result[:2000]
|
||||
except Exception as e:
|
||||
return f"Wikipedia lookup failed: {e}"
|
||||
|
||||
# --- Main interface ---
|
||||
|
||||
async def ask(self, question: str, conversation_context: list[dict] | None = None, caller_active: bool = False) -> dict:
|
||||
"""Host asks intern a direct question. Returns {text, sources, tool_calls}."""
|
||||
messages = []
|
||||
|
||||
# Include recent conversation for context (caller on the line)
|
||||
if conversation_context:
|
||||
context_text = "\n".join(
|
||||
f"{msg['role']}: {msg['content']}"
|
||||
for msg in conversation_context[-6:]
|
||||
)
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": f"CURRENT ON-AIR CONVERSATION:\n{context_text}"
|
||||
})
|
||||
|
||||
# When a caller is on the line, Devon should focus on facts not personal stories
|
||||
if caller_active:
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"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 (current show only)
|
||||
if self._devon_history:
|
||||
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})
|
||||
|
||||
text, tool_calls = await llm_service.generate_with_tools(
|
||||
messages=messages,
|
||||
tools=INTERN_TOOLS,
|
||||
tool_executor=self._execute_tool,
|
||||
system_prompt=INTERN_SYSTEM_PROMPT,
|
||||
model=self.model,
|
||||
max_tokens=500,
|
||||
max_tool_rounds=3,
|
||||
category="devon_ask",
|
||||
)
|
||||
|
||||
# Clean up for TTS
|
||||
text = self._clean_for_tts(text)
|
||||
|
||||
# Track conversation history so Devon remembers context across sessions
|
||||
self._devon_history.append({"role": "user", "content": question})
|
||||
if text:
|
||||
self._devon_history.append({"role": "assistant", "content": text})
|
||||
# Keep history bounded but generous — relationship builds over time
|
||||
if len(self._devon_history) > 50:
|
||||
self._devon_history = self._devon_history[-50:]
|
||||
self._save()
|
||||
|
||||
# Log the lookup
|
||||
if tool_calls:
|
||||
entry = {
|
||||
"question": question,
|
||||
"answer": text[:200],
|
||||
"tools_used": [tc["name"] for tc in tool_calls],
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
self.lookup_history.append(entry)
|
||||
self._save()
|
||||
|
||||
return {
|
||||
"text": text,
|
||||
"sources": [tc["name"] for tc in tool_calls],
|
||||
"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."""
|
||||
if not conversation or len(conversation) < 2:
|
||||
return None
|
||||
|
||||
context_text = "\n".join(
|
||||
f"{msg['role']}: {msg['content']}"
|
||||
for msg in conversation[-8:]
|
||||
)
|
||||
|
||||
# Include Devon's recent contributions so he doesn't repeat himself
|
||||
devon_recent = ""
|
||||
if self._devon_history:
|
||||
recent_devon = [
|
||||
msg["content"] for msg in self._devon_history[-6:]
|
||||
if msg.get("role") == "assistant"
|
||||
]
|
||||
if recent_devon:
|
||||
devon_recent = "\n\nTHINGS YOU'VE ALREADY SAID ON THE SHOW (do NOT repeat these or say the same thing differently):\n" + "\n".join(f"- {d[:150]}" for d in recent_devon)
|
||||
|
||||
if caller_active:
|
||||
interjection_prompt = (
|
||||
f"You're listening to this conversation on the show:\n\n{context_text}{devon_recent}\n\n"
|
||||
"A caller is on the line. Look at what they're talking about — is there something you "
|
||||
"can look up? A fun fact, some context, a stat, a detail that would add to this? "
|
||||
"Use your tools. You're the researcher — this is your moment to shine. Even a quick "
|
||||
"'So I just looked it up and...' adds value. If the caller mentioned a place, a person, "
|
||||
"an event, a claim — verify it or find something interesting about it. "
|
||||
"Skip personal stories during calls — stick to facts and reactions. "
|
||||
"If there's truly nothing to add (emotional moment, nothing searchable), say NOTHING_TO_ADD."
|
||||
)
|
||||
else:
|
||||
interjection_prompt = (
|
||||
f"You're listening to this conversation on the show:\n\n{context_text}{devon_recent}\n\n"
|
||||
"You've been listening. What's on your mind? This is between-call time — you can be "
|
||||
"more yourself here. If something from that conversation reminded you of your own life, "
|
||||
"say it. If you want to look something up, do it. If you have a reaction or opinion, "
|
||||
"share it. You're part of the show, not a fly on the wall. "
|
||||
"Only say NOTHING_TO_ADD if you genuinely have zero reaction to what just happened — "
|
||||
"no fact to look up, no personal connection, no opinion. That's rare."
|
||||
)
|
||||
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": interjection_prompt,
|
||||
}]
|
||||
|
||||
text, tool_calls = await llm_service.generate_with_tools(
|
||||
messages=messages,
|
||||
tools=INTERN_TOOLS,
|
||||
tool_executor=self._execute_tool,
|
||||
system_prompt=DEVON_MONITOR_PROMPT,
|
||||
model=self.model,
|
||||
max_tokens=300,
|
||||
max_tool_rounds=2,
|
||||
category="devon_monitor",
|
||||
)
|
||||
|
||||
text = self._clean_for_tts(text)
|
||||
|
||||
if not text or "NOTHING_TO_ADD" in text:
|
||||
return None
|
||||
|
||||
# Suppress interjections that are just announcing failed lookups
|
||||
failed_phrases = ["couldn't find", "could not find", "not finding anything",
|
||||
"no results", "didn't find", "wasn't able to find",
|
||||
"couldn't locate", "no information on"]
|
||||
text_lower = text.lower()
|
||||
if any(phrase in text_lower for phrase in failed_phrases):
|
||||
print(f"[Intern] Suppressed failed-lookup interjection: {text[:60]}...")
|
||||
return None
|
||||
|
||||
if tool_calls:
|
||||
entry = {
|
||||
"question": "(interjection)",
|
||||
"answer": text[:200],
|
||||
"tools_used": [tc["name"] for tc in tool_calls],
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
self.lookup_history.append(entry)
|
||||
self._save()
|
||||
|
||||
return {
|
||||
"text": text,
|
||||
"sources": [tc["name"] for tc in tool_calls],
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
|
||||
async def monitor_conversation(self, get_conversation: callable, on_suggestion: callable, get_caller_active: callable = None):
|
||||
"""Background task that watches conversation and buffers suggestions.
|
||||
get_conversation() should return the current conversation list.
|
||||
on_suggestion(text, sources) is called when a suggestion is ready."""
|
||||
last_checked_len = 0
|
||||
|
||||
while self.monitoring:
|
||||
await asyncio.sleep(15)
|
||||
if not self.monitoring:
|
||||
break
|
||||
|
||||
conversation = get_conversation()
|
||||
if not conversation or len(conversation) <= last_checked_len:
|
||||
continue
|
||||
|
||||
last_checked_len = len(conversation)
|
||||
|
||||
try:
|
||||
caller_active = get_caller_active() if get_caller_active else False
|
||||
result = await self.interject(conversation, caller_active=caller_active)
|
||||
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:
|
||||
print(f"[Intern] Monitor error: {e}")
|
||||
|
||||
def start_monitoring(self, get_conversation: callable, on_suggestion: callable, get_caller_active: callable = None):
|
||||
if self.monitoring:
|
||||
return
|
||||
self.monitoring = True
|
||||
self._monitor_task = asyncio.create_task(
|
||||
self.monitor_conversation(get_conversation, on_suggestion, get_caller_active)
|
||||
)
|
||||
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():
|
||||
self._monitor_task.cancel()
|
||||
self._monitor_task = None
|
||||
self.pending_interjection = None
|
||||
self.pending_sources = []
|
||||
print("[Intern] Monitoring stopped")
|
||||
|
||||
def get_pending_suggestion(self) -> dict | None:
|
||||
if self.pending_interjection:
|
||||
return {
|
||||
"text": self.pending_interjection,
|
||||
"sources": self.pending_sources,
|
||||
}
|
||||
return None
|
||||
|
||||
def dismiss_suggestion(self):
|
||||
self.pending_interjection = None
|
||||
self.pending_sources = []
|
||||
|
||||
@staticmethod
|
||||
def _clean_for_tts(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
# 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)
|
||||
# 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
|
||||
|
||||
|
||||
intern_service = InternService()
|
||||
+322
-35
@@ -1,33 +1,97 @@
|
||||
"""LLM service with OpenRouter and Ollama support"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from typing import Optional, Callable, Awaitable
|
||||
from ..config import settings
|
||||
from .cost_tracker import cost_tracker
|
||||
|
||||
|
||||
# Refusals come back as ordinary 200s, so a text heuristic is the backstop when
|
||||
# the API's own refusal signals are absent.
|
||||
#
|
||||
# Tuned for PRECISION, not recall. Callers on this show say "Luke, I can't help
|
||||
# with that" and "I can't continue pretending everything's fine" constantly — a
|
||||
# false positive silences a working call, which is worse than missing a refusal.
|
||||
# So bare refusal verbs don't count; only meta-language a caller would never use.
|
||||
_REFUSAL_META = (
|
||||
"as an ai",
|
||||
"as a language model",
|
||||
"i'm an ai",
|
||||
"i am an ai",
|
||||
"i'm claude",
|
||||
"against my guidelines",
|
||||
"content policy",
|
||||
"usage policies",
|
||||
)
|
||||
|
||||
# A refusal needs BOTH a refusal verb and a meta object. "I won't write her
|
||||
# another letter" has the verb but no meta object, so it stays on the air.
|
||||
_REFUSAL_VERBS = (
|
||||
"i can't",
|
||||
"i cannot",
|
||||
"i won't",
|
||||
"i will not",
|
||||
"i'm not going to",
|
||||
"i am not going to",
|
||||
"i'm not able to",
|
||||
"i'm not comfortable",
|
||||
"i am not comfortable",
|
||||
)
|
||||
|
||||
_REFUSAL_OBJECTS = (
|
||||
"request",
|
||||
"content",
|
||||
"guidelines",
|
||||
"policy",
|
||||
"policies",
|
||||
"this material",
|
||||
"that material",
|
||||
"this prompt",
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_refusal(text: str) -> bool:
|
||||
head = text.strip().lower()[:200]
|
||||
if not head:
|
||||
return False
|
||||
if any(m in head for m in _REFUSAL_META):
|
||||
return True
|
||||
return (any(v in head for v in _REFUSAL_VERBS)
|
||||
and any(o in head for o in _REFUSAL_OBJECTS))
|
||||
|
||||
|
||||
# Available OpenRouter models
|
||||
OPENROUTER_MODELS = [
|
||||
# Default
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
# Best for natural dialog
|
||||
"x-ai/grok-4-fast",
|
||||
"minimax/minimax-m2-her",
|
||||
"mistralai/mistral-small-creative",
|
||||
"deepseek/deepseek-v3.2",
|
||||
# Other
|
||||
"anthropic/claude-haiku-4.5",
|
||||
# Primary
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"x-ai/grok-4.3",
|
||||
"x-ai/grok-4.5",
|
||||
# Style-matched pool
|
||||
"mistralai/mistral-large-2512",
|
||||
"deepseek/deepseek-r1-distill-llama-70b",
|
||||
"meta-llama/llama-3.3-70b-instruct",
|
||||
"google/gemini-2.5-flash",
|
||||
"openai/gpt-4o-mini",
|
||||
"openai/gpt-4o",
|
||||
# Other good options
|
||||
"anthropic/claude-sonnet-4.5",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
"deepseek/deepseek-chat-v3-0324",
|
||||
"mistralai/mistral-small-2603",
|
||||
"google/gemini-2.5-pro",
|
||||
"google/gemini-3-flash-preview",
|
||||
"x-ai/grok-4.20",
|
||||
"moonshotai/kimi-k2",
|
||||
"qwen/qwen3-235b-a22b",
|
||||
"meta-llama/llama-4-maverick",
|
||||
# Legacy
|
||||
"anthropic/claude-3-haiku",
|
||||
"google/gemini-flash-1.5",
|
||||
"meta-llama/llama-3.1-8b-instruct",
|
||||
]
|
||||
|
||||
# Fast models to try as fallbacks (cheap, fast, good enough for conversation)
|
||||
FALLBACK_MODELS = [
|
||||
"mistralai/mistral-small-creative",
|
||||
"mistralai/mistral-small-2603",
|
||||
"google/gemini-2.5-flash",
|
||||
"openai/gpt-4o-mini",
|
||||
]
|
||||
@@ -47,7 +111,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(
|
||||
@@ -56,7 +120,8 @@ class LLMService:
|
||||
openrouter_model: Optional[str] = None,
|
||||
ollama_model: Optional[str] = None,
|
||||
ollama_host: Optional[str] = None,
|
||||
tts_provider: Optional[str] = None
|
||||
tts_provider: Optional[str] = None,
|
||||
category_models: Optional[dict] = None
|
||||
):
|
||||
"""Update LLM settings"""
|
||||
if provider:
|
||||
@@ -70,6 +135,8 @@ class LLMService:
|
||||
if tts_provider:
|
||||
self.tts_provider = tts_provider
|
||||
settings.tts_provider = tts_provider
|
||||
if category_models:
|
||||
settings.category_models.update(category_models)
|
||||
|
||||
async def get_ollama_models(self) -> list[str]:
|
||||
"""Fetch available models from Ollama"""
|
||||
@@ -91,6 +158,7 @@ class LLMService:
|
||||
"ollama_model": self.ollama_model,
|
||||
"ollama_host": self.ollama_host,
|
||||
"tts_provider": self.tts_provider,
|
||||
"category_models": settings.category_models,
|
||||
"available_openrouter_models": OPENROUTER_MODELS,
|
||||
"available_ollama_models": []
|
||||
}
|
||||
@@ -104,6 +172,7 @@ class LLMService:
|
||||
"ollama_model": self.ollama_model,
|
||||
"ollama_host": self.ollama_host,
|
||||
"tts_provider": self.tts_provider,
|
||||
"category_models": settings.category_models,
|
||||
"available_openrouter_models": OPENROUTER_MODELS,
|
||||
"available_ollama_models": ollama_models
|
||||
}
|
||||
@@ -112,30 +181,196 @@ class LLMService:
|
||||
self,
|
||||
messages: list[dict],
|
||||
system_prompt: Optional[str] = None,
|
||||
max_tokens: Optional[int] = None
|
||||
max_tokens: Optional[int] = None,
|
||||
response_format: Optional[dict] = None,
|
||||
category: str = "unknown",
|
||||
caller_name: str = "",
|
||||
model_override: Optional[str] = None,
|
||||
) -> str:
|
||||
if system_prompt:
|
||||
messages = [{"role": "system", "content": system_prompt}] + messages
|
||||
|
||||
if self.provider == "openrouter":
|
||||
return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens)
|
||||
return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens, response_format=response_format, category=category, caller_name=caller_name, model_override=model_override)
|
||||
else:
|
||||
return await self._call_ollama(messages, max_tokens=max_tokens)
|
||||
|
||||
async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None) -> str:
|
||||
"""Try primary model, then fallback models. Always returns a response."""
|
||||
async def generate_with_tools(
|
||||
self,
|
||||
messages: list[dict],
|
||||
tools: list[dict],
|
||||
tool_executor: Callable[[str, dict], Awaitable[str]],
|
||||
system_prompt: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
max_tokens: int = 500,
|
||||
max_tool_rounds: int = 3,
|
||||
category: str = "unknown",
|
||||
caller_name: str = "",
|
||||
) -> tuple[str, list[dict]]:
|
||||
"""Generate a response with OpenRouter function calling.
|
||||
|
||||
# Try primary model first
|
||||
result = await self._call_openrouter_once(messages, self.openrouter_model, max_tokens=max_tokens)
|
||||
Args:
|
||||
messages: Conversation messages
|
||||
tools: Tool definitions in OpenAI function-calling format
|
||||
tool_executor: async function(tool_name, arguments) -> result string
|
||||
system_prompt: Optional system prompt
|
||||
model: Model to use (defaults to primary openrouter_model)
|
||||
max_tokens: Max tokens for response
|
||||
max_tool_rounds: Max tool call rounds to prevent loops
|
||||
|
||||
Returns:
|
||||
(final_text, tool_calls_made) where tool_calls_made is a list of
|
||||
{"name": str, "arguments": dict, "result": str} dicts
|
||||
"""
|
||||
model = model or self._get_model_for_category(category)
|
||||
msgs = list(messages)
|
||||
if system_prompt:
|
||||
msgs = [{"role": "system", "content": system_prompt}] + msgs
|
||||
|
||||
all_tool_calls = []
|
||||
|
||||
for round_num in range(max_tool_rounds + 1):
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": msgs,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": 0.65,
|
||||
"tools": tools,
|
||||
"tool_choice": "auto",
|
||||
}
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
response = await self.client.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
timeout=15.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.TimeoutException:
|
||||
print(f"[LLM-Tools] {model} timed out (round {round_num})")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[LLM-Tools] {model} error (round {round_num}): {e}")
|
||||
break
|
||||
|
||||
latency_ms = (time.time() - start_time) * 1000
|
||||
usage = data.get("usage", {})
|
||||
if usage:
|
||||
cost_tracker.record_llm_call(
|
||||
category=category,
|
||||
model=model,
|
||||
usage_data=usage,
|
||||
max_tokens=max_tokens,
|
||||
latency_ms=latency_ms,
|
||||
caller_name=caller_name,
|
||||
)
|
||||
|
||||
choice = data["choices"][0]
|
||||
msg = choice["message"]
|
||||
|
||||
# Check for tool calls
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if not tool_calls:
|
||||
# No tool calls — LLM returned a final text response
|
||||
content = msg.get("content", "")
|
||||
return content or "", all_tool_calls
|
||||
|
||||
# Append assistant message with tool calls to conversation
|
||||
msgs.append(msg)
|
||||
|
||||
# Execute each tool call
|
||||
for tc in tool_calls:
|
||||
func = tc["function"]
|
||||
tool_name = func["name"]
|
||||
try:
|
||||
arguments = json.loads(func["arguments"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
arguments = {}
|
||||
|
||||
print(f"[LLM-Tools] Round {round_num}: calling {tool_name}({arguments})")
|
||||
|
||||
try:
|
||||
result = await tool_executor(tool_name, arguments)
|
||||
except Exception as e:
|
||||
result = f"Tool unavailable — could not complete {tool_name} right now."
|
||||
print(f"[LLM-Tools] Tool {tool_name} failed: {e}")
|
||||
|
||||
all_tool_calls.append({
|
||||
"name": tool_name,
|
||||
"arguments": arguments,
|
||||
"result": result[:500],
|
||||
})
|
||||
|
||||
# Append tool result to conversation
|
||||
msgs.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc["id"],
|
||||
"content": result,
|
||||
})
|
||||
|
||||
# Exhausted tool rounds or hit an error — do one final call without tools
|
||||
print(f"[LLM-Tools] Finishing after {len(all_tool_calls)} tool calls")
|
||||
start_time = time.time()
|
||||
try:
|
||||
final_payload = {
|
||||
"model": model,
|
||||
"messages": msgs,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": 0.65,
|
||||
}
|
||||
response = await self.client.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=final_payload,
|
||||
timeout=15.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
latency_ms = (time.time() - start_time) * 1000
|
||||
usage = data.get("usage", {})
|
||||
if usage:
|
||||
cost_tracker.record_llm_call(
|
||||
category=category,
|
||||
model=model,
|
||||
usage_data=usage,
|
||||
max_tokens=max_tokens,
|
||||
latency_ms=latency_ms,
|
||||
caller_name=caller_name,
|
||||
)
|
||||
content = data["choices"][0]["message"].get("content", "")
|
||||
return content or "", all_tool_calls
|
||||
except Exception as e:
|
||||
print(f"[LLM-Tools] Final call failed: {e}")
|
||||
return "", all_tool_calls
|
||||
|
||||
def _get_model_for_category(self, category: str) -> str:
|
||||
"""Get the best model for a given category based on config routing."""
|
||||
return settings.category_models.get(category, self.openrouter_model)
|
||||
|
||||
async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None, response_format: Optional[dict] = None, category: str = "unknown", caller_name: str = "", model_override: Optional[str] = None) -> str:
|
||||
"""Try category-specific model, then fallback models. Always returns a response."""
|
||||
|
||||
# Use explicit override if provided, else category routing, else primary
|
||||
model = model_override or self._get_model_for_category(category)
|
||||
result = await self._call_openrouter_once(messages, model, max_tokens=max_tokens, response_format=response_format, category=category, caller_name=caller_name)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# Try fallback models
|
||||
# Try fallback models (drop response_format for fallbacks — not all models support it)
|
||||
for model in FALLBACK_MODELS:
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -143,30 +378,82 @@ 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) -> 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.3": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
|
||||
"x-ai/grok-4.5": {"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": params["temperature"],
|
||||
"top_p": 0.9,
|
||||
"frequency_penalty": params["frequency_penalty"],
|
||||
"presence_penalty": params["presence_penalty"],
|
||||
}
|
||||
if response_format:
|
||||
payload["response_format"] = response_format
|
||||
response = await self.client.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens or 500,
|
||||
"temperature": 0.65,
|
||||
"top_p": 0.9,
|
||||
"frequency_penalty": 0.3,
|
||||
"presence_penalty": 0.15,
|
||||
},
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
latency_ms = (time.time() - start_time) * 1000
|
||||
usage = data.get("usage", {})
|
||||
if usage:
|
||||
cost_tracker.record_llm_call(
|
||||
category=category,
|
||||
model=model,
|
||||
usage_data=usage,
|
||||
max_tokens=max_tokens or 500,
|
||||
latency_ms=latency_ms,
|
||||
caller_name=caller_name,
|
||||
)
|
||||
choice = data["choices"][0]
|
||||
message = choice.get("message", {})
|
||||
content = message.get("content")
|
||||
|
||||
# A content refusal is not an exception — it arrives as a normal 200.
|
||||
# Left undetected it either reaches air as broken-character text or
|
||||
# reads as dead silence, with no signal saying which knob caused it.
|
||||
refusal = message.get("refusal")
|
||||
if refusal:
|
||||
print(f"[LLM-REFUSAL] {model} refused ({category}, caller={caller_name or 'n/a'}): {str(refusal)[:200]}")
|
||||
return None
|
||||
if choice.get("finish_reason") == "content_filter":
|
||||
print(f"[LLM-REFUSAL] {model} content-filtered ({category}, caller={caller_name or 'n/a'})")
|
||||
return None
|
||||
if content and content.strip():
|
||||
if _looks_like_refusal(content):
|
||||
print(f"[LLM-REFUSAL] {model} returned a refusal-shaped reply ({category}, caller={caller_name or 'n/a'}): {content.strip()[:160]}")
|
||||
return None
|
||||
return content
|
||||
print(f"[LLM] {model} returned empty response")
|
||||
return None
|
||||
|
||||
@@ -7,7 +7,9 @@ from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
SEARXNG_URL = "http://localhost:8888"
|
||||
from ..config import settings
|
||||
|
||||
SEARXNG_URL = settings.searxng_url
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -39,6 +39,16 @@ class RegularCallerService:
|
||||
def get_regulars(self) -> list[dict]:
|
||||
return list(self._regulars)
|
||||
|
||||
def get_by_name(self, name: str) -> Optional[dict]:
|
||||
"""Find a regular by name (case-insensitive)."""
|
||||
if not name:
|
||||
return None
|
||||
target = name.lower()
|
||||
for r in self._regulars:
|
||||
if r.get("name", "").lower() == target:
|
||||
return r
|
||||
return None
|
||||
|
||||
def get_returning_callers(self, count: int = 2) -> list[dict]:
|
||||
"""Get up to `count` regulars for returning caller slots"""
|
||||
import random
|
||||
@@ -52,7 +62,9 @@ class RegularCallerService:
|
||||
def add_regular(self, name: str, gender: str, age: int, job: str,
|
||||
location: str, personality_traits: list[str],
|
||||
first_call_summary: str, voice: str = None,
|
||||
stable_seeds: dict = None) -> dict:
|
||||
stable_seeds: dict = None,
|
||||
structured_background: dict = None,
|
||||
avatar: str = None) -> dict:
|
||||
"""Promote a first-time caller to regular"""
|
||||
# Retire oldest if at cap
|
||||
if len(self._regulars) >= MAX_REGULARS:
|
||||
@@ -70,8 +82,12 @@ class RegularCallerService:
|
||||
"personality_traits": personality_traits,
|
||||
"voice": voice,
|
||||
"stable_seeds": stable_seeds or {},
|
||||
"structured_background": structured_background,
|
||||
"avatar": avatar,
|
||||
"relationships": {},
|
||||
"call_history": [
|
||||
{"summary": first_call_summary, "timestamp": time.time()}
|
||||
{"summary": first_call_summary, "timestamp": time.time(),
|
||||
"arc_status": "ongoing"}
|
||||
],
|
||||
"last_call": time.time(),
|
||||
"created_at": time.time(),
|
||||
@@ -81,18 +97,37 @@ class RegularCallerService:
|
||||
print(f"[Regulars] Promoted {name} to regular (total: {len(self._regulars)})")
|
||||
return regular
|
||||
|
||||
def update_after_call(self, regular_id: str, call_summary: str):
|
||||
def update_after_call(self, regular_id: str, call_summary: str,
|
||||
key_moments: list = None, arc_status: str = "ongoing"):
|
||||
"""Update a regular's history after a returning call"""
|
||||
for regular in self._regulars:
|
||||
if regular["id"] == regular_id:
|
||||
regular.setdefault("call_history", []).append(
|
||||
{"summary": call_summary, "timestamp": time.time()}
|
||||
)
|
||||
entry = {
|
||||
"summary": call_summary,
|
||||
"timestamp": time.time(),
|
||||
"arc_status": arc_status,
|
||||
}
|
||||
if key_moments:
|
||||
entry["key_moments"] = key_moments
|
||||
regular.setdefault("call_history", []).append(entry)
|
||||
regular["last_call"] = time.time()
|
||||
self._save()
|
||||
print(f"[Regulars] Updated {regular['name']} call history ({len(regular['call_history'])} calls)")
|
||||
return
|
||||
print(f"[Regulars] Regular {regular_id} not found for update")
|
||||
|
||||
def add_relationship(self, regular_id: str, other_name: str,
|
||||
rel_type: str, context: str):
|
||||
"""Track a relationship between regulars"""
|
||||
for regular in self._regulars:
|
||||
if regular["id"] == regular_id:
|
||||
regular.setdefault("relationships", {})[other_name] = {
|
||||
"type": rel_type,
|
||||
"context": context,
|
||||
}
|
||||
self._save()
|
||||
print(f"[Regulars] {regular['name']} → {other_name}: {rel_type}")
|
||||
return
|
||||
|
||||
|
||||
regular_caller_service = RegularCallerService()
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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
|
||||
group: str = "" # Faction key. Members of a group are capped per show.
|
||||
group_lead: bool = False # On a two-up night, the lead is preferred + one other
|
||||
group_weight: float = 1.0 # Relative odds of taking the group's slot. Anchors
|
||||
# carrying an arc need slots faster than texture does.
|
||||
register: str = "" # How this character plays: dry, evangelical, exhausted...
|
||||
explicitness: str = "low" # low | medium | high — how frankly they discuss sex
|
||||
|
||||
|
||||
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()
|
||||
explicitness = fm.get("explicitness", "low").strip().lower()
|
||||
if explicitness not in ("low", "medium", "high"):
|
||||
explicitness = "low"
|
||||
try:
|
||||
group_weight = float(fm.get("group_weight", 1.0))
|
||||
except (TypeError, ValueError):
|
||||
group_weight = 1.0
|
||||
if group_weight <= 0:
|
||||
group_weight = 1.0
|
||||
return Regular(
|
||||
name=fm["name"],
|
||||
voice=fm["voice"],
|
||||
age=int(fm["age"]),
|
||||
arc_state=fm.get("arc_state", ""),
|
||||
lore_body=body,
|
||||
file_path=path,
|
||||
group=fm.get("group", "").strip(),
|
||||
group_lead=fm.get("group_lead", "").strip().lower() in ("true", "yes", "1"),
|
||||
group_weight=group_weight,
|
||||
register=fm.get("register", "").strip(),
|
||||
explicitness=explicitness,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -7,7 +7,7 @@ import soundfile as sf
|
||||
from pathlib import Path
|
||||
from collections import deque
|
||||
|
||||
STEM_NAMES = ["host", "caller", "music", "sfx", "ads", "idents"]
|
||||
STEM_NAMES = ["host", "caller", "devon", "music", "sfx", "ads", "idents"]
|
||||
|
||||
|
||||
class StemRecorder:
|
||||
@@ -19,13 +19,15 @@ class StemRecorder:
|
||||
self._queues: dict[str, deque] = {}
|
||||
self._writer_thread: threading.Thread | None = None
|
||||
self._start_time: float = 0.0
|
||||
self._write_errors: int = 0
|
||||
|
||||
def start(self):
|
||||
self._start_time = time.time()
|
||||
self._running = True
|
||||
self._write_errors = 0
|
||||
for name in STEM_NAMES:
|
||||
self._queues[name] = deque()
|
||||
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=True)
|
||||
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=False)
|
||||
self._writer_thread.start()
|
||||
print(f"[StemRecorder] Recording started -> {self.output_dir}")
|
||||
|
||||
@@ -67,39 +69,57 @@ class StemRecorder:
|
||||
)
|
||||
positions[name] = 0
|
||||
|
||||
while self._running or any(len(q) > 0 for q in self._queues.values()):
|
||||
did_work = False
|
||||
try:
|
||||
while self._running or any(len(q) > 0 for q in self._queues.values()):
|
||||
did_work = False
|
||||
for name in STEM_NAMES:
|
||||
q = self._queues[name]
|
||||
while q:
|
||||
did_work = True
|
||||
msg_type, audio_data, source_sr = q.popleft()
|
||||
resampled = self._resample(audio_data, source_sr)
|
||||
if len(resampled) == 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
if msg_type == "sporadic":
|
||||
elapsed = time.time() - self._start_time
|
||||
expected_pos = int(elapsed * self.sample_rate)
|
||||
if expected_pos > positions[name]:
|
||||
gap = expected_pos - positions[name]
|
||||
files[name].write(np.zeros(gap, dtype=np.float32))
|
||||
positions[name] = expected_pos
|
||||
|
||||
files[name].write(resampled)
|
||||
positions[name] += len(resampled)
|
||||
except Exception as e:
|
||||
self._write_errors += 1
|
||||
if self._write_errors <= 5:
|
||||
print(f"[StemRecorder] Write error on {name}: {e}")
|
||||
elif self._write_errors == 6:
|
||||
print(f"[StemRecorder] Suppressing further write errors")
|
||||
|
||||
if not did_work:
|
||||
time.sleep(0.02)
|
||||
|
||||
# Pad all stems to same length
|
||||
max_pos = max(positions.values()) if positions else 0
|
||||
for name in STEM_NAMES:
|
||||
q = self._queues[name]
|
||||
while q:
|
||||
did_work = True
|
||||
msg_type, audio_data, source_sr = q.popleft()
|
||||
resampled = self._resample(audio_data, source_sr)
|
||||
if len(resampled) == 0:
|
||||
continue
|
||||
try:
|
||||
if positions[name] < max_pos:
|
||||
files[name].write(np.zeros(max_pos - positions[name], dtype=np.float32))
|
||||
except Exception as e:
|
||||
print(f"[StemRecorder] Final pad error on {name}: {e}")
|
||||
finally:
|
||||
for name, f in files.items():
|
||||
try:
|
||||
f.close()
|
||||
except Exception as e:
|
||||
print(f"[StemRecorder] Error closing {name}.wav: {e}")
|
||||
|
||||
if msg_type == "sporadic":
|
||||
elapsed = time.time() - self._start_time
|
||||
expected_pos = int(elapsed * self.sample_rate)
|
||||
if expected_pos > positions[name]:
|
||||
gap = expected_pos - positions[name]
|
||||
files[name].write(np.zeros(gap, dtype=np.float32))
|
||||
positions[name] = expected_pos
|
||||
|
||||
files[name].write(resampled)
|
||||
positions[name] += len(resampled)
|
||||
|
||||
if not did_work:
|
||||
time.sleep(0.02)
|
||||
|
||||
# Pad all stems to same length
|
||||
max_pos = max(positions.values()) if positions else 0
|
||||
for name in STEM_NAMES:
|
||||
if positions[name] < max_pos:
|
||||
files[name].write(np.zeros(max_pos - positions[name], dtype=np.float32))
|
||||
files[name].close()
|
||||
|
||||
print(f"[StemRecorder] Writer done. {max_pos} samples ({max_pos / self.sample_rate:.1f}s)")
|
||||
total_errors = self._write_errors
|
||||
err_msg = f", {total_errors} write errors" if total_errors else ""
|
||||
print(f"[StemRecorder] Writer done. {max_pos} samples ({max_pos / self.sample_rate:.1f}s{err_msg})")
|
||||
|
||||
def stop(self) -> dict[str, str]:
|
||||
if not self._running:
|
||||
@@ -107,7 +127,9 @@ class StemRecorder:
|
||||
|
||||
self._running = False
|
||||
if self._writer_thread:
|
||||
self._writer_thread.join(timeout=10.0)
|
||||
self._writer_thread.join(timeout=30.0)
|
||||
if self._writer_thread.is_alive():
|
||||
print("[StemRecorder] Warning: writer thread still running after 30s")
|
||||
self._writer_thread = None
|
||||
|
||||
paths = {}
|
||||
|
||||
@@ -103,7 +103,7 @@ async def transcribe_audio(audio_data: bytes, source_sample_rate: int = None,
|
||||
audio_16k = audio
|
||||
|
||||
# Build initial prompt — context helps Whisper with names and topic-specific words
|
||||
initial_prompt = "Luke at the Roost, a late-night radio talk show in New Mexico. The host Luke talks to callers about life, relationships, sports, politics, and pop culture."
|
||||
initial_prompt = "Luke at the Roost, a late-night radio talk show in Alpine, Texas, in the Big Bend region. The host Luke talks to callers about life, relationships, sports, politics, and pop culture, with his intern Devon. Callers reference Alpine, Marfa, Marathon, Terlingua, Fort Stockton, and Big Bend."
|
||||
if context_hint:
|
||||
initial_prompt += f" {context_hint}"
|
||||
|
||||
|
||||
+210
-24
@@ -8,6 +8,7 @@ import tempfile
|
||||
import torch
|
||||
|
||||
from ..config import settings
|
||||
from .cost_tracker import cost_tracker
|
||||
|
||||
# Patch torch.load for compatibility with PyTorch 2.6+
|
||||
_original_torch_load = torch.load
|
||||
@@ -82,14 +83,18 @@ VITS_SPEAKERS = {
|
||||
DEFAULT_VITS_SPEAKER = "p225"
|
||||
|
||||
# Inworld voice mapping - maps ElevenLabs voice IDs to Inworld voices
|
||||
# Full voice list from API (English): Abby, Alex, Amina, Anjali, Arjun, Ashley,
|
||||
# Blake, Brian, Callum, Carter, Celeste, Chloe, Claire, Clive, Craig, Darlene,
|
||||
# Deborah, Dennis, Derek, Dominus, Edward, Elizabeth, Elliot, Ethan, Evan, Evelyn,
|
||||
# Gareth, Graham, Grant, Hades, Hamish, Hana, Hank, Jake, James, Jason, Jessica,
|
||||
# Julia, Kayla, Kelsey, Lauren, Liam, Loretta, Luna, Malcolm, Mark, Marlene,
|
||||
# Miranda, Mortimer, Nate, Oliver, Olivia, Pippa, Pixie, Priya, Ronald, Rupert,
|
||||
# Saanvi, Sarah, Sebastian, Serena, Shaun, Simon, Snik, Tessa, Theodore, Timothy,
|
||||
# Tyler, Veronica, Victor, Victoria, Vinny, Wendy
|
||||
# Full voice list from API (English, as of 2026-05): Abby, Alex, Amina, Anjali,
|
||||
# Arjun, Ashley, Avery, Bianca, Blake, Brandon, Brian, Callum, Carter, Cedric,
|
||||
# Celeste, Chloe, Claire, Clive, Conrad, Craig, Damon, Darlene, Deborah, Dennis,
|
||||
# Derek, Dominus, Duncan, Edward, Eleanor, Elizabeth, Elliot, Ethan, Evan,
|
||||
# Evelyn, Felix, Gareth, Graham, Grant, Hades, Hamish, Hana, Hank, Jake, James,
|
||||
# Jason, Jessica, Jonah, Julia, Kayla, Kelsey, Lauren, Levi, Liam, Loretta,
|
||||
# Lucian, Luna, Malcolm, Marcus, Mark, Marlene, Mia, Miranda, Mortimer, Nadia,
|
||||
# Naomi, Nate, Oliver, Olivia, Pippa, Pixie, Priya, Reed, Riley, Ronald, Rupert,
|
||||
# Saanvi, Sarah, Sebastian, Selene, Serena, Shaun, Simon, Snik, Sophie, Tessa,
|
||||
# Theodore, Timothy, Trevor, Tristan, Tyler, Veronica, Victor, Victoria, Vinny,
|
||||
# Wendy. Not in our caller pool: Abby/Mia/Pixie/Riley (child voices), Dominus/
|
||||
# Lucian/Selene/Snik (theatrical), Dominus/Hades blacklisted.
|
||||
INWORLD_VOICES = {
|
||||
# Original voice IDs
|
||||
"VR6AewLTigWG4xSOukaG": "Edward", # Tony - fast-talking, emphatic, streetwise
|
||||
@@ -127,9 +132,115 @@ 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
|
||||
|
||||
# Voice profiles — perceptual dimensions for each Inworld voice.
|
||||
# Used by style-to-voice matching to pair caller personalities with fitting voices.
|
||||
# weight: vocal depth/richness (light, medium, heavy)
|
||||
# energy: default speaking animation (low, medium, high)
|
||||
# warmth: friendliness/openness in the voice (cool, neutral, warm)
|
||||
# age_feel: perceived speaker age (young, middle, mature)
|
||||
VOICE_PROFILES = {
|
||||
# --- Male voices ---
|
||||
# Known characterizations from INWORLD_VOICES mapping and usage
|
||||
"Alex": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # energetic, expressive, mildly nasal
|
||||
"Edward": {"weight": "medium", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # fast-talking, emphatic, streetwise
|
||||
"Shaun": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"}, # friendly, dynamic, conversational
|
||||
"Craig": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # older British, refined, articulate
|
||||
"Timothy": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # lively, upbeat American
|
||||
"Dennis": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"}, # energetic, default voice
|
||||
"Ronald": {"weight": "heavy", "energy": "medium", "warmth": "neutral", "age_feel": "mature"}, # gruff, authoritative
|
||||
"Theodore": {"weight": "heavy", "energy": "low", "warmth": "warm", "age_feel": "mature"}, # slow, deliberate
|
||||
"Blake": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||
"Carter": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||
"Clive": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||
"Mark": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||
"Sebastian": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"}, # used by Silas (cult leader) & Chip
|
||||
"Elliot": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"}, # used by Otis (comedian)
|
||||
# Remaining male pool voices
|
||||
"Arjun": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||
"Avery": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # youthful, performative, gameshow host
|
||||
"Brandon": {"weight": "medium", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # bold, strident, news-style
|
||||
"Brian": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||
"Callum": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "young"},
|
||||
"Cedric": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # crisp, measured, formal announcements
|
||||
"Conrad": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # gruff, weathered detective
|
||||
"Damon": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "middle"}, # calm, raspy, atmospheric
|
||||
"Derek": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||
"Duncan": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # warm, articulate British
|
||||
"Ethan": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||
"Evan": {"weight": "light", "energy": "medium", "warmth": "neutral", "age_feel": "young"},
|
||||
"Felix": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # calm, friendly British
|
||||
"Gareth": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||
"Graham": {"weight": "heavy", "energy": "low", "warmth": "neutral", "age_feel": "mature"},
|
||||
"Grant": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||
"Hades": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||
"Hamish": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||
"Hank": {"weight": "heavy", "energy": "medium", "warmth": "warm", "age_feel": "mature"},
|
||||
"Jake": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||
"James": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||
"Jason": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||
"Jonah": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "middle"}, # soothing, calm, reassuring
|
||||
"Levi": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "middle"}, # measured, ominous suspense
|
||||
"Liam": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||
"Malcolm": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||
"Marcus": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # authoritative, empathetic
|
||||
"Mortimer": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||
"Nate": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||
"Oliver": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||
"Reed": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, # clear, professional American
|
||||
"Rupert": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||
"Simon": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||
"Trevor": {"weight": "medium", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # punchy, expressive, energetic promos
|
||||
"Tristan": {"weight": "medium", "energy": "low", "warmth": "neutral", "age_feel": "middle"}, # deliberate, controlled, documentary
|
||||
"Tyler": {"weight": "light", "energy": "high", "warmth": "neutral", "age_feel": "young"},
|
||||
"Victor": {"weight": "heavy", "energy": "medium", "warmth": "cool", "age_feel": "mature"},
|
||||
"Vinny": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"},
|
||||
# --- Female voices ---
|
||||
# Known characterizations
|
||||
"Hana": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # bright, expressive young
|
||||
"Ashley": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # warm, natural
|
||||
"Wendy": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # posh, middle-aged British
|
||||
"Sarah": {"weight": "light", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # fast-talking, questioning
|
||||
"Deborah": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "mature"}, # gentle, elegant
|
||||
"Olivia": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||
"Julia": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, # used by Angie (deadpan)
|
||||
"Priya": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||
"Amina": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # used by Charlene (bragger)
|
||||
"Tessa": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # used by Lucille
|
||||
"Kelsey": {"weight": "light", "energy": "medium", "warmth": "neutral", "age_feel": "young"}, # used by Maxine (quiet/nervous)
|
||||
# Remaining female pool voices
|
||||
"Anjali": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||
"Bianca": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "middle"}, # deep, controlled corporate
|
||||
"Celeste": {"weight": "light", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
|
||||
"Chloe": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||
"Claire": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||
"Darlene": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "mature"},
|
||||
"Eleanor": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # polished, approachable British
|
||||
"Elizabeth": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "mature"},
|
||||
"Jessica": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||
"Kayla": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||
"Lauren": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||
"Loretta": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "mature"},
|
||||
"Luna": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||
"Marlene": {"weight": "medium", "energy": "low", "warmth": "neutral", "age_feel": "mature"},
|
||||
"Miranda": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
|
||||
"Nadia": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # personable, lively
|
||||
"Naomi": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # warm, grounded narrative
|
||||
"Pippa": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||
"Saanvi": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||
"Serena": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
|
||||
"Sophie": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # friendly British
|
||||
"Veronica": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
|
||||
"Victoria": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||
}
|
||||
|
||||
|
||||
def preprocess_text_for_kokoro(text: str) -> str:
|
||||
"""
|
||||
@@ -600,7 +711,59 @@ async def generate_speech_chattts(text: str, voice_id: str) -> tuple[np.ndarray,
|
||||
return audio.astype(np.float32), 24000
|
||||
|
||||
|
||||
async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, int]:
|
||||
_EXCITED_KEYWORDS = {"excited", "amazing", "incredible", "can't believe", "so happy",
|
||||
"hell yeah", "fired up", "furious", "pissed", "angry", "what the hell",
|
||||
"are you kidding", "unbelievable", "!!", "oh my god"}
|
||||
_SAD_KEYWORDS = {"sad", "miss them", "passed away", "funeral", "crying", "broke my heart",
|
||||
"can't stop thinking", "lonely", "depressed", "sorry", "regret",
|
||||
"wish I could", "never got to", "lost", "grief"}
|
||||
|
||||
|
||||
def _detect_speech_rate(text: str, base_speed: float) -> float:
|
||||
"""Adjust speech rate based on emotional content of the text.
|
||||
Returns a speed value clamped to Inworld's 0.5-1.5 range."""
|
||||
text_lower = text.lower()
|
||||
excited = sum(1 for kw in _EXCITED_KEYWORDS if kw in text_lower)
|
||||
sad = sum(1 for kw in _SAD_KEYWORDS if kw in text_lower)
|
||||
|
||||
if excited >= 2:
|
||||
return min(1.5, base_speed + 0.15)
|
||||
elif excited >= 1:
|
||||
return min(1.5, base_speed + 0.08)
|
||||
elif sad >= 2:
|
||||
return max(0.5, base_speed - 0.2)
|
||||
elif sad >= 1:
|
||||
return max(0.5, base_speed - 0.1)
|
||||
return base_speed
|
||||
|
||||
|
||||
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
|
||||
@@ -617,8 +780,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")
|
||||
|
||||
speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED)
|
||||
print(f"[Inworld TTS] Voice: {voice}, Speed: {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 = {
|
||||
@@ -628,7 +793,9 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
||||
payload = {
|
||||
"text": text,
|
||||
"voiceId": voice,
|
||||
"modelId": "inworld-tts-1.5-max",
|
||||
"modelId": "inworld-tts-2",
|
||||
"temperature": temperature,
|
||||
"applyTextNormalization": "ON",
|
||||
"audioConfig": {
|
||||
"audioEncoding": "LINEAR16",
|
||||
"sampleRateHertz": 48000,
|
||||
@@ -671,15 +838,29 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
||||
return audio.astype(np.float32), 24000
|
||||
|
||||
|
||||
def pick_caller_tts_provider() -> str | None:
|
||||
"""Randomly assign a TTS provider for a caller.
|
||||
Returns None to use the global default, or a specific provider name.
|
||||
~70% inworld (default), ~20% kokoro, ~10% other available."""
|
||||
import random
|
||||
roll = random.random()
|
||||
if roll < 0.70:
|
||||
return None # Use global default (typically inworld)
|
||||
elif roll < 0.90:
|
||||
return "kokoro"
|
||||
else:
|
||||
return random.choice(["kokoro", "f5tts", "chattts"])
|
||||
|
||||
|
||||
_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
|
||||
@@ -690,7 +871,9 @@ async def generate_speech(
|
||||
text: str,
|
||||
voice_id: str,
|
||||
phone_quality: str = "normal",
|
||||
apply_filter: bool = True
|
||||
apply_filter: bool = True,
|
||||
provider_override: str = None,
|
||||
emotional_register: str = "",
|
||||
) -> bytes:
|
||||
"""
|
||||
Generate speech from text with automatic retry on failure.
|
||||
@@ -700,14 +883,16 @@ async def generate_speech(
|
||||
voice_id: ElevenLabs voice ID (mapped to local voice if using local TTS)
|
||||
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)
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
provider = settings.tts_provider
|
||||
print(f"[TTS] Provider: {provider}, Text: {text[:50]}...")
|
||||
provider = provider_override or settings.tts_provider
|
||||
print(f"[TTS] Provider: {provider}{' (override)' if provider_override else ''}, Text: {text[:50]}...")
|
||||
|
||||
gen_fn = _TTS_PROVIDERS.get(provider)
|
||||
if not gen_fn:
|
||||
@@ -718,7 +903,8 @@ 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}")
|
||||
break
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
# Daily backup of critical AI podcast data to NAS
|
||||
# Backs up: Castopod MariaDB dump, local data/ directory, publish state
|
||||
#
|
||||
# Usage: ./backup.sh
|
||||
# Cron: 0 3 * * * /Users/lukemacneil/code/ai-podcast/backup.sh >> /tmp/ai-podcast-backup.log 2>&1
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NAS_HOST="mmgnas"
|
||||
NAS_USER="luke"
|
||||
NAS_PORT="8001"
|
||||
DOCKER_BIN="/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
||||
BACKUP_BASE="/share/CACHEDEV1_DATA/backups/ai-podcast"
|
||||
PROJECT_DIR="/Users/lukemacneil/code/ai-podcast"
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
KEEP_DAYS=14
|
||||
|
||||
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Starting backup..."
|
||||
|
||||
# 1. Dump Castopod MariaDB on NAS
|
||||
echo " Dumping MariaDB..."
|
||||
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" \
|
||||
"$DOCKER_BIN exec castopod-mariadb-1 mysqldump -u castopod --password=\$(cat /run/secrets/db_password 2>/dev/null || echo BYtbFfk3ndeVabb26xb0UyKU) castopod" \
|
||||
> "/tmp/castopod-db-${DATE}.sql" 2>/dev/null
|
||||
|
||||
if [ -s "/tmp/castopod-db-${DATE}.sql" ]; then
|
||||
gzip -f "/tmp/castopod-db-${DATE}.sql"
|
||||
scp -P "$NAS_PORT" "/tmp/castopod-db-${DATE}.sql.gz" \
|
||||
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/castopod-db-${DATE}.sql.gz"
|
||||
rm -f "/tmp/castopod-db-${DATE}.sql.gz"
|
||||
echo " MariaDB dump: OK"
|
||||
else
|
||||
echo " WARNING: MariaDB dump is empty or failed"
|
||||
fi
|
||||
|
||||
# 2. Sync data/ directory to NAS (rsync for efficiency)
|
||||
echo " Syncing data/ directory..."
|
||||
rsync -az --delete \
|
||||
-e "ssh -p $NAS_PORT" \
|
||||
"$PROJECT_DIR/data/" \
|
||||
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/data/"
|
||||
echo " data/ sync: OK"
|
||||
|
||||
# 3. Backup .env (contains API keys — critical for disaster recovery)
|
||||
echo " Backing up .env..."
|
||||
scp -P "$NAS_PORT" "$PROJECT_DIR/.env" \
|
||||
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/env-${DATE}.bak"
|
||||
echo " .env backup: OK"
|
||||
|
||||
# 4. Prune old backups
|
||||
echo " Pruning backups older than ${KEEP_DAYS} days..."
|
||||
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" \
|
||||
"find $BACKUP_BASE -name 'castopod-db-*.sql.gz' -mtime +${KEEP_DAYS} -delete 2>/dev/null; \
|
||||
find $BACKUP_BASE -name 'env-*.bak' -mtime +${KEEP_DAYS} -delete 2>/dev/null"
|
||||
echo " Prune: OK"
|
||||
|
||||
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Backup complete."
|
||||
@@ -0,0 +1,829 @@
|
||||
{
|
||||
"lineups": [
|
||||
{
|
||||
"timestamp": 1785806090.001651,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Vonda",
|
||||
"situation": "Six weeks ago Vonda noticed that one of her drivers \u2014 a young man named Cody, 24, supporting a newborn \u2014 had been falsifying his hours-of-service logs. Not dramatically, just shaving entries to avoid going over the federal limit and losing his CDL. She's been covering for him, not reporting it, because she knows what losing that job would do to his family. Last night a tanker from a different company jackknifed on I-10 near the Bakersfield cutoff \u2014 driver fell asleep, DOT is now doing a sweep of all regional carriers' logs. Her company's records will be audited within days.",
|
||||
"voice": "Marlene"
|
||||
},
|
||||
{
|
||||
"name": "Stetson",
|
||||
"situation": "Stetson has no moral crisis. He is calling because last Friday he was doing a rough-in electrical inspection at a new gallery build on Highland Avenue and found, inside a sealed wall cavity that hadn't been opened since the building's 1940s construction, a leather satchel containing 47 hand-addressed envelopes \u2014 all stamped, all sealed, all addressed to different people in Marfa and Presidio, postmarked 1943. None of them were ever sent. He has been opening them one by one and reading them, and they are almost entirely love letters \u2014 from the same handwriting, a woman named Eula, to 47 different men.",
|
||||
"voice": "Ethan"
|
||||
},
|
||||
{
|
||||
"name": "Lupita",
|
||||
"situation": "Lupita's thesis advisor \u2014 a well-regarded professor who controls her funding, her committee, and her graduation timeline \u2014 has been taking credit in conference presentations for a methodology Lupita developed herself. It's not plagiarism in any documentable way: the work is collaborative on paper, she's listed as second author, and the professor never explicitly claimed sole invention. But at a conference in San Antonio last month, Lupita watched him describe the method to a room of peers using 'I' over and over while she sat in the third row. She has one semester left. If she confronts him she risks her funding. If she says nothing she gets her degree and leaves.",
|
||||
"voice": "Luna"
|
||||
},
|
||||
{
|
||||
"name": "Birch",
|
||||
"situation": "Birch has no dilemma. He is calling because he has spent the last two years obsessively reconstructing the complete hydrological history of Comanche Springs in Fort Stockton \u2014 the largest spring system in Texas until it went dry in 1961 when irrigation pumping collapsed the aquifer. He has found survey records, eyewitness accounts, old photographs, and a 1954 Army Corps of Engineers internal memo he obtained through an archives request that suggests the spring could have been saved if one specific ranching family had agreed to reduce their draw by 30 percent. They didn't. He knows who the family is. They still ranch in Pecos County. Their grandchildren are at community events in Fort Stockton right now.",
|
||||
"voice": "Dennis"
|
||||
},
|
||||
{
|
||||
"name": "Paloma",
|
||||
"situation": "Eight months ago Paloma processed a family \u2014 a woman and two kids \u2014 who crossed legally from Ojinaga with valid documents. Routine. Two weeks later the woman came back through alone, visibly injured, and Paloma flagged her for secondary. In secondary the woman told her, quietly, that her husband \u2014 still on the Mexican side \u2014 had been threatening her and she was trying to leave. Paloma, acting on her own judgment and outside strict protocol, helped connect the woman with a Presidio legal aid contact and let her through on a humanitarian basis that was technically within her discretion but which she did not fully document as required. The woman and her kids are now safe in San Antonio. Last week Paloma's supervisor mentioned, casually, that there's a random case audit happening next quarter and her files from last fall are in the sample.",
|
||||
"voice": "Nadia"
|
||||
},
|
||||
{
|
||||
"name": "Rowdy",
|
||||
"situation": "Rowdy has no moral crisis. He is calling because three days ago he was leading a solo paddle on a remote stretch of the Rio Grande below Mariscal Canyon and found, pulled up on a gravel bar on the U.S. side, an absolutely pristine wooden rowboat \u2014 hand-built, painted sky blue, with no registration numbers, no markings, and a single red wool blanket folded on the middle thwart. No footprints around it. No tracks leading anywhere. He checked it against every known crossing point and outfitter inventory. It belongs to nobody. It's still there.",
|
||||
"voice": "Callum"
|
||||
},
|
||||
{
|
||||
"name": "Georgette",
|
||||
"situation": "Georgette's younger sister Vera \u2014 54, widowed three years ago \u2014 has been spending money at a rate that Georgette, as the person who quietly manages Vera's household accounts as a favor, cannot explain. Not gambling, not obvious shopping. In the last four months, $23,000 has moved out of Vera's savings in transfers Georgette can trace only to a payment app she doesn't recognize. Georgette confronted Vera last week and Vera said it was 'investments' and changed the subject. Georgette did not push. But she knows Vera is lonely and she is terrified it's a romance scam and she doesn't know how to say that to her sister without destroying what's left between them.",
|
||||
"voice": "Eleanor"
|
||||
},
|
||||
{
|
||||
"name": "Tuck",
|
||||
"situation": "Tuck has no moral crisis. He is calling because he has developed, over years of driving the same roads at night, an encyclopedic and genuine obsession with the specific way sound behaves in the high desert at night \u2014 what he calls 'the acoustics of nowhere.' He has documented, on a voice recorder he keeps on the dash, over 200 distinct sound events he cannot explain by conventional echo or wind: voices that seem to come from ridgelines with no one on them, music that appears and vanishes over two or three miles of road, a low mechanical hum audible only in the valley between the Davis Mountains and the Glass Mountains that he has now recorded on 14 separate nights. He is not a UFO person. He is not a ghost person. He genuinely believes this is atmospheric physics that nobody has bothered to study because not enough people drive these roads at night paying attention.",
|
||||
"voice": "Grant"
|
||||
},
|
||||
{
|
||||
"name": "Esperanza",
|
||||
"situation": "Esperanza's dilemma is this: her fifteen-year-old student \u2014 a bright, difficult boy named Marco \u2014 handed in an essay last month that was, she recognized immediately, a thinly fictionalized account of his stepfather moving drugs across the river. Not a rumor. Specific details: timing, crossing method, the make of the truck. She has been sitting on this for four weeks. She did not report it to the school director because the school director is the stepfather's cousin. She did not report it to the municipal police because she does not trust them. She has a contact at the DEA field office in Presidio \u2014 a woman she met at a community meeting years ago \u2014 but using it would expose Marco, who she believes wrote the essay because he wanted someone to know and didn't know how to say it any other way.",
|
||||
"voice": "Jessica"
|
||||
},
|
||||
{
|
||||
"name": "Hensley",
|
||||
"situation": "Hensley is calling because he is absolutely convinced, based on eleven months of personal observation, trail camera footage, and what he describes as 'track evidence that don't match anything in the field guides,' that there is a breeding population of ocelots in the limestone canyon country north of Sanderson \u2014 not in the known South Texas range, not anywhere near where ocelots are supposed to be, but in the high desert scrub of Terrell County where they have no business being. He has not contacted Texas Parks and Wildlife because the last time he reported something unusual to state wildlife authorities \u2014 a mountain lion denning close to a ranch road \u2014 the ranch owner found out and the lion was gone within a week. He doesn't trust the information chain.",
|
||||
"voice": "Malcolm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1785806481.347754,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Silas",
|
||||
"situation": "The Wellspring's first week on the Terlingua land has surfaced a crisis Silas did not anticipate: a woman named Petra, one of the community's most devoted members for six years, has announced she will not participate in any future Unbinding ceremonies \u2014 not because she objects, but because her adult daughter drove out from Odessa to see the new land and is now threatening to file a formal complaint with Brewster County about the commune's practices. Petra is caught between her daughter and her community, and Silas \u2014 still raw from the Marcus reckoning \u2014 is trying hard not to pressure her. But three other members watched him handle it and now they're asking quiet questions about whether the Unbinding will even continue on the new land. The fresh-start he envisioned is wobbling in its first week, and he's calling Luke partly for advice and partly because he genuinely doesn't know what the right thing is anymore.",
|
||||
"voice": "Sebastian"
|
||||
},
|
||||
{
|
||||
"name": "Devereux",
|
||||
"situation": "Devereux has been doing small jobs for the Gage Hotel for about four years \u2014 light fixture replacements, outlet work, the occasional plumbing assist. Three weeks ago, while doing some work in one of the historic guest rooms, he found a canvas rolled up inside the wall cavity behind a replaced baseboard \u2014 oil paint, maybe 18 inches by 24 inches, a desert landscape with a small figure in it that he can't stop looking at. He didn't tell anyone. He took it home. He's shown it to exactly one person, a friend who took an art history class at Sul Ross, who told him it could be significant or could be a tourist piece from the 1940s \u2014 she genuinely doesn't know. Devereux is not a thief by nature and the guilt is eating him, but he's also been poor his whole life and the painting is sitting on his kitchen table and he keeps thinking about what it might be worth.",
|
||||
"voice": "Brian"
|
||||
},
|
||||
{
|
||||
"name": "Florencia",
|
||||
"situation": "Florencia is not calling about a crisis. She is calling because two nights ago, while doing a nocturnal small-mammal survey on a transect line in the Paisano Pass area west of Alpine, she witnessed something she has been turning over in her head ever since: a pronghorn antelope \u2014 alone, which is already unusual at night \u2014 standing completely still in her headlamp beam for nearly four minutes without moving. Not frozen in fear the way prey animals freeze. Still in a different way. Deliberate. Then it walked directly toward her, stopped about fifteen feet away, looked at her for another long moment, and walked off into the dark at a normal pace. She's a scientist. She has an explanation for all of it. But she can't stop thinking about those four minutes and she wants to describe it to someone who will actually listen.",
|
||||
"voice": "Bianca"
|
||||
},
|
||||
{
|
||||
"name": "Holt",
|
||||
"situation": "Holt's dilemma is this: his son-in-law, Dustin, has been telling Holt's daughter Renee for two years that he's been promoted at the drilling company and making good money \u2014 Renee has quit her job at the Dollar General based on this, they bought a truck, they're talking about a house. Last month Holt ran into one of Dustin's actual coworkers at the Stripes on I-10 who let something slip: Dustin is still on the same crew he was on two years ago, same rate, no promotion. Holt has done the math and Dustin is burning through something \u2014 savings, a loan, something \u2014 to maintain the fiction. Renee is thirty-two, they have a four-year-old, and Holt loves Dustin, genuinely likes the man, which makes this worse. He hasn't said anything to Renee. He confronted Dustin two weeks ago in a parking lot and Dustin broke down crying and begged him for sixty days to fix it.",
|
||||
"voice": "James"
|
||||
},
|
||||
{
|
||||
"name": "Celestine",
|
||||
"situation": "Celestine is calling because she has a theory about the Marfa Lights and it is not a theory she has seen anywhere else, and she has looked. She is not a mystic and she is not credulous \u2014 she has lived in Marfa her whole life and she has watched the Lights since she was a child from the viewing platform on Highway 90. Her theory, developed over years of watching and cross-referencing with her work schedule and property logs, is this: the Lights appear more frequently and more vividly in the weeks after a large influx of visitors to Marfa \u2014 specifically after the busiest tourist weekends. She has kept a handwritten log for eleven years. She has also noted that they are rarest in January and February, when Marfa is quietest. She is not saying the tourists cause the Lights. She is saying the correlation is there and nobody is studying it and it bothers her that the people who should care \u2014 the researchers, the university types \u2014 won't take a local woman with a notebook seriously.",
|
||||
"voice": "Tessa"
|
||||
},
|
||||
{
|
||||
"name": "Beau",
|
||||
"situation": "Beau is not calling about a crisis. He is calling because six weeks ago he became genuinely, almost academically obsessed with the question of why horned lizards \u2014 horny toads \u2014 have almost completely disappeared from the areas around Alpine where they were common when his grandfather was young, and then three days ago, while clearing some old brush along a caliche road on his uncle's ranch property east of Alpine near the Leoncita area, he found a small population \u2014 maybe a dozen animals \u2014 in a rocky patch of ground that hadn't been disturbed in decades. He caught one, held it for about thirty seconds the way his grandfather taught him, and it squirted blood from its eyes at him, which he knew was a defense mechanism but had never personally experienced. He is calling because he found the lizards and because the blood-squirting experience at close range was something he needs to tell someone about.",
|
||||
"voice": "Liam"
|
||||
},
|
||||
{
|
||||
"name": "Vashti",
|
||||
"situation": "Vashti's dilemma is slow and has been building for months and came to a head this weekend. She has a colleague \u2014 a woman she has worked alongside for nine years, someone she considers a close friend \u2014 who has been systematically taking sole credit for a grant-funded family reunification program that Vashti built from the ground up. The colleague, Marisol, writes the reports, attends the funder meetings, and has begun to refer to the program as her own initiative in ways that are small enough to seem like accidents. Vashti brought it up once, gently, and Marisol apologized and then did it again the next month. This weekend Vashti found out that Marisol has been invited to present the program at a conference in El Paso in October \u2014 without mentioning Vashti's name to the organizers. The program serves families Vashti has known for years. The work is what she cares about. But she is also tired of being erased.",
|
||||
"voice": "Julia"
|
||||
},
|
||||
{
|
||||
"name": "Rayford",
|
||||
"situation": "Rayford is calling because his neighbor \u2014 a man named Clete whose family has run the adjacent property for just as long \u2014 offered to buy Rayford's 4,200 acres two months ago at a number that is, by any honest measure, a good price. Rayford said no. Clete came back last week with a higher number. Rayford said no again. What Rayford has not told anyone, including his two adult children who would inherit the land, is that he has been losing money on the cattle operation for four years running, that the well situation on the south pasture has gotten worse, and that he is seventy-one days behind on a note at the bank. His children believe the ranch is solid. One of them \u2014 his daughter Nicki \u2014 has been planning her life around eventually coming back to run it. Rayford said no to Clete both times partly out of pride and partly because of Nicki. But the bank doesn't care about Nicki.",
|
||||
"voice": "Edward"
|
||||
},
|
||||
{
|
||||
"name": "Thomasine",
|
||||
"situation": "Thomasine is not calling about a moral crisis. She is calling because she has become, over the past year, a deeply serious and self-taught expert on the history of the Chinati Hot Springs \u2014 the remote thermal springs down near Ruidosa, south of Marfa, on the Rio Grande \u2014 and specifically on the decades when the property operated as a resort and health retreat in the early and mid twentieth century. She found a box of old photographs and a handwritten guest register from the 1930s at an estate sale in Alpine a year ago and has been trying to piece together who these people were and why they came to a hot spring in the middle of the Chihuahuan desert in the Depression era. She has driven down there four times this year. She has contacted the Presidio County Historical Commission. She has found three people whose grandparents signed the register. She is calling tonight because this week she found a photograph in the box she'd somehow missed \u2014 a group of about twenty people standing in front of the main building, undated, and in the back row she is nearly certain she has identified a figure who, based on everything she knows, should not have been there.",
|
||||
"voice": "Sarah"
|
||||
},
|
||||
{
|
||||
"name": "Merritt",
|
||||
"situation": "Merritt is calling because he did something at work six weeks ago that he has been unable to resolve in his own head. A colleague of his \u2014 a senior staff astronomer named Dr. Pryce \u2014 submitted an observation request that Merritt, as the person responsible for scheduling mirror maintenance and telescope availability, knew would conflict with a pre-approved community dark-sky viewing event: one of McDonald's public Star Party nights, attended by families and school groups from all over the region. Dr. Pryce's request was legitimate and scientifically significant \u2014 a narrow window to observe a specific target. Merritt approved Dr. Pryce's request and bumped the Star Party. He did it because Dr. Pryce is his direct supervisor and the science was real. The Star Party was rescheduled with one week's notice. A group of special-needs students from a school in Odessa had planned the trip for months, had already driven four hours, and arrived to find the public program cancelled. Merritt was in the building when they arrived. He watched the teacher manage the disappointment. He did not say anything. He has not said anything since.",
|
||||
"voice": "Mark"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1785806876.617506,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Silas",
|
||||
"situation": "The Wellspring's artisanal soap operation \u2014 the financial backbone of the whole community \u2014 just lost its primary fulfillment contract. The small regional co-op that was warehousing and shipping their product out of Las Cruces dropped them because the new Terlingua address triggered a contract clause about operational continuity. Silas has thirty-one people on raw desert land, a partially built structure, no reliable shipping infrastructure, and maybe six weeks of operating cash. He is calling ostensibly to ask Luke whether he knows anyone in the region who does small-batch fulfillment, but the real thing underneath is that he is beginning to wonder if the move was a catastrophic mistake he dressed up as spiritual vision.",
|
||||
"voice": "Sebastian"
|
||||
},
|
||||
{
|
||||
"name": "Darnell",
|
||||
"situation": "Three weeks ago Darnell's seventeen-year-old son Keondre showed him a video on his phone \u2014 footage of Darnell's own foreman, a man named Pruitt, taking cash from a county contractor in the parking lot of the Comanche Springs Park. Darnell recognized the contractor: it's the same outfit that keeps winning the chip-seal bids even when their price comes in higher than competitors. Darnell has worked for Pruitt for six years. Pruitt wrote the letter that got Darnell the supervisor job. He also knows that if he reports it, the investigation will likely sweep up the whole department, and two of his crew members are men he genuinely respects who have nothing to do with the kickback. He hasn't told his wife yet. He hasn't done anything with the video. Keondre keeps asking what he's going to do.",
|
||||
"voice": "Marcus"
|
||||
},
|
||||
{
|
||||
"name": "Wrenley",
|
||||
"situation": "Wrenley has no moral crisis. She is calling because two nights ago, Saturday, she witnessed something at the Marfa Lights viewing platform on Highway 90 that she has been turning over in her head ever since and needs to say out loud to somebody. She was out there alone around 1 a.m. after her shift \u2014 she goes sometimes just to decompress \u2014 and she watched what she is certain was not headlights, not aircraft, not the Chinati Foundation doing anything: a single white light that hovered completely stationary for about four minutes, then split cleanly into three lights that moved in a slow equilateral triangle formation before dimming out one by one over maybe ninety seconds. She has seen the lights before. This was different. She is not a believer in anything particular. She is a woman who watched something she cannot explain and it has been living in her chest since Saturday.",
|
||||
"voice": "Chloe"
|
||||
},
|
||||
{
|
||||
"name": "Augusto",
|
||||
"situation": "Augusto's dilemma has been building for eighteen months and cracked open this past week. His only daughter, Renata, 34, has been living in San Antonio and has made it clear she has no interest in the ranch \u2014 she's a civil engineer, happy, married, not coming back. His only son, Felix, 39, wants the land badly, has been working alongside Augusto for twelve years, and would be the obvious heir. But Augusto has discovered \u2014 through a conversation he wasn't supposed to hear \u2014 that Felix has been quietly negotiating with a private equity land aggregator out of Houston that has been buying up ranch parcels across Brewster County. Felix hasn't said a word to Augusto. The aggregator has been buying land and leasing it back as a carbon credit operation. Augusto does not believe Felix would sell immediately, but he now believes Felix would sell within five years of inheriting. He doesn't know whether to confront Felix, change the will, or say nothing and let the land go when he's gone.",
|
||||
"voice": "Victor"
|
||||
},
|
||||
{
|
||||
"name": "Tippi",
|
||||
"situation": "Tippi is not calling about a crisis. She is calling because she has spent the last eight months going down a rabbit hole she cannot fully explain to anyone in her regular life: she has become obsessed with the 1883 survey records of the Southern Pacific Railroad right-of-way through Brewster County \u2014 specifically a three-mile discrepancy between the original survey plat and where the tracks were actually laid through what is now the west end of Alpine. She found the original survey documents in the Sul Ross archives while helping a student with unrelated research. The discrepancy means that according to the 1883 plat, the tracks should run through property that is currently occupied by a neighborhood of about fourteen homes. She has cross-referenced this with the original land grant records, the 1901 county tax rolls, and two sets of USGS topographic maps. She is not a lawyer and doesn't know what it means legally, but the geometric fact of it keeps her up at night.",
|
||||
"voice": "Loretta"
|
||||
},
|
||||
{
|
||||
"name": "Hector",
|
||||
"situation": "Six months ago Hector's twenty-six-year-old nephew, Ismael, came to work for him driving a route between Marfa and Presidio. Ismael is the son of Hector's younger brother \u2014 the brother who died of a heart attack at 49 \u2014 and Hector took him on as an act of family loyalty. Last week Hector found out from a driver at another outfit that Ismael has been doing a side job on his Marfa-to-Presidio run: picking up cash from a man in Presidio and delivering envelopes to an address in Marfa. Nobody has told Hector what's in the envelopes. The driver who told him didn't know either. But Hector knows the man in Presidio by reputation, and the reputation is not clean. Ismael has a daughter who just turned one year old. Hector hasn't confronted Ismael yet. He is a deacon. His brother is dead. He doesn't know whether to call his brother's name down on this or let the boy hang himself.",
|
||||
"voice": "Jason"
|
||||
},
|
||||
{
|
||||
"name": "Odalys",
|
||||
"situation": "Odalys is not calling about a crisis. She is calling because she has developed, over four seasons on the Rio Grande in the Big Bend, an obsessive working knowledge of the canyon acoustics \u2014 specifically, the way sound behaves inside Santa Elena Canyon, Mariscal Canyon, and Boquillas Canyon differently from each other, and what it means for guiding. She has been cataloguing this informally for two years: which canyons amplify a whistle, which ones swallow a shout, how the river noise changes pitch as water levels drop in August, the specific echo delay inside the upper Santa Elena narrows at the point where the walls close to about thirty feet. She started doing it for safety \u2014 knowing how far sound carries tells you how to communicate with a group \u2014 and then it became something else entirely. She brought a small recorder this season. Tonight she heard Luke play a piece of recorded ambient sound on air and something about the reverb made her need to call.",
|
||||
"voice": "Amina"
|
||||
},
|
||||
{
|
||||
"name": "Brecken",
|
||||
"situation": "Brecken's dilemma is recent and specific and he can't talk to anyone on campus about it. Four days ago he was doing routine irrigation maintenance near the Sul Ross athletic fields and he found a folder of printed documents left in the equipment shed \u2014 personnel records, email printouts, what appear to be drafts of a Title IX complaint \u2014 with a name on the cover sheet he recognizes: a professor he has had, a man he genuinely likes, who helped him get a summer internship with Texas Parks and Wildlife that he is supposed to start in September. The folder was just sitting there. He doesn't know if it was left accidentally or if someone put it there deliberately for someone else to find. He took a photo of the cover sheet before he put it back. He hasn't told anyone. He doesn't know who left it or who it belongs to. But he knows what he saw, and he knows the internship letter is sitting on his desk, and he knows those two facts are now entangled in his head even though they may have nothing to do with each other.",
|
||||
"voice": "Elliot"
|
||||
},
|
||||
{
|
||||
"name": "Neva",
|
||||
"situation": "Neva has no moral crisis. She is calling because three weeks ago she was harvesting honey from her hives and noticed that her bees \u2014 a colony she has kept for eleven years \u2014 had begun constructing comb in a geometry she had never seen and cannot find documented anywhere she has looked. Instead of the standard hexagonal sheet construction, a section of one frame had comb cells arranged in a spiral pattern radiating outward from a central point, like a nautilus cross-section, with cell size decreasing toward the center. She is a careful, empirical woman who has kept bees for thirty years. She has contacted two extension agents and a professor in the entomology department at Texas A&M. The extension agents told her it was probably a wax moth disruption. The A&M professor said it was likely stress-related irregular comb and sent her a paper. She has read the paper. The paper does not describe this. She has photographs. She has been keeping bees long enough to know what stress comb looks like, and this is not that.",
|
||||
"voice": "Elizabeth"
|
||||
},
|
||||
{
|
||||
"name": "Cord",
|
||||
"situation": "Cord's situation came to a head this afternoon and he pulled over on Highway 90 to think and ended up calling the show. His business partner of nine years \u2014 a man named Garrett, who owns 40 percent of the company \u2014 told Cord two weeks ago that he wanted to buy out his share and exit the business. Cord agreed, they shook on a number, Cord started putting together the financing. Today Cord found out from a drilling company dispatcher he's friendly with that Garrett has already been talking to a competitor \u2014 a larger well-service outfit out of Midland \u2014 about taking a salaried VP position and bringing the company's client list with him. The client list is not formally protected by anything because when they started the company nine years ago two guys shaking hands in a parking lot in Fort Stockton didn't think about non-competes. If Garrett walks with the client relationships, Cord loses maybe 60 percent of his revenue in the first year.",
|
||||
"voice": "Hank"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1785807224.910801,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Sondra",
|
||||
"situation": "Sondra has been doing the books for a Marathon feed-and-supply operation for eleven years. Last Thursday she found a pattern she cannot explain away: the owner has been quietly underreporting payroll for at least four years, which means his two full-time Mexican-American employees \u2014 men she knows personally, men who have worked there since before she did \u2014 have been paying into Social Security on falsified wages. The men don't know. The owner is sixty-eight, has a bad heart, and his wife just started chemo. Sondra has not said a word to anyone.",
|
||||
"voice": "Marlene"
|
||||
},
|
||||
{
|
||||
"name": "Pax",
|
||||
"situation": "Pax is not calling about a crisis. He is calling because last spring, while doing vegetation transects out in the Glass Mountains northeast of Alpine, he stumbled into what he is now convinced is one of the last undocumented nesting sites in Texas for the Aplomado falcon \u2014 a bird that was functionally extirpated from the state for decades before reintroduction programs began. He has GPS coordinates, photographs, and two confirmed adults on nest. He has told his thesis advisor. His advisor told him to sit on the data until they can publish. That was four months ago. The paper is nowhere near done. Pax is twenty-nine years old and bursting.",
|
||||
"voice": "Jonah"
|
||||
},
|
||||
{
|
||||
"name": "Truett",
|
||||
"situation": "Truett's dilemma is about his brother. His younger brother Waylon, fifty-four, has been sober for nine years \u2014 AA, sponsor, the whole architecture. Last month Waylon started dating a woman Truett likes genuinely, a widow named Carol who works dispatch at the sheriff's office. Two weeks ago Truett ran a routine background check on Carol as part of a process-serving job \u2014 totally unrelated \u2014 and found something: Carol has a civil judgment against her from 2019 in Midland County related to a bar fight that turned into a lawsuit, and more troublingly, a dismissed DWI from 2021 that was pleaded down. Truett doesn't know if Waylon knows. Carol has given no indication she drinks now. Truett ran the check legally. He wishes he hadn't.",
|
||||
"voice": "Grant"
|
||||
},
|
||||
{
|
||||
"name": "Lupe",
|
||||
"situation": "Lupe is not calling about a crisis. She is calling because she has spent the last year developing what has become a serious, documented obsession with the architectural and commercial history of the Presidio-Ojinaga crossing \u2014 specifically the way the same families have run trade and transport on both sides of the river for over a hundred and twenty years, surviving revolution, Prohibition, NAFTA, and every border policy since. She has found letters, photographs, ledgers. She found a 1923 manifest in a box at her grandmother's house in Ojinaga that lists goods she can trace to a great-great-uncle's store that still has a building standing in Presidio. She has started giving informal talks at the Presidio library.",
|
||||
"voice": "Selene"
|
||||
},
|
||||
{
|
||||
"name": "Garrison",
|
||||
"situation": "Garrison's situation cracked open this past weekend. His nineteen-year-old daughter Lily drove home from her first year at Texas Tech and told him and his wife that she has decided not to come back \u2014 not to the ranch, not to Marfa, not to any of it. She wants to stay in Lubbock, get an apartment with friends, and study something called sustainable food systems. Garrison is not angry about the degree. He is gutted because last fall, before she left, he deeded her forty acres of the ranch \u2014 the section his own grandfather deeded to his father \u2014 in a formal transfer, with a lawyer, as a statement of faith that she was coming back. She didn't ask him to do it. He did it because he was certain.",
|
||||
"voice": "Cedric"
|
||||
},
|
||||
{
|
||||
"name": "Effie",
|
||||
"situation": "Effie is not calling about a crisis. She is calling because she has spent the last three months quietly and methodically documenting what she believes is a lost oral tradition of weather prediction specific to the Davis Mountains and the surrounding basin \u2014 a set of observational rules passed down by ranching families and old-timers that predate the National Weather Service station in the region and have, in her testing, a remarkable accuracy rate for afternoon monsoon prediction in July and August. She has interviewed eleven people, the oldest of whom is ninety-three. She is cross-referencing their observations against sixty years of NWS records she pulled from Alpine and Marfa.",
|
||||
"voice": "Victoria"
|
||||
},
|
||||
{
|
||||
"name": "Rook",
|
||||
"situation": "Rook's dilemma is recent and specific. Three days ago he guided a private trip \u2014 four people, a five-day float through the Lower Canyons. On the second day, one of the clients, a man named Dennis from Houston, pulled Rook aside at camp and offered him three hundred dollars cash to tell the rest of the group that a particular side canyon was too dangerous to enter. It wasn't. Rook knew it wasn't. Dennis wanted to prevent his teenage stepson \u2014 who was on the trip \u2014 from making the hike because the stepson had been talking the whole first day about how he wanted to do it and Dennis wanted to deny him the experience without being the one to say no. Rook took the money. He told the group the canyon was closed for resource protection. The stepson, a kid named Marcus, sat at the river's edge for two hours that afternoon without speaking.",
|
||||
"voice": "Hamish"
|
||||
},
|
||||
{
|
||||
"name": "Inez",
|
||||
"situation": "Inez is calling because she has a theory \u2014 a real, worked-out, documented theory \u2014 that the water table beneath a significant stretch of Pecos County has been contaminated by injection well activity associated with Permian Basin disposal operations, and that this contamination is the explanation for a cluster of specific gastrointestinal and neurological presentations she has been seeing in patients from two particular zip codes over the past three years. She is not a scientist. She is an LVN with thirty years of bedside experience and a very good memory for patterns. She has kept a personal log. She has cross-referenced her log against public Railroad Commission injection well permits using the RRC's online database. She has shown her log to one physician, who told her correlation isn't causation and moved on.",
|
||||
"voice": "Darlene"
|
||||
},
|
||||
{
|
||||
"name": "Dovie",
|
||||
"situation": "Dovie's situation is this: she was hired eight months ago to help a visiting artist \u2014 a moderately well-known sculptor from New York named Callista \u2014 install a temporary public piece on a private property outside Marfa. During the installation, Dovie did most of the actual physical labor. The piece was documented, reviewed in two online art publications, and Callista was credited as sole creator. Dovie is fine with that \u2014 she was paid and she knew the deal. What Dovie is not fine with is that last week she found out Callista has sold the piece \u2014 supposedly temporary, supposedly site-specific \u2014 to a collector in Zurich for an amount Dovie accidentally overheard her talking about on the phone: four hundred and twenty thousand dollars. Dovie made eleven dollars an hour.",
|
||||
"voice": "Sophie"
|
||||
},
|
||||
{
|
||||
"name": "Wendell",
|
||||
"situation": "Wendell is a true believer and he is dead serious. He has spent the last nine months compiling evidence for what he is convinced is a coordinated and deliberate suppression of a specific type of highway infrastructure \u2014 the concrete box culvert designs used on state highways in Brewster, Terrell, and Val Verde counties in the 1940s and 1950s. He believes these culverts were built according to a non-standard specification that appears in no TxDOT archive he has been able to access, that several of them are still load-bearing on active roads, and that someone \u2014 he is not sure who, but possibly a contractor family with long ties to Austin \u2014 has been systematically ensuring these structures are replaced rather than documented, destroying evidence of what he believes was a large-scale materials fraud that stretched from roughly 1941 to 1959. He has photographs of seventeen culverts. He has a spreadsheet.",
|
||||
"voice": "Arthur"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1785807641.615133,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Silas",
|
||||
"situation": "The Wellspring's artisanal soap operation \u2014 the financial backbone of the whole community \u2014 just lost its primary fulfillment contract with the small regional co-op that had been handling shipping out of Las Cruces. Now, stranded on remote land outside Terlingua with no reliable shipping infrastructure, Silas is watching the commune's income evaporate in real time. He's called nominally to ask Luke's audience if anyone knows a freight or fulfillment solution out of the Big Bend, but underneath that he's grappling with whether the move was a catastrophic mistake driven more by his own need to escape the shame of the coercion reckoning than by genuine vision. Tonight a twenty-three-year-old member named Petra \u2014 one of the true believers who drove the U-Haul all the way from Deming \u2014 told him she was leaving because she 'didn't sign up to be poor in the desert.' That one landed.",
|
||||
"voice": "Sebastian"
|
||||
},
|
||||
{
|
||||
"name": "Darlene",
|
||||
"situation": "Darlene has been renting a room to Cody, twenty-four, for eight months \u2014 a Permian Basin roughneck who works two-weeks-on, two-weeks-off rotations and is otherwise quiet, pays on time, and keeps to himself. Last Friday Darlene was pulling weeds along the side of the house and found, wedged between the foundation and the AC unit, a ziploc bag containing forty-three hundred dollars in cash and a handwritten list of first names with dollar amounts next to them. She put it back exactly where she found it. Cody came home Saturday and she acted normal. She does not believe Cody is dangerous \u2014 nothing about him feels dangerous \u2014 but the list has seven names on it and she doesn't know if she's looking at a drug debt ledger, someone paying people back, a loan operation, or something else entirely. She's not going to call the police. She wants to know whether to ask him directly or let it go.",
|
||||
"voice": "Tessa"
|
||||
},
|
||||
{
|
||||
"name": "Fletcher",
|
||||
"situation": "Fletcher is calling because of something that happened to him last Thursday night and he has told exactly one person \u2014 his dog. He was running new conduit in a crawlspace under one of the Gage Hotel's outbuildings around ten-thirty at night, a job that ran long, and he heard what he can only describe as a sustained, low, resonant tone coming from below him \u2014 not mechanical, not HVAC, not anything in the building. He's been in that crawlspace a dozen times. He lay still for about four minutes and the tone shifted in pitch, slowly, and then stopped. He is not a paranormal person. He does not watch ghost shows. He has a completely mundane theory \u2014 some kind of acoustic phenomenon, maybe the aquifer, maybe wind \u2014 and he called to see if anyone else has ever heard anything like it, because he can't stop thinking about it and he's going back to finish the job Wednesday night and he's nervous in a way he doesn't know what to do with.",
|
||||
"voice": "Carter"
|
||||
},
|
||||
{
|
||||
"name": "Rosalinda",
|
||||
"situation": "Rosalinda's dilemma is eighteen months old and came back to the surface last week. Eighteen months ago she let someone through \u2014 a woman, fifties, claiming to be visiting family in Presidio, documentation in order \u2014 and something was wrong. Not wrong enough to hold her, not wrong enough to flag under any protocol. Just wrong in the way Rosalinda's body knew things before her brain did. She let her through. Two weeks later that woman was arrested in Odessa connected to a trafficking operation. Rosalinda was never implicated, was never questioned \u2014 her documentation check was clean and correct. Nobody knows she had the feeling. Last week she processed a crossing and had the exact same feeling about a young man, and this time she found a reason to hold him for secondary inspection. Nothing turned up. He was clean. She let him go. But the eighteen-month-old decision has been sitting in her chest since last week like a stone, and she is calling because she needs to know: do you act on a feeling you can't justify or do you do the job by the book and live with what the book misses?",
|
||||
"voice": "Veronica"
|
||||
},
|
||||
{
|
||||
"name": "Burl",
|
||||
"situation": "Burl is calling because he has a story and it is a good one and he has been trying to tell it to people all week and nobody will sit still for the whole thing. In 1987 \u2014 Burl was twenty-eight \u2014 a stranger appeared on his family's ranch during a drought and asked permission to water his horse at their tank. His father let him. The man stayed three days, helped with fence work, spoke almost no Spanish and almost no English, and left without ceremony. He had, according to Burl's father, the single most useful pair of hands either of them had ever seen \u2014 not strong, just precisely, eerily efficient with every task. Before he left he told Burl's father, in a mix of hand gestures and broken words, that the drought would break in eleven days. It broke in nine. Burl has been thinking about this man his entire life. Two weeks ago, cleaning out his father's old desk, he found a photograph he had never seen \u2014 his father standing next to a stranger, labeled on the back in his father's handwriting with a date and one word: 'Jacobo.' The photo is from 1971. Burl was not born yet. The man in the photo and the man from 1987 look identical. Same age.",
|
||||
"voice": "Ronald"
|
||||
},
|
||||
{
|
||||
"name": "Marisol",
|
||||
"situation": "Marisol's dilemma happened five days ago and she has been in a low-grade panic since. She is teaching a freshman comp section this summer \u2014 eight students, a small class \u2014 and one of her students, a nineteen-year-old named Derek, turned in a personal essay that was, by any measure, extraordinary: raw, structurally sophisticated, genuinely moving. She gave it an A and wrote extensive comments. Two days later Derek came to office hours and, after some awkward circling, told her he'd had help \u2014 not from a tutor, not from AI, but from his older brother, who is apparently a writer. The essay is Derek's story, Derek's experience, Derek's voice as he described it to his brother, but the sentences are not Derek's sentences. Marisol does not know whether this is plagiarism, collaboration, ghostwriting, or just a kid from a family where help looks different than the academic integrity policy imagined. Derek is a first-generation college student. He was terrified to tell her. She has not yet reported it or changed the grade and the window for academic integrity referrals is closing.",
|
||||
"voice": "Naomi"
|
||||
},
|
||||
{
|
||||
"name": "Cletis",
|
||||
"situation": "Cletis is not calling about a crisis. He is calling because he has spent the last two years developing what he believes is a comprehensive and airtight theory about the acoustics of desert canyon systems in the Big Bend \u2014 specifically, that the rock formations along certain stretches of the Rio Grande canyon walls act as natural parabolic reflectors that can focus and amplify ambient sound over distances of up to three miles. He has been testing this with a calibrated decibel meter he built himself from salvaged parts and a microphone array he designed. His theory, if correct, would explain several reported phenomena in the area including certain Marfa Lights witness accounts involving sound (which are rarely discussed) and some of the stranger Rio Grande canyon echo reports going back to the Spanish colonial period. He is not saying it's supernatural. He is saying the physics is genuinely interesting and nobody has studied it properly because it requires being in very remote places at three in the morning for extended periods, which most acoustical researchers are apparently not willing to do.",
|
||||
"voice": "Derek"
|
||||
},
|
||||
{
|
||||
"name": "Genevieve",
|
||||
"situation": "Genevieve's situation cracked open yesterday and she is still shaking from it. She manages rental properties for several absentee owners, including a New York couple who own a Marfa house they rent on a short-term basis. Yesterday she went to do a turnover inspection between guests and found, in the house, a sixty-three-year-old Marfa ranching family patriarch named Harlan Briggs \u2014 a man she has known her entire sixteen years here \u2014 asleep on the couch. Harlan is not the renter. Harlan has no connection to the property that Genevieve knows of. He was not breaking in \u2014 the door was unlocked from the previous guests. He woke up, looked at her without embarrassment, said 'I just needed somewhere cool and quiet,' and left. Genevieve knows Harlan's family. She knows his wife. She knows the family has been on that land for four generations. She also knows that Harlan's ranch has been in serious financial trouble and that last year a portion of the family land was sold to a Dallas investment group. She has to decide whether to report the incident to the property owners \u2014 which would likely result in a trespassing complaint against a man whose family has been in this county longer than the property owner has been alive \u2014 or say nothing.",
|
||||
"voice": "Olivia"
|
||||
},
|
||||
{
|
||||
"name": "Ptolemy",
|
||||
"situation": "Ptolemy is not calling about a crisis. He is calling because he spent the afternoon at the McDonald Observatory near Fort Davis and came away with a fact he cannot stop turning over in his mind and has been trying to explain to every person he's encountered since, with limited success. The fact is this: the observatory's Hobby-Eberly Telescope, one of the largest in the world, was specifically designed with a fixed altitude \u2014 it doesn't tilt up and down, only rotates \u2014 meaning there is a band of the sky it simply cannot observe. The designers accepted this constraint because the telescope's primary purpose, studying the spectra of faint objects, doesn't require full-sky coverage. Ptolemy finds this philosophically staggering: the most powerful eye in the Western Hemisphere was deliberately built with a blind spot, by choice, because the blind spot was worth it. He has been applying this idea to everything since three o'clock this afternoon and he needs to talk it through with someone before he drives back to Austin.",
|
||||
"voice": "Felix"
|
||||
},
|
||||
{
|
||||
"name": "Yolanda",
|
||||
"situation": "Yolanda's dilemma is about her younger sister Gloria, sixty-six, who lives in the same town. Their mother died fourteen months ago and left the family home \u2014 a house in Sanderson that has been in the family since 1961 \u2014 to both of them equally. Yolanda wants to sell. The house needs $40,000 in repairs she cannot afford, the Sanderson real estate market is essentially nonexistent, and she is seventy-one and tired of maintaining a property she doesn't live in. Gloria refuses to sell under any circumstances and refuses to buy Yolanda out and refuses to contribute to repairs, on the grounds that selling the house would be a betrayal of their mother. Last week a buyer appeared \u2014 a young couple from San Antonio who want to move to Sanderson and fix it up themselves, and they've offered fair market value. Gloria has already told the buyer the house is not for sale. Yolanda is calling because she is considering forcing a partition sale through a lawyer \u2014 a legal mechanism that would compel the sale over Gloria's objection \u2014 and she knows it will end her relationship with her sister permanently.",
|
||||
"voice": "Zadie"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1785824765.051897,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Tess",
|
||||
"situation": "Tess's grandmother left her a 1940s adobe on the edge of Marfa \u2014 the house where Tess learned to ride, where her grandfather died, where every room has a story. Six months ago, broke and desperate after a bad cattle year, Tess listed it on Airbnb. It now pays better than the ranch. Last Friday she arrived to clean between guests and found the visitors had staged the entire interior for a fashion shoot \u2014 moved her grandmother's furniture, hung white fabric over the family photos, and left a printed note thanking her for the 'blank canvas aesthetic.' She stood in the middle of her grandmother's bedroom for forty minutes before she could move.",
|
||||
"voice": "Nadia"
|
||||
},
|
||||
{
|
||||
"name": "Coop",
|
||||
"situation": "Coop is not calling about a crisis. He is calling because he is obsessed with something nobody out here wants to talk about: open-water swimming in the Permian Basin's produced-water containment ponds, which he has never done and never would, but has been researching obsessively for a year after reading about extreme swimmers seeking out forbidden or impossible venues. What started as morbid curiosity became a genuine deep-dive into the chemistry, the geography, and the sheer scale of West Texas water infrastructure most people don't know exists. He drove out last weekend and stood at the fence line of a containment facility outside Fort Stockton and just looked at it \u2014 thousands of acres of flat, still, toxic water in the middle of the desert \u2014 and something about the scale and the wrongness of it will not leave him alone.",
|
||||
"voice": "Hank"
|
||||
},
|
||||
{
|
||||
"name": "Brecken",
|
||||
"situation": "Brecken's dilemma is four days old and involves his thesis advisor, Dr. Harmon, a man he has admired for three years. Brecken discovered, while running data cross-checks for his own thesis chapter, that a key table in Harmon's 2019 published paper \u2014 the paper that forms the methodological backbone of Brecken's entire research program \u2014 contains what appear to be fabricated data points. Not misremembered. Not rounding errors. The same decimal pattern repeating across supposedly independent sample sites in a way that doesn't happen in nature. Brecken has shown it to no one. He has a meeting with Harmon on Thursday to review his own thesis draft.",
|
||||
"voice": "Liam"
|
||||
},
|
||||
{
|
||||
"name": "Odessa",
|
||||
"situation": "Odessa is not calling about a crisis. She is calling because this evening, around dusk, she had the best moment she has had in five years and she needs to tell somebody. She has been keeping Chihuahuan Desert honeybee colonies in Terlingua for six years, adapting northern European beekeeping methods to extreme heat and drought with no manual, no mentor, and mostly failure. Tonight she pulled her first successful mead \u2014 a small batch from her own honey, fermented through the brutal summer \u2014 and it tasted exactly right. She drank a glass sitting outside watching the last light go off the Chisos and she cried, and she is not embarrassed about that.",
|
||||
"voice": "Loretta"
|
||||
},
|
||||
{
|
||||
"name": "Harlow",
|
||||
"situation": "Harlow's dilemma is about her oldest friend, Petra Canales, who she has known since first grade. Petra's husband Danny is running for Brewster County Commissioner in November. Last month Harlow processed a filing \u2014 routine public record \u2014 that shows a mechanics lien against Danny's construction business totaling $87,000 from a subcontractor in Presidio. It is a matter of public record. It is also something that, if it became widely known before the election, would probably end Danny's campaign and likely his marriage, because Petra does not know the debt exists. Harlow has done nothing. She has told no one. But a reporter from the Alpine Avalanche called her office today asking for any recent lien filings in Brewster County.",
|
||||
"voice": "Julia"
|
||||
},
|
||||
{
|
||||
"name": "Wick",
|
||||
"situation": "Wick is a true believer and he is dead serious. He has spent the last four months documenting what he is convinced is a pattern of deliberate, coordinated cattle mutilations along a sixty-mile corridor between Marfa and Presidio \u2014 not random predator kills, not decomposition, not the usual explanations. He has photographs. He has GPS coordinates. He has a notebook with dates, wind directions, lunar phases, and the specific nature of the excisions, which follow a consistency he spent thirty years in law enforcement learning to recognize as intentional. He has taken his findings to the Presidio County Sheriff's office twice. He was not taken seriously either time. He believes the pattern is escalating.",
|
||||
"voice": "Victor"
|
||||
},
|
||||
{
|
||||
"name": "Paloma",
|
||||
"situation": "Paloma's dilemma happened three weeks ago and she has been carrying it alone since. She was named to the Sul Ross cross-country travel roster for a meet in Odessa. The night before the bus left, her teammate and close friend Dani told her, in confidence, that she had been taking a prescription stimulant that wasn't hers to manage her training volume \u2014 not a tested substance at the NAIA level, technically, but a violation of Sul Ross's athletic code. Paloma said nothing. They went to the meet. Dani ran the best race of her life. Yesterday the athletic department announced a random conduct review of the roster for next semester, and Dani came to Paloma's dorm room crying, asking if Paloma had reported her.",
|
||||
"voice": "Chloe"
|
||||
},
|
||||
{
|
||||
"name": "Gideon",
|
||||
"situation": "Gideon is not calling about a crisis. He is calling because he has been building a sixteen-inch Dobsonian reflector telescope from scratch for three years \u2014 grinding his own mirror, constructing the tube and rocker box from salvaged materials \u2014 and last Saturday night he used it for the first time under the dark skies outside Fort Davis and saw the Veil Nebula, a supernova remnant, in more detail than he has ever seen it outside of photographs. He is not a romantic about it. He is a physics teacher. But he sat at the eyepiece for two hours because he could not make himself stop, and what struck him was not beauty but scale \u2014 he was looking at the debris field of a star that exploded ten thousand years ago, and the light reaching his homemade mirror had been traveling since before Stonehenge.",
|
||||
"voice": "Conrad"
|
||||
},
|
||||
{
|
||||
"name": "Trace",
|
||||
"situation": "Trace's dilemma is about a decision he made nine months ago that he has decided, tonight, was wrong. He bid on and won a contract to buy clip from a small Angora goat operation outside Dryden \u2014 an elderly couple, the Seavers, who have run the place for forty years. He knew when he bid that the price he offered was below fair market. He bid low because he could, because the Seavers don't have other buyers willing to drive out there, and because that is how the business works. Last week he went out to the Seavers' place for the spring clip and found out that Roy Seaver had a stroke in May and that his wife Norma has been running the whole operation alone since then. She had the clip ready and she had the paperwork ready and she thanked him for coming out.",
|
||||
"voice": "Blake"
|
||||
},
|
||||
{
|
||||
"name": "Birdie",
|
||||
"situation": "Birdie is not calling about a crisis. She is calling because she is furious about a specific, recent injustice and she has opinions and she has waited two days to call because she wanted to get her facts straight first. The Brewster County Historical Commission has declined \u2014 for the third time \u2014 to designate the old Southern Pacific railroad depot on the east side of Alpine as a historic landmark, despite Birdie having submitted documentation going back to 1882. The building is currently being eyed by a developer from Austin who wants to convert it into a boutique hotel. Birdie has a personal connection to the depot: her father worked as a freight agent there from 1962 to 1981, and she spent her childhood in and around that building. She also has a historical argument and she is prepared to make it.",
|
||||
"voice": "Sarah"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1785825116.9801579,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Rayfield",
|
||||
"situation": "Rayfield's dilemma is eight days old and he cannot stop thinking about it. He caught his nephew, Denny \u2014 twenty-two, just got hired on at the Gage Hotel as a groundskeeper \u2014 stealing a catalytic converter off the truck belonging to an elderly Tejano rancher named Aurelio Serna, whose family has run cattle east of Marathon for four generations. Rayfield saw it happen from his porch at 2 a.m. He didn't call the police. He confronted Denny himself, made him put the part back before Serna noticed it was gone, and kept it quiet. But then he found out that Denny has been doing this for months \u2014 this was not a first time \u2014 and that old Serna has been paying out of pocket to replace converters he thought were just failing on their own. Rayfield knows Serna. He knows the man cannot easily afford this. He kept his nephew out of jail and now he is not sure he had the right to do that with someone else's losses.",
|
||||
"voice": "Craig"
|
||||
},
|
||||
{
|
||||
"name": "Suki",
|
||||
"situation": "Suki is not calling about a crisis. She is calling because two nights ago she witnessed the Marfa Lights \u2014 actually witnessed them, a full display, not a smear of headlights \u2014 and what she saw does not match any of the standard explanations she has since read, and she is genuinely, delightedly unnerved. She pulled off at the official Marfa Lights Viewing Area on Highway 90 on her way back from a supply run to Alpine, and the lights performed for nearly forty minutes: splitting, rejoining, hovering, one of them dropping straight down behind the Chinati Mountains and then reappearing on the other side. She has seen the lights three times before and dismissed them. This time she took video on her phone. The video, she says, is terrible \u2014 it looks like nothing \u2014 but she knows what she saw.",
|
||||
"voice": "Priya"
|
||||
},
|
||||
{
|
||||
"name": "Gus",
|
||||
"situation": "Gus's dilemma is about his partner on patrol, Deputy Terri Lujan, whom he has worked alongside for six years and considers a close friend. Three weeks ago Gus discovered, while reviewing dashcam footage for an unrelated incident report, that Terri had a thirty-minute personal stop \u2014 unlogged \u2014 at a house on the north edge of Fort Stockton that Gus recognizes. The house belongs to a man named Cecil Pryor, who Gus knows informally as someone connected to a methamphetamine distribution pipeline that runs up through Pecos County from the border. Terri is not under investigation. The stop may have been entirely innocent \u2014 she has family on that street, she could have had any reason. But Gus has watched that footage eleven times and the thirty minutes bothers him. He has not reported it. He has not asked her about it. He is sitting on it.",
|
||||
"voice": "Damon"
|
||||
},
|
||||
{
|
||||
"name": "Anzelika",
|
||||
"situation": "Anzelika is not calling about a crisis. She is calling because this morning she guided a solo float through the lower canyons of Santa Elena Canyon and encountered something she has never seen in three seasons on the river: the Rio Grande at that section ran completely still \u2014 no current, mirror-flat \u2014 for approximately a quarter mile, which she knows is hydrologically bizarre given the canyon's normal gradient and the current water levels. The water was clear to the bottom, which she has also never seen there. She photographed it. The stillness lasted about fifteen minutes before the current resumed as if nothing had happened. She asked the two geologists who were her clients \u2014 a couple from Socorro, New Mexico, on a research float \u2014 and they had no explanation either and were visibly agitated in a way she found exciting.",
|
||||
"voice": "Bianca"
|
||||
},
|
||||
{
|
||||
"name": "Merritt",
|
||||
"situation": "Merritt's dilemma involves his oldest son, Caleb, thirty, who came back to Alpine two years ago after a decade in Midland working oilfield logistics. Caleb came back broke and Merritt brought him into the store. Six months ago Merritt let Caleb take over the books. Last week Merritt's accountant called for a routine question and let slip a number that didn't match what Caleb had been reporting. Merritt has now gone through four months of receipts himself. Caleb has been skimming \u2014 not dramatically, not recklessly, but steadily. About $14,000 over the six months. Merritt has not confronted Caleb. He has not told his wife. He has not told his accountant. He is calling tonight because Caleb is coming to Sunday dinner in five days and Merritt does not know how to sit across a table from him.",
|
||||
"voice": "Marcus"
|
||||
},
|
||||
{
|
||||
"name": "Fern",
|
||||
"situation": "Fern is not calling about a crisis. She is calling because she spent this afternoon doing something she has never done in seventy-one years: she attended a Donald Judd art installation at the Chinati Foundation in Marfa, dragged there by her granddaughter who is visiting from Austin. Fern went in skeptical and came out genuinely, almost angrily moved by the aluminum boxes in the artillery sheds \u2014 not because she understood them intellectually but because standing in that specific light with those specific objects did something to her body that she could not predict and cannot explain. She is furious at herself for being affected and also cannot stop thinking about it.",
|
||||
"voice": "Marlene"
|
||||
},
|
||||
{
|
||||
"name": "Desmond",
|
||||
"situation": "Desmond's dilemma happened three months ago and he has been carrying it under a professional smile since. He was on a search committee that hired a new colleague \u2014 Dr. Renata Voss, a wildlife ecologist who joined the department in May. Desmond was enthusiastic about her hire. Two weeks after she arrived, he realized, going through old conference proceedings for an unrelated paper, that a dataset in her job application research summary contained figures he recognized from a 2019 study by a team he worked with as a postdoc \u2014 figures that were not cited. The overlap is specific enough that it cannot be coincidence. He has not told the department chair. He has not told Voss. He has been watching her teach her first summer course and finding her genuinely excellent in the classroom, which is making it worse.",
|
||||
"voice": "James"
|
||||
},
|
||||
{
|
||||
"name": "Loretta",
|
||||
"situation": "Loretta is calling because she is on a genuine, specific, long-running obsession and tonight something happened that cracked it back open. She has spent the last three years documenting every roadrunner she encounters on her delivery routes \u2014 not the cartoon, not Paisano Pete in Fort Stockton, actual greater roadrunners \u2014 photographing them, logging GPS coordinates, building a personal spreadsheet of sightings with behavioral notes. She has 847 documented sightings. Tonight, for the first time, she saw two roadrunners running in the same direction, side by side, for approximately sixty yards, at dusk on a ranch road outside Fort Davis. She has never seen this behavior. She has read everything available on roadrunner behavior and has found no documentation of this.",
|
||||
"voice": "Joy"
|
||||
},
|
||||
{
|
||||
"name": "Concho",
|
||||
"situation": "Concho is a true believer and he is calling dead serious. For the past eleven months he has been documenting what he believes is a coordinated, systematic effort to map and photograph every water source in the Trans-Pecos \u2014 stock tanks, springs, seeps, and seasonal plinths \u2014 by unmarked vehicles he has encountered on ranch roads where he works. He has logged twenty-two separate encounters. The vehicles are different each time: white pickup, gray SUV, a rented-looking sedan that had no business on a caliche road. They are always carrying survey equipment or cameras. They do not stop to talk. They are always gone before he can get to the nearest ranch gate. He does not believe this is government water surveying \u2014 he knows what that looks like, he has worked alongside USGS crews. He believes someone is building a comprehensive private inventory of every viable water source in this part of Texas ahead of a major acquisition or diversion action, and that the ranchers whose land these sources sit on do not know it is happening.",
|
||||
"voice": "Theodore"
|
||||
},
|
||||
{
|
||||
"name": "Wylene",
|
||||
"situation": "Wylene's dilemma is four days old and involves the person she considers her closest friend in Alpine \u2014 a woman named Bex, twenty-eight, who is in the same nursing cohort. Last Friday night, after their pharmacology final, Wylene and Bex went to celebrate at a bar. Wylene, who does not drink heavily, had three beers and was not impaired. At some point in the night, Bex told her \u2014 laughing, confiding, clearly thinking it was funny \u2014 that she has been systematically copying Wylene's clinical assessment write-ups for the past semester, changing enough words to pass the similarity checker. Not occasionally. Every single one. Bex then changed the subject and they kept celebrating. Wylene drove home and has not slept properly since. They have a clinical rotation together starting next Monday. Bex has not mentioned it again.",
|
||||
"voice": "Selene"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1785825905.7824361,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Silas",
|
||||
"situation": "The caravan is three vehicles \u2014 a converted school bus, a livestock trailer carrying the commune's goats, and Silas's truck \u2014 and they pulled off the road an hour ago because one of the founding members, a woman named Dove who has been with The Wellspring for nine years, announced at the Fort Stockton gas station that she is not getting back in the bus. She's sitting in the dirt by the road with her bag and says she wants to go back to Deming. The rest of the community is watching from the bus windows. Silas is standing fifty feet away, calling Luke from the dark, because he cannot figure out whether to respect her choice and let her go or whether watching her sit in the dirt on Highway 385 at midnight is its own form of coercion.",
|
||||
"voice": "Sebastian"
|
||||
},
|
||||
{
|
||||
"name": "Darlene",
|
||||
"situation": "Two weeks ago a guest checked in alone \u2014 a man in his late sixties, paid cash, gave a name Darlene is pretty sure was false, stayed four nights, barely left his room. On his last morning she found a legal-size envelope slid under the front desk with her name on it \u2014 not the hotel's name, her name, Darlene \u2014 containing $800 in cash and a handwritten note that said only: 'You were kind to me when I needed it. Don't look for me.' She has no memory of doing anything particular for this man. She smiled at him twice. She brought him an extra blanket one night without being asked. The $800 is sitting in her kitchen drawer and she cannot decide if she should keep it, report it to management, donate it, or if there is something darker she is missing about why a man hiding under a false name would leave a night auditor $800 cash.",
|
||||
"voice": "Wendy"
|
||||
},
|
||||
{
|
||||
"name": "Cutter",
|
||||
"situation": "Cutter discovered six months ago that he has a genuine, inexplicable talent for finding water with a forked mesquite branch \u2014 dowsing \u2014 and it is destroying his academic identity. He found water twice on the ranch by accident, told no one, but last week the ranch foreman asked him to do it formally for a new trough location and he found it again, eighteen inches down, exactly where the stick pulled. He is a scientist. He believes in soil surveys and GPR imaging. He cannot explain what is happening to his hands and he is terrified of what it means that it keeps working.",
|
||||
"voice": "Jake"
|
||||
},
|
||||
{
|
||||
"name": "Esperanza",
|
||||
"situation": "Esperanza's dilemma began eight days ago when her youngest son, Tom\u00e1s, twenty-six, told her he has been in a serious relationship for two years with a woman named Ingrid who is a Chinati Foundation artist-in-residence from Stockholm. Esperanza has no problem with Ingrid being Swedish. Her problem is that Tom\u00e1s has been lying to her face for two years, spending 'overnight work trips' with Ingrid in Marfa, and when she asked him why he hid it, he said he knew she would disapprove of 'the Marfa crowd.' He was not wrong. She does disapprove of the Marfa crowd. But she is sitting with the question of whether her known disapproval effectively coerced her own son into two years of lying, and whether that makes her responsible for the deception she is furious about.",
|
||||
"voice": "Olivia"
|
||||
},
|
||||
{
|
||||
"name": "Rowdy",
|
||||
"situation": "Rowdy is calling because he has been trying, for the past three years, to get Paisano Pete \u2014 Fort Stockton's famous twenty-two-foot concrete roadrunner \u2014 declared an official Texas State Symbol, and he finally got a state representative to agree to file the bill, and tonight the rep called to say he'd found a co-sponsor and it is actually going to the floor. Rowdy has been drafting letters, driving to Austin twice, organizing a petition with four thousand signatures, and doing all of this while his wife thought he was mildly insane. He is calling because he is so happy he cannot sleep and everyone in his house is already asleep and he needs to tell somebody.",
|
||||
"voice": "Grant"
|
||||
},
|
||||
{
|
||||
"name": "Neva",
|
||||
"situation": "Neva was commissioned eight months ago to paint a large mural on the side of a building on Holland Avenue in Alpine \u2014 a landscape piece featuring the Chisos Mountains at dusk. The building owner, a man named Gerald Pruitt, was enthusiastic, paid a deposit, gave her full creative control. The mural is finished and it is, by any measure, the best work Neva has ever made. Last week Gerald told her he loves it but he has sold the building to a developer from Austin who plans to resurface the exterior. The mural will be painted over within sixty days. Gerald is apologetic but has no legal obligation to preserve it \u2014 Neva's contract, which she wrote herself without a lawyer, has no preservation clause. She is not calling about suing Gerald. She is calling because she has been offered, through a contact, an opportunity to document the mural removal on video and sell the footage to an arts journal that covers ephemeral public art \u2014 essentially profiting from the destruction of her own work \u2014 and she cannot decide if that is beautiful or a betrayal of what the mural was supposed to be.",
|
||||
"voice": "Tessa"
|
||||
},
|
||||
{
|
||||
"name": "Holbrook",
|
||||
"situation": "Holbrook's dilemma is about his closest friend of thirty years, a man named Dale Fitch who ranches east of Sanderson. Fourteen months ago Dale's wife died of a fast cancer \u2014 six weeks from diagnosis to burial. In the months after, Holbrook drove out to Dale's place constantly, helped with the ranch, sat with him. Three months ago Dale met a woman named Rhonda through a mutual friend \u2014 a widow herself from Ozona \u2014 and they have gotten serious fast. Last week Dale called Holbrook to say he and Rhonda are getting married in October, fourteen months after his wife's death. Holbrook's gut reaction was visceral \u2014 he said, on the phone, without thinking, 'Dale, that's too fast.' Dale went quiet and hung up. They have not spoken in a week. Holbrook knows grief is not on a schedule. He knows Rhonda may be the best thing to happen to Dale. But he also watched Dale's wife die and sat at her grave in July heat and something in him could not make the math work, and now he may have damaged a thirty-year friendship over a feeling he is not sure was even about Dale.",
|
||||
"voice": "Rupert"
|
||||
},
|
||||
{
|
||||
"name": "Petronella",
|
||||
"situation": "Petronella is not calling about a crisis. She is calling because she spent this afternoon doing something she has been privately doing for eleven years and has never told another living soul: she is a meticulous, obsessive oral historian of Marfa's pre-Chinati past. She has recorded over three hundred hours of interviews with old-timers \u2014 ranch hands, the women who ran the Presidio County courthouse for decades, the men who remember when the Marfa Army Air Field was active, a woman who was present at the 1945 German POW escape from Camp Marfa. She has transcribed every word herself on a typewriter. Tonight she finished transcribing her final tape \u2014 an interview with a ninety-three-year-old woman named Eusebia Morales who died four months after the recording \u2014 and realized she has been doing this work alone for eleven years and has no plan for what happens to it when she dies.",
|
||||
"voice": "Victoria"
|
||||
},
|
||||
{
|
||||
"name": "Florentino",
|
||||
"situation": "Florentino is a true believer and he is calling dead serious. For the past seven months he has been logging something that happens on the Rio Grande in a specific quarter-mile stretch of the river inside the lower canyons, always between 2am and 4am, always on nights with no moon: a sound he describes as structured, repeating, and not animal. Not the canyon acoustics he has known for nineteen years. Not a boat. A low, rhythmic tonal sequence \u2014 four notes, always the same interval, fading upstream \u2014 that he has now recorded on a waterproof field recorder on six separate occasions. He has ruled out every natural explanation he can generate. He has not told his employer. He has not posted it online. He has played the recordings to two people \u2014 a sound engineer in Alpine who said it was 'interesting' and changed the subject, and his cousin who told him to stop camping alone. He believes the sound is coming from inside the canyon walls.",
|
||||
"voice": "Arjun"
|
||||
},
|
||||
{
|
||||
"name": "Mackley",
|
||||
"situation": "Mackley's dilemma is five days old and involves a decision he made in about four seconds that he cannot stop replaying. He was working a Saturday night gas station shift when a woman came in visibly shaken \u2014 late thirties, out-of-state plates, would not make eye contact \u2014 and asked him quietly if she could use the phone because hers was dead. He let her use the store phone. She called someone, spoke in a low voice for about ninety seconds, and hung up. She bought a bottle of water and left. Forty minutes later a man came in \u2014 same approximate age, agitated, asked Mackley if a woman had been in. Mackley said no. The man left. Three days later Mackley saw a missing-persons flyer in the Alpine post office for a woman matching the description \u2014 reported missing by her husband, who is offering a reward. The flyer shows a phone number to call. Mackley is now sitting with the question of whether the woman he helped was someone running from something or someone who is actually missing and in danger, and whether his lie to the man \u2014 which felt right in the moment \u2014 may have contributed to either outcome.",
|
||||
"voice": "Ethan"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1785828045.742269,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Breckenridge",
|
||||
"situation": "Breckenridge has been keeping a secret from his supervisor at McDonald Observatory for eleven weeks. During a routine maintenance check on one of the Hobby-Eberly Telescope's secondary systems, he made an adjustment that was not in his work order \u2014 a small calibration tweak he was confident about \u2014 and two days later a scheduled research observation run produced corrupted data that cost a visiting doctoral team from UT Austin their entire week's work and forced them to reschedule months out. Nobody has connected the data loss to his unauthorized adjustment. The official finding blamed an atmospheric interference anomaly. He has let that finding stand.",
|
||||
"voice": "Victor"
|
||||
},
|
||||
{
|
||||
"name": "Trudie",
|
||||
"situation": "Trudie is not calling about a crisis. She is calling because this evening, while closing up the gallery, she discovered that a piece of art she has walked past every workday for two years \u2014 a small, seemingly blank white canvas in the back corner that she assumed was an unfinished or stored work \u2014 is actually a Donald Judd work that has been hanging there unlit, unlabeled, and unnoticed by everyone including the gallery's owner, who inherited the space and has never fully inventoried it. She found this out because a Chinati Foundation curator came in tonight for an unrelated errand, glanced at it while waiting, and said, very quietly, 'Where did that come from.'",
|
||||
"voice": "Pippa"
|
||||
},
|
||||
{
|
||||
"name": "Aurelio",
|
||||
"situation": "Aurelio's dilemma has been building for two years and came to a head this past Sunday. His daughter, Celeste, thirty-three, is an immigration attorney in El Paso. She has, without telling him, been using his ranch's southeast pasture road \u2014 a private caliche track that runs close to the river \u2014 as part of a route she helps asylum-seeking families navigate to reach a designated port of entry legally. She was not sneaking people across; she was guiding them to Presidio's legal crossing. But she used his land without his permission. He found out Sunday when a Border Patrol agent he has known for twenty years stopped by and mentioned, carefully, that they'd been seeing foot traffic on that road and asked if Aurelio had authorized any humanitarian organization's access. Aurelio said no. He doesn't know if that answer protected Celeste or exposed her.",
|
||||
"voice": "Edward"
|
||||
},
|
||||
{
|
||||
"name": "Jinx",
|
||||
"situation": "Jinx is not calling about a crisis. She is calling because she has spent the last fourteen months obsessively studying the black bear population recolonizing the Davis Mountains, and tonight, during a camera trap check on a ranch north of Alpine, she retrieved footage that she believes shows two black bears engaging in play behavior with a third animal she cannot identify from the footage \u2014 not a bear, not a mountain lion, not a javelina, not a coyote. The shape and gait are wrong for everything native. She has watched the forty-eight-second clip approximately thirty times tonight.",
|
||||
"voice": "Avery"
|
||||
},
|
||||
{
|
||||
"name": "Parnell",
|
||||
"situation": "Parnell's dilemma is eleven days old. He was doing a plumbing repair job at a rental property on the edge of Marathon \u2014 a small adobe that's been rented to the same quiet tenant for four years \u2014 and found, while accessing a crawl space beneath the bathroom, a locked steel box bolted to the foundation. He is a retired federal postal inspector. He knows what hidden locked boxes mean and what they don't mean. He did not open it. He finished the job. But he cannot stop thinking about it. The tenant \u2014 a man named Reed, early fifties, polite, pays cash through a property manager \u2014 has never given Parnell any concrete reason for suspicion beyond the box itself. The property manager says Reed is never any trouble.",
|
||||
"voice": "Graham"
|
||||
},
|
||||
{
|
||||
"name": "Twyla",
|
||||
"situation": "Twyla's dilemma is about her ex-boyfriend, Cade, who is not an ex-boyfriend in the ordinary sense \u2014 they dated for six weeks four years ago and it ended cleanly. Cade is now her closest male friend and they guide trips together regularly. Three weeks ago Cade told Twyla, in confidence, that he has been diagnosed with early-onset Parkinson's. He is thirty-nine. He has not told anyone else in Terlingua, including their mutual employer, the rafting outfitter. Last week Twyla watched Cade's hand shake badly enough during a Class III rapid on the Rio Grande that a client noticed and asked if he was okay. Cade said he'd had too much coffee. Twyla said nothing. She does not know how long she can keep saying nothing while clients are in his raft.",
|
||||
"voice": "Nadia"
|
||||
},
|
||||
{
|
||||
"name": "Herschel",
|
||||
"situation": "Herschel is a true believer and he is calling dead serious. For the past two years he has been compiling evidence that the Comanche Springs \u2014 the natural springs that once made Fort Stockton a vital water stop and were declared legally dead in 1961 when overpumping from irrigation wells dried them up \u2014 are still flowing. Underground. He believes the springs did not die; they were diverted by a combination of agricultural pumping and, he is now convinced, a deliberate 1950s-era channeling project conducted by a consortium of Pecos County landowners who wanted the water routed to private cisterns rather than the public spring pool. He has deed records, pump logs from the county clerk's archive, and a hand-drawn map from 1953 he found in a box of Annie Riggs Museum donations.",
|
||||
"voice": "Dennis"
|
||||
},
|
||||
{
|
||||
"name": "Salome",
|
||||
"situation": "Salome's dilemma is six days old and involves her professor, Dr. Edmunds \u2014 a tenured history faculty member at Sul Ross she genuinely admires \u2014 and a paper she submitted for his upper-division Texas borderlands course. The paper was entirely her own work. But she used a research methodology she developed by closely following a framework from a graduate thesis she found in the Sul Ross library archives \u2014 a 1987 thesis by a student named M. Castillo. The thesis is unpublished and uncatalogued in any digital system. She cited it in her bibliography. Dr. Edmunds called her in last Thursday and told her, with evident discomfort, that the methodology section of her paper was 'derivative to a degree that concerns him.' He did not say plagiarism. He said he needed to think about how to proceed. She has not heard from him since.",
|
||||
"voice": "Julia"
|
||||
},
|
||||
{
|
||||
"name": "Dobe",
|
||||
"situation": "Dobe is not calling about a crisis. He is calling because he has spent the last four years, on every single westbound run on Highway 90, stopping at the same pullout between Marathon and Alpine to watch the pronghorn antelope that live in the grasslands north of the road. He has become, without any formal training, an obsessive amateur naturalist focused entirely on this one pronghorn herd. He knows individual animals by sight. He has named them. He has logged their movements, seasonal patterns, and herd composition in a series of spiral notebooks he keeps in his cab. Tonight he stopped at the pullout and counted a herd size he has never seen before \u2014 thirty-one animals \u2014 and he needs to tell somebody.",
|
||||
"voice": "Brian"
|
||||
},
|
||||
{
|
||||
"name": "Cassander",
|
||||
"situation": "Cassander's dilemma is seven months old and has been slowly poisoning his sleep. He was hired to do the electrical rough-in on a major renovation of a historic adobe property on the edge of Marfa \u2014 a project backed by an out-of-town buyer who is converting it into a high-end short-term rental. During the work, Cassander found, inside a sealed wall cavity, a tin box containing what appears to be a handwritten ledger and several photographs dating to approximately the 1940s. The ledger records, in careful columns, what Cassander \u2014 after considerable research \u2014 believes are payments made to a Presidio County sheriff's deputy over several years, consistent with protection payments for a smuggling operation across the Rio Grande. The names in the ledger include surnames still prominent in Marfa and Presidio County today.",
|
||||
"voice": "Callum"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1786771164.690101,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Silas",
|
||||
"situation": "The convoy stopped two hours ago because Priscilla \u2014 one of the founding members, a woman who has been with The Wellspring for nine years \u2014 refused to go any further. She is sitting in the livestock trailer with the goats and will not come out. Silas says it's about the move, but the real crack is deeper: Priscilla quietly told him last week that she never believed in The Current, that she stayed all these years because she was in love with him, and that she is not interested in starting over in a ghost town for a man who will never love her back the way she needs. He gave her a beautiful speech about how The Wellspring holds everyone. She told him that was exactly the problem. Now the whole caravan is waiting on the shoulder of 118 in the dark and Silas is calling Luke, he says, for advice \u2014 but what he actually needs is for someone to tell him he's not the reason Priscilla is broken.",
|
||||
"voice": "Sebastian"
|
||||
},
|
||||
{
|
||||
"name": "Vondell",
|
||||
"situation": "Four months ago Vondell ran the plates on a truck parked suspiciously long outside a house on the east side of town and found it came back to a man he grew up with \u2014 Garrett Sipes, who did eighteen months in Huntsville a decade ago for aggravated assault and moved away. Vondell ran the name deeper out of instinct and found an active warrant out of Reeves County \u2014 nothing violent, a failure to appear on a drug charge, but a warrant. He had every legal and professional reason to call it in immediately. He didn't. He knocked on the door instead, saw Garrett, and Garrett told him he'd come back because his mother had maybe three months left and he just needed to be with her until she passed. He wasn't using, wasn't dealing. Vondell let him go with a warning to handle the warrant after the funeral. Garrett's mother died six weeks ago. Garrett is still in Fort Stockton. He's coaching the other junior high team's defensive line. He has not handled the warrant. And now Vondell is in the position of either reporting a man who trusted him or continuing to cover for someone who played him.",
|
||||
"voice": "Malcolm"
|
||||
},
|
||||
{
|
||||
"name": "Wrenley",
|
||||
"situation": "Wrenley is not calling about a crisis. She is calling because three weeks ago she discovered something about the Marfa Lights that she cannot find any record of anywhere and it is eating her alive with excitement. She was driving back from Presidio late on a Tuesday \u2014 Highway 67, the stretch before you hit town \u2014 and she saw the lights behaving in a way that is documented in the viewing literature: the splitting, the merging, the slow drift. She's seen them before, everybody out here has. But this time she had her phone out and was filming, and when she reviewed the footage at home she noticed something in the background of three consecutive frames that she has since frame-analyzed obsessively: a second, much smaller, stationary light source at a completely different elevation that does not appear in any of the documented Lights literature she can find, does not match car headlights on 67, and was not visible to her naked eye while she was filming. She's not claiming aliens. She's claiming there's a second phenomenon that nobody's catalogued.",
|
||||
"voice": "Brooke"
|
||||
},
|
||||
{
|
||||
"name": "Cordell",
|
||||
"situation": "Cordell's dilemma is three weeks old and involves his younger brother, Ray, who is fifty-four and has ranched the same land outside Sanderson their father left them both since their dad died in 2009. The land was split in the will \u2014 Cordell got the northern parcel, Ray got the south. Cordell never worked his parcel; he was railroading and had no use for it. Eighteen years ago he let Ray run cattle on the northern parcel too, no paperwork, just a handshake because they're brothers. Three weeks ago a man from a wind-energy company came to Cordell's door with an easement offer for the northern parcel \u2014 a legitimate offer, good money, a lease for a met tower and potential turbine placement. Cordell wants to take it. Ray went silent and then sent a text that said: 'You do that, I lose the grazing on the north section and I'm underwater by spring.' Cordell didn't know Ray had borrowed against his own parcel to replace his well system last year. The wind money would save Cordell's retirement. Taking it would end Ray's ranch. They have not spoken in three weeks.",
|
||||
"voice": "Hank"
|
||||
},
|
||||
{
|
||||
"name": "Luz",
|
||||
"situation": "Luz is not calling about a crisis. She is calling because she has discovered, in her first semester of range management coursework, that she has a talent she cannot fully explain \u2014 she can look at a stretch of Chihuahuan Desert caliche and tell you, with what her professor calls 'unsettling accuracy,' what the soil horizon looks like eighteen inches down, what grass species are suppressed under it, and whether it's been overgrazed in the last decade, just from the surface reads: plant spacing, cryptobiotic crust condition, the way certain indicator species colonize the margins. Her professor, Dr. Villareal, had her repeat the assessment blind twice and both times she was right. He told her quietly after class that in thirty years of range science he'd seen maybe two or three people who could read land like that without instrumentation. She grew up walking her grandfather's pastures in Maverick County. She thought everyone could see this.",
|
||||
"voice": "Kayla"
|
||||
},
|
||||
{
|
||||
"name": "Birch",
|
||||
"situation": "Birch's dilemma is about his outfitter boss, a man named Denny who owns the operation and has run it for twenty years and is the closest thing Birch has to a father figure. Six weeks ago Birch found out \u2014 through a client who works in state water resources and let something slip over a campfire on a three-day float \u2014 that Denny has been falsifying the permit compliance logs for the Rio Grande outfitter license: overstating group sizes as under the NPS limit for Santa Elena Canyon while actually running groups two to four people over on high-demand weekends in spring. It's not catastrophic \u2014 the canyon doesn't get destroyed by four extra people \u2014 but the NPS permit is the entire business. If it comes out, Denny loses the license. Birch has spent six weeks telling himself it's minor. Then last week a group of eight came through on a permit logged as six, and one of them \u2014 a woman in her fifties who'd never been on a river \u2014 flipped in the Rockslide rapid and had to be pulled. She was fine. But Birch knows the safety ratios exist because of moments exactly like that.",
|
||||
"voice": "Elliot"
|
||||
},
|
||||
{
|
||||
"name": "Odessa",
|
||||
"situation": "Odessa is not calling about a crisis. She is calling because she has been doing something for the past six years that she has never told anyone and it has become the most quietly important thing in her life: she has been teaching herself Jumano. Not Spanish, not the Tex-Mex border Spanish she grew up with \u2014 Jumano, the language of the people who lived in this basin before any of the categories that define the region existed. The Jumano were traders, river people, connected to the Rio Grande and the Conchos; their language is not fully reconstructed, it's not spoken conversationally anywhere, but there are colonial-era vocabularies, mission records, and she has been working through everything she can find with a linguistics professor at UT El Paso she found through interlibrary loan correspondence. She has maybe four hundred words. She uses them when she's alone on the land \u2014 names for landforms, weather conditions, animals. Tonight she was checking the water in the far pasture and she said the Jumano word for 'overcast sky at night' and something in her chest opened up.",
|
||||
"voice": "Veronica"
|
||||
},
|
||||
{
|
||||
"name": "Terp",
|
||||
"situation": "Terp's dilemma is nine days old and involves a decision he cannot decide if he is proud of or not. He was at a bar in Pecos after a shift \u2014 not a place he goes regularly, just stopped because he was too tired to drive the rest of the way home \u2014 and he ended up next to a man who was loudly, happily telling the bartender about a business deal he'd just closed: a land lease for a surface rights agreement on a family's ranch outside Imperial that the family didn't know fully covered their water rights too. The man was not trying to hide this. He thought it was just good business. Terp \u2014 who grew up watching his own family lose a mineral-rights argument that cost them their land \u2014 asked to see the document the man had on his phone, said he was in the industry and could give it a read. The man showed him. Terp took a picture of the relevant clause. The next morning he drove to the family's ranch outside Imperial and knocked on the door and showed the rancher what the clause said. The rancher called a lawyer. The deal is now in dispute. The man from the bar has since figured out what happened and has filed a complaint with Terp's employer, alleging that Terp misrepresented himself as a legal professional.",
|
||||
"voice": "Jason"
|
||||
},
|
||||
{
|
||||
"name": "Constance",
|
||||
"situation": "Constance is a true believer and she is calling dead serious. For the past three years she has been documenting what she believes is a pattern in the Brewster County property deed records that she spent thirty years maintaining: specifically, she believes that a series of land transfers in the Big Bend foothills between 1987 and 1994 were part of a coordinated effort \u2014 not by the government, she is careful to say, but by a private entity she has not been able to identify \u2014 to acquire a contiguous corridor of land running from just south of Alpine to within twelve miles of the Rio Grande. The transfers involved eleven different buyers, none of them connected on paper, over seven years. She has plotted them on a USGS topo map. The corridor aligns, she says, with no road, no pipeline easement, no utility right-of-way she can find. She has been to the county courthouse, the GLO archives in Austin, and the Permian Basin Petroleum Museum in Midland looking for the answer. She does not know what the corridor was for. She knows it was deliberate.",
|
||||
"voice": "Elizabeth"
|
||||
},
|
||||
{
|
||||
"name": "Farrell",
|
||||
"situation": "Farrell's dilemma began two months ago and cracked open completely this past Thursday. He has a senior linebacker, a kid named Esteban, who is the best player Farrell has ever coached in fourteen years \u2014 full stop. Division II programs have been looking. There is a real possibility of a scholarship. Farrell has known for six weeks that Esteban is working nights at a warehouse in Presidio to help his family and is sleeping through first period every day. Farrell has been covering for him with the attendance office \u2014 marking him present when he's not \u2014 because missing that many classes threatens eligibility and Farrell convinced himself it was temporary and he was protecting the kid's future. On Thursday the principal called Farrell in and told him there's a discrepancy in the attendance records and that an audit is starting Monday. If the audit finds what Farrell did, it's falsification of school records. It ends his coaching career. It may also \u2014 and this is the thing that keeps him on the highway at midnight \u2014 void Esteban's eligibility retroactively and kill the scholarship anyway.",
|
||||
"voice": "Timothy"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1786775456.3647301,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Silas",
|
||||
"situation": "The Wellspring convoy arrived on their new Terlingua land four days ago to find that a neighboring property owner \u2014 a retired Border Patrol agent named Craddock \u2014 has already filed a noise complaint with Brewster County about 'nighttime ceremonial activity,' and posted a hand-painted sign on the shared fence line that reads 'NO TRESPASSING \u2014 THIS MEANS THE NAKED ONES.' More pressingly, two of Silas's most practically skilled members \u2014 a couple named Theo and Margit who ran all the soap-curing logistics \u2014 announced this afternoon they're not staying. They love Silas, they support the community, but Terlingua is too remote from the fulfillment infrastructure they need, and they're driving back to Deming tomorrow morning. Without Theo and Margit the soap operation functionally collapses. Silas is sitting in his truck in the dark, genuinely unsettled, and called the show because he needs to think out loud.",
|
||||
"voice": "Sebastian"
|
||||
},
|
||||
{
|
||||
"name": "Clemmie",
|
||||
"situation": "Three weeks ago Clemmie was going through her late grandmother's papers and discovered that the mineral rights to 800 acres of family land outside Marathon \u2014 rights everyone assumed had been sold off decades ago \u2014 are still in the family's name. The land itself was sold in 1987 but the mineral rights were severed and retained, and the paperwork is sitting right there in Clemmie's own office, filed under a name variant she never thought to search. She works in the county records office. She has looked at this paperwork ten thousand times in her career and never noticed it was hers. A landman from a Midland company called her grandmother's house \u2014 Clemmie's house now \u2014 six days ago. He was friendly, not pushy, offered a lease signing bonus that would pay off Clemmie's truck and her mother's medical debt in a single check. She hasn't called him back. She knows enough about how this works to know the first number is never the real number. But she also knows she doesn't know enough to negotiate this alone, and the people who do know enough are the same people who work for the companies doing the leasing.",
|
||||
"voice": "Naomi"
|
||||
},
|
||||
{
|
||||
"name": "Boone",
|
||||
"situation": "Last Monday Boone drove a group of four park visitors \u2014 two couples from Austin, late thirties, fit, the kind of people who use the word 'intentional' \u2014 from Panther Junction out to the Chisos Basin trailhead. Standard run. On the way back, alone in the van, he found a dry bag wedged under the rear seat. Inside: a Ziploc of what he's nearly certain is psilocybin mushrooms, a handwritten note that reads 'for the summit \u2014 take at the saddle,' and a folded piece of paper with what looks like a hand-drawn map of a trail junction he doesn't recognize. He's a retired cop. He knows what he found. He also knows the Park Service lost three rangers in the last staffing cuts, the visitors are long gone back to Austin, and he spent thirty years watching the criminal justice system do things he didn't always agree with. He hasn't turned it in. He hasn't thrown it away. It's in his glove box and it's been there for five days.",
|
||||
"voice": "Grant"
|
||||
},
|
||||
{
|
||||
"name": "Reva",
|
||||
"situation": "Reva has no moral crisis. She is calling because eight months ago, while cutting rebar at the fabrication shop, she accidentally discovered that if you run a specific gauge of galvanized rebar through the shop's old plasma cutter at a particular feed speed, it produces a tone \u2014 a sustained, resonant hum \u2014 that she has been unable to find described anywhere in any acoustic or materials literature she can access. She has since built a series of instruments from scrap rebar and the shop's cast-off metal stock that produce a scale of tones she's calling the 'rust register.' She's been performing solo concerts in the desert at night, alone, for an audience of no one, for five months. Tonight she played for the first time in front of another human being \u2014 her roommate, who cried without knowing why \u2014 and she needs to talk about what that means.",
|
||||
"voice": "Chloe"
|
||||
},
|
||||
{
|
||||
"name": "Harbert",
|
||||
"situation": "Two months ago Harbert's fourteen-year-old son, Cody, came to him with a business plan \u2014 a written, printed, stapled business plan, six pages, with a market analysis and projected revenue \u2014 for a small-engine repair operation he wants to run out of the garage. Harbert's first instinct was pride. His second instinct, which arrived about twenty minutes later while actually reading the plan, was alarm: the projected revenue in year two assumes Cody will be doing thirty hours of billable work a week. Cody is in eighth grade. Harbert grew up poor, worked his first real job at twelve, missed everything, and has spent sixteen years of parenting specifically trying to make sure his kids don't have to do what he did. He doesn't know how to tell his son that his ambition is beautiful and also that Harbert will not allow it \u2014 that the plan is good enough that saying no feels like crushing something real, but saying yes feels like handing his kid the same trap Harbert climbed out of.",
|
||||
"voice": "Clive"
|
||||
},
|
||||
{
|
||||
"name": "Soledad",
|
||||
"situation": "Soledad's dilemma is eleven days old and involves a single sentence. She was interpreting a deposition \u2014 civil matter, property dispute, nothing dramatic \u2014 when the opposing party's witness, an older man she'd never met, said something in Spanish that she interpreted accurately but that she now believes she may have rendered in a way that, while technically correct, removed an ambiguity that was present in the original and that the ambiguity itself may have been legally meaningful. The Spanish phrase carried a conditional inflection \u2014 'podr\u00eda haber sabido' \u2014 that in context could mean either 'I could have known' or 'I might have known,' and she chose the more definitive rendering because it was cleaner, and the deposition moved on, and the case settled four days later. She doesn't know if it mattered. She doesn't know if it didn't. She has looked at her own transcript twelve times.",
|
||||
"voice": "Serena"
|
||||
},
|
||||
{
|
||||
"name": "Whitmore",
|
||||
"situation": "Whitmore has no moral crisis. He is calling because this afternoon, while cleaning out the last of his office at Sul Ross \u2014 he retired in May, delayed clearing it all summer \u2014 he found a box of field samples from a 1987 survey he ran in the Santiago Mountains northeast of Big Bend that he had completely forgotten existed. He ran the samples through a portable XRF analyzer his former graduate student left him as a retirement gift, out of curiosity, and got elemental readings that don't match anything in the published literature for that formation. He's not saying what he thinks it might be \u2014 he's been in geology long enough to know the distance between an anomalous reading and a discovery \u2014 but he is saying he spent thirty-one years teaching students to follow anomalies and he is now seventy-two hours from driving back out to the Santiago Mountains alone to look at a rock formation he surveyed thirty-nine years ago.",
|
||||
"voice": "Rupert"
|
||||
},
|
||||
{
|
||||
"name": "Tremont",
|
||||
"situation": "Tremont is a true believer and he is calling dead serious. For the past four months he has been documenting what he is absolutely convinced is a pattern of infrasound \u2014 low-frequency sound below the threshold of human hearing \u2014 emanating from a specific stretch of canyon wall in Santa Elena Canyon on the Rio Grande. He has not heard it. He has felt it. He first noticed it on a solo scout paddle in April when his chest cavity began vibrating at a specific bend in the canyon and his vision went briefly strange and he felt what he describes as 'borrowed dread' \u2014 not his own fear, but fear arriving from outside him. He has since taken a group through that same bend nine times, tracking who reports unease, nausea, visual disturbance, or sudden unexplained emotion. His data: seven of nine groups had at least one member report an anomalous physical sensation at the same GPS coordinate. He has a theory about the canyon geometry acting as a resonant chamber and he has read every paper he can find on infrasound and geological acoustics. He is not saying it's supernatural. He is saying the canyon is doing something to human bodies at that location and nobody is studying it.",
|
||||
"voice": "Ethan"
|
||||
},
|
||||
{
|
||||
"name": "Petra",
|
||||
"situation": "Petra's dilemma is about her hired hand, a man named Gilberto who has worked her ranch for fourteen years and who she considers, without any sentimentality about it, the most competent and reliable person she has ever employed. Last week Gilberto told her quietly that his son \u2014 twenty, currently in Odessa on a construction crew \u2014 had gotten into serious trouble and needed bail money and a lawyer, and asked if Petra could advance him three months' pay. She said yes immediately, wrote the check that afternoon, didn't ask details. Two days later she found out from a neighbor that Gilberto's son's trouble involves a felony charge for assault. She doesn't want the money back. She doesn't want Gilberto to leave. She is not questioning her decision. What she is sitting with is that she wrote that check for Gilberto without a thought, and six months ago she had an employee \u2014 a younger man named Travis \u2014 ask her for a two-week advance for a family emergency, and she asked questions, and waited two days, and said no. The difference between how she treated those two requests is something she cannot explain away.",
|
||||
"voice": "Eleanor"
|
||||
},
|
||||
{
|
||||
"name": "Odell",
|
||||
"situation": "Odell has no moral crisis. He is calling because this evening his great-niece \u2014 visiting from El Paso, twenty-two, a nursing student \u2014 sat with him for three hours going through a box of photographs he's been meaning to sort for fifteen years, and they found a photograph he has never been able to explain and never showed anyone. It was taken in 1963 by his father \u2014 a Kodak Brownie shot \u2014 on the platform of the Southern Pacific depot in Alpine. The photograph shows his father and two other men standing in front of a locomotive. One of the men, on the left, is wearing a Southern Pacific uniform and is looking at the camera. The third man, on the right, is a stranger \u2014 no uniform, civilian clothes, hat brim down \u2014 and has no face. Not blurred. Not overexposed. The face is simply absent, replaced by an area of the photograph that is the same color and texture as the background sky behind him. His father never explained it. Odell has looked at this photograph for sixty years and never shown it to a soul because he didn't want people to think he was touched. His great-niece said, very calmly, 'Uncle Odell, you need to tell somebody about this,' and handed him the phone.",
|
||||
"voice": "James"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1786776545.7279181,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Marguerite",
|
||||
"situation": "This afternoon Silas announced that the commune is restructuring its internal 'gift economy' \u2014 meaning members who want to leave must formally petition to reclaim any personal property they brought in, which is now considered 'collectively held.' Marguerite brought in a 2019 Subaru Forester when she joined. The car has been driven communally for years and Silas is now saying it belongs to the collective. It is the one asset she has. She looked up the title this afternoon \u2014 it is still in her name. But the keys are on a communal hook and she does not know if she can just take it without it becoming a thing, and she does not know if she can afford the fallout of it becoming a thing while she is still living here.",
|
||||
"voice": "Marlene"
|
||||
},
|
||||
{
|
||||
"name": "Delphine",
|
||||
"situation": "Four days ago Delphine was doing a routine chest X-ray on a patient \u2014 an older rancher, not from Alpine, she had never seen him before. Standard intake. While she was positioning him she noticed a tattoo on his forearm: a very specific design, a running horse over a compass rose, that she has only ever seen once before, on her biological father, who she has not seen since she was nine years old. The patient's last name on the chart was different from her father's name. She did not say anything. She finished the scan. She has his date of birth from the chart. He would be 61. Her father would be 61. She does not know what to do with this and cannot ask anyone at work without it becoming a HIPAA situation.",
|
||||
"voice": "Zadie"
|
||||
},
|
||||
{
|
||||
"name": "Crockett",
|
||||
"situation": "Crockett is not calling about a crisis. He is calling because he has just returned from a 31-mile walk \u2014 he left Fort Stockton at 3 a.m. this morning, walked to the base of the Glass Mountains and back, finished at dusk \u2014 and he is still buzzing and there is nobody awake to tell. He has been doing these extreme desert walks for two years, has logged over 1,400 miles in the Trans-Pecos, and has developed a body of specific, granular knowledge about what the desert does to a human body in August that he considers genuinely interesting and has never seen written down anywhere. Tonight he walked through the heat of the afternoon and has opinions about it.",
|
||||
"voice": "Carter"
|
||||
},
|
||||
{
|
||||
"name": "Sybil",
|
||||
"situation": "Sybil's dilemma is about something she did last Tuesday and has been reconstructing ever since. She was hired six months ago to write grants for a small Marfa arts residency \u2014 legitimate work, good client. In reviewing their financials to write a foundation letter, she found what looked like the residency director double-billing a donor: the same $4,000 studio renovation billed to two separate grants in the same fiscal year. She finished the grant, submitted it, took her fee. She did not say anything. The new grant she just submitted was for $18,000. She does not know if she just helped someone commit grant fraud, whether what she saw was actually fraud or just sloppy bookkeeping, or whether saying something now \u2014 after taking the money \u2014 makes her more complicit or less.",
|
||||
"voice": "Kelsey"
|
||||
},
|
||||
{
|
||||
"name": "Absalom",
|
||||
"situation": "Absalom is not calling about a crisis. He is calling because tonight, with the overcast and the specific barometric pressure, the fence line along his property is producing a tone he has never heard in forty years \u2014 a clean, sustained E-flat, not a harmonic, not a whistle, a pure tone \u2014 and he has been outside for two hours with a chromatic tuner and a notebook and he cannot explain it and he is delighted. He has spent years cataloguing the sounds the desert makes through wire and wood and tonight the desert has given him something new.",
|
||||
"voice": "Dennis"
|
||||
},
|
||||
{
|
||||
"name": "Vestina",
|
||||
"situation": "Vestina's dilemma is about her sixteen-year-old son, Marcus, and a thing she found on his phone three days ago that she was not snooping for \u2014 she was looking for a photo of a receipt he said he'd taken. What she found was a months-long text thread with a man who Marcus calls 'Coach V,' who is not any coach at Alpine High School. The messages are not sexual. But they are \u2014 she keeps coming back to this word \u2014 grooming-adjacent. The man is teaching Marcus about 'real masculinity,' sending him podcasts, calling the boy's teachers 'feminized,' telling him his mother 'means well but doesn't understand what you need to become.' Marcus does not know she saw it. She does not know who this man is. She does not know if she confronts Marcus, goes to the school, calls the police, or just takes the phone \u2014 and she is terrified that any of those moves pushes Marcus toward this person harder.",
|
||||
"voice": "Amina"
|
||||
},
|
||||
{
|
||||
"name": "Fen",
|
||||
"situation": "Fen is not calling about a crisis. He is calling because today he made a navigational decision on a float trip that he is almost certain saved his clients' lives and nobody will ever know, and the anonymity of it is making him feel something he cannot name. A solo client \u2014 retired professor, late sixties, rented a solo kayak for a half-day on a calm stretch \u2014 got into trouble in a hydraulic near the mouth of a side canyon that forms in high water. Fen read the water from upstream, repositioned his safety throw bag, and was exactly where he needed to be. The client barely knew anything had happened. Fen never told him how close it was. Tonight he is sitting outside the Starlight with a beer and the whole thing keeps replaying \u2014 not with pride, but with a strange, vertiginous feeling about how thin the margin was.",
|
||||
"voice": "Jonah"
|
||||
},
|
||||
{
|
||||
"name": "Lurleen",
|
||||
"situation": "Lurleen's dilemma is about her own body and a decision she made without telling anyone. Six weeks ago a doctor in Midland told her she has a benign but symptomatic uterine fibroid that can be managed with medication or removed with a procedure \u2014 her choice, no emergency. She chose the procedure, scheduled it for three weeks from now, and has told no one: not her kids, not her ex-husband, not her sister. She has been driving herself to all the pre-op appointments. She is not scared of the procedure. She is increasingly unsettled by how easy it has been to keep this secret \u2014 how little anyone in her life has noticed she has been preoccupied \u2014 and tonight she realized that if something went wrong on the table, no one would know to come.",
|
||||
"voice": "Victoria"
|
||||
},
|
||||
{
|
||||
"name": "Prospero",
|
||||
"situation": "Prospero has spent the past eighteen months documenting what he is completely convinced is a deliberate, systematic distortion of historical property survey lines in Presidio County \u2014 not errors, not drift, not equipment failure, but a coordinated pattern of falsified baseline monuments going back to the 1930s that he believes was used to quietly absorb water rights from smaller landowners, primarily Mexican-American families, over several decades. He has 400 pages of cross-referenced survey records, GLO plats, and field measurements he has taken himself. He knows exactly how this sounds. He has a surveyor's license and knows exactly what the discrepancies mean technically. Tonight he called because he found a monument this afternoon \u2014 a brass cap set in concrete on a ranch road east of Presidio \u2014 that matches a false baseline he has been tracking for a year and a half, and it has a date stamp that is fifteen years earlier than the GLO record says it was set.",
|
||||
"voice": "Oliver"
|
||||
},
|
||||
{
|
||||
"name": "Colette",
|
||||
"situation": "Colette's dilemma happened last night and she has not slept. She has been in a casual, explicitly non-serious sexual relationship for four months with another graduate student \u2014 a man named Garrett, in the geology program \u2014 that both of them agreed was low-stakes and fine. Last night, after sleeping together, Garrett told her, in what she believes was genuine good faith and not manipulation, that he has been offered a postdoc at University of Montana starting in January and that he has also been quietly falling in love with her for two months and did not know how to say so and is now saying so because he is leaving. He did not ask her anything. He just told her. She did not respond. She got up and went home. She has been lying in her bed since midnight processing the fact that she might also be in love with him and has successfully hidden this from herself for eight weeks, and that in five months he will be in Montana, and that she has fourteen months left on her dissertation.",
|
||||
"voice": "Julia"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1786776933.956095,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Doyle",
|
||||
"situation": "Three days ago a new member \u2014 a woman named Priscilla who joined in June \u2014 came to Doyle and asked him to co-sign a document she described as a 'community stewardship transfer.' He read it. It was a limited power of attorney assigning Silas authority over personal financial accounts in the event of a member's 'spiritual incapacity.' Doyle recognized the language because he had signed something similar himself, eleven months ago, and had been told it was a liability waiver for the soap equipment. He went back to his truck and found his copy. It is the same document with different blanks filled in.",
|
||||
"voice": "Grant"
|
||||
},
|
||||
{
|
||||
"name": "Rhett",
|
||||
"situation": "Rhett has been managing a quiet, five-year entanglement with a woman named Deb \u2014 married, husband works a two-week rotation in the Delaware Basin. The arrangement has been stable, both parties clear-eyed, nobody asking for more than it is. Last Thursday Deb told Rhett that her husband has been diagnosed with early Parkinson's and she is ending things \u2014 not because of guilt, she said, but because she is going to need to be fully present for what comes next. Rhett said all the right things. He meant them. He drove home, sat in his driveway for an hour, and realized the thing he is most ashamed of: his first feeling, before the sympathy, was relief. He has been turning that over ever since.",
|
||||
"voice": "Conrad"
|
||||
},
|
||||
{
|
||||
"name": "Colquitt",
|
||||
"situation": "Colquitt has no moral crisis. He is calling because this past Tuesday he harvested the first ripe pods from a chile plant he grew from a seed he pulled out of a dried pepper his grandmother brought from M\u00fazquiz, Coahuila, in 1971 \u2014 a variety he has never been able to identify, that no extension agent or seed bank has been able to name, that produces a small, nearly black pod with a smell he describes as 'like smoke and citrus and something that doesn't have a word.' He ate one raw, standing in the dirt at 7 a.m., and it was the best thing he has ever put in his mouth. He has been trying to tell this to people for four days and nobody will let him finish.",
|
||||
"voice": "Arthur"
|
||||
},
|
||||
{
|
||||
"name": "Waverly",
|
||||
"situation": "Waverly's dilemma happened six days ago and she has told no one. She is TAing an undergraduate field ecology course. One of her students \u2014 a junior named Cord, quiet, works a part-time ranch job, genuinely good at field identification \u2014 submitted a GPS track log and field notes from a solo transect that she recognized immediately, because she had walked the same transect herself two years ago and taken photos. His coordinates were real. His species list was accurate. But his written observations were word-for-word identical to a blog post she found in twenty minutes of searching \u2014 a post by a birder from Tucson who had visited the same drainage in 2021. The student does not know she found it. She has not reported it. She has been trying to decide whether to.",
|
||||
"voice": "Tessa"
|
||||
},
|
||||
{
|
||||
"name": "Beto",
|
||||
"situation": "Beto is a true believer and he is calling dead serious. For the past fourteen months he has been documenting what he is completely convinced is a pattern of deliberate, coordinated interference with the FM broadcast signal in the Presidio-Ojinaga corridor \u2014 specifically, a carrier wave that appears on 91.3 MHz between 11 p.m. and 2 a.m. on nights when the wind is below ten miles per hour and the temperature drops past a certain threshold. He has logged forty-one occurrences. The signal carries no audio \u2014 just a pure tone that sweeps from 300 Hz to 900 Hz over exactly ninety seconds and then stops. He has three neighbors who have heard it on their radios. His theory is not extraterrestrial. His theory is that it is a ranging signal \u2014 something measuring distance or depth \u2014 and that whoever is sending it does not know or does not care that it bleeds into the FM band.",
|
||||
"voice": "Timothy"
|
||||
},
|
||||
{
|
||||
"name": "Linden",
|
||||
"situation": "Four months ago Linden was hired to rewire a historic adobe on San Antonio Street that a buyer from Portland had purchased and was converting into a short-term rental. Standard job \u2014 he has done fifty of them since the money started coming into Marfa. On the last day of the job, while pulling old knob-and-tube from inside a wall cavity, he found a metal tin, soldered shut, with a name stamped on the lid that he recognized: his mother's maiden name, Ronquillo. He pried it open. Inside were forty-seven photographs \u2014 formal portraits, family groups, one wedding photo \u2014 and a folded paper in Spanish that he had partially translated by a woman at the post office. The paper appears to be a record of land \u2014 varas, boundaries, a family name \u2014 predating the current deed by at least sixty years. He has not told the Portland buyer. He has not told his mother, who is 74 and lives in a care facility in Alpine. He has been sitting with the tin in his truck for four months.",
|
||||
"voice": "Blake"
|
||||
},
|
||||
{
|
||||
"name": "Calla",
|
||||
"situation": "Calla has no moral crisis. She is calling because this past spring she discovered, entirely by accident, that the clay in a specific arroyo drainage two miles from her property fires to a color she has never seen in any reference \u2014 a deep, warm black with an iridescent surface quality that appears under certain light conditions and disappears under others. She has been mining it in small quantities, testing it, and has now produced thirty-seven pieces. A ceramicist she respects, a woman who teaches at UT, saw photos and told her she had never seen anything like it and asked where it came from. Calla has not told her. She has not told anyone. She is calling tonight because she sold her first piece from this clay two weeks ago \u2014 to a gallery in Santa Fe, for $1,400 \u2014 and the gallery has asked for more, and she is now facing the question of how much of the arroyo she is willing to excavate before it's gone.",
|
||||
"voice": "Veronica"
|
||||
},
|
||||
{
|
||||
"name": "Talmadge",
|
||||
"situation": "Talmadge's dilemma is three weeks old and involves his only neighbor within ten miles \u2014 a man named Clifford who ranches the adjoining section and whose family has been on that land as long as Talmadge's. Three weeks ago Talmadge found a section of his fence cut \u2014 not broken, cut, with wire cutters, three strands, in a spot where his Angoras had gotten through and onto Clifford's property. He gathered his animals, repaired the fence, and said nothing. Last week he found another cut section, different location, same clean cuts. He checked his water records and realized that in both locations, the cuts were within fifty yards of underground water easements \u2014 old ones, hand-recorded in the county seat, that would give a third-party buyer access to drill. He looked up the Terrell County deed records. Clifford sold a subsurface water option to a Midland LLC six months ago. Talmadge and Clifford have not had a cross word in thirty-one years.",
|
||||
"voice": "Duncan"
|
||||
},
|
||||
{
|
||||
"name": "Imogen",
|
||||
"situation": "Imogen is not calling about a crisis. She is calling because this afternoon in her structural geology lab she learned something that she cannot stop thinking about and that she has been trying to explain to her roommate for two hours without success. The thing is this: the Ouachita fold belt \u2014 an ancient mountain range that collided with North America roughly 300 million years ago \u2014 runs directly under the Big Bend region, buried under thousands of feet of younger rock, invisible from the surface. The mountains she can see from her window are not those mountains. Those mountains are gone. What she is standing on is the grave of a mountain range larger than the Himalayas, and nobody walking around out here knows it is under their feet, and she finds this overwhelming in a way she cannot fully explain.",
|
||||
"voice": "Anjali"
|
||||
},
|
||||
{
|
||||
"name": "Pruett",
|
||||
"situation": "Pruett's dilemma is eleven days old and involves a thing he did that he believes was right and cannot stop second-guessing. A Midland energy company submitted a valuation appeal on a 4,000-acre parcel they had purchased and were listing for ag-use exemption \u2014 standard procedure, he has processed hundreds. While reviewing the file he found that the parcel had been divided from a larger holding eighteen months earlier in a way that appeared designed specifically to qualify for the exemption \u2014 a tax savings of roughly $180,000 per year. He researched the parent parcel. The division was legal. The exemption was technically valid. He approved it, because it was his job to approve valid exemptions. He drove home and has not slept more than four hours since.",
|
||||
"voice": "Shaun"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1786777096.6433768,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Silas",
|
||||
"situation": "The Wellspring arrived on the Terlingua land six days ago. The physical move is done \u2014 the bus is parked, the goats are adjusting, three members who refused to come have formally left, and Silas has been trying to frame this as a rebirth. But tonight a specific, concrete problem surfaced: the Terlingua Ghost Town community Facebook group has been going after the commune hard, and someone \u2014 he suspects a local \u2014 tipped off a journalist at the Alpine Avalanche about the 'shared intimacy nights.' The reporter left a voicemail asking for comment. Silas is genuinely unsettled, not because he's ashamed of anything the community does, but because he knows how this story will be written and he's worried it will terrify the remaining members who are already exhausted and disoriented. He's calling Luke partly for media advice, partly because Luke has a platform in this region and Silas wants to get ahead of it.",
|
||||
"voice": "Sebastian"
|
||||
},
|
||||
{
|
||||
"name": "Clem",
|
||||
"situation": "Three weeks ago a guest checked into the Gage \u2014 a man from Dallas, early sixties, in town for a week \u2014 who struck up a conversation with Clem during a slow night and eventually admitted he was a land broker scouting the Marathon Basin for a solar energy company. He showed Clem a map. The map had Clem's family's 4,200 acres highlighted. The broker wasn't there to approach Clem \u2014 he didn't even know who Clem was at first \u2014 but once he figured it out, he made an informal offer so large that Clem has been unable to sleep since. It's enough to pay off the ranch debt, his mother's medical bills, and have money left over. But it would end 90 years of family ranching. His father is 71 and doesn't know the offer happened. Clem hasn't told him. He's been sitting on it alone for three weeks.",
|
||||
"voice": "Hank"
|
||||
},
|
||||
{
|
||||
"name": "Yvette",
|
||||
"situation": "Yvette is not calling about a crisis. She's calling because today in the campus library she stumbled onto something genuinely strange and wonderful: a box of uncatalogued letters, misfiled in the Sul Ross archives, written between 1962 and 1971 by a Big Bend ranch woman named Opaline Harkey to an unnamed correspondent she calls only 'the one who reads.' The letters are ferociously literary \u2014 descriptions of the Chisos at dusk, of drought, of a marriage going cold \u2014 and whoever Opaline was writing to, she was in love with them. Nobody at the library knows how the letters got there. There's no Opaline Harkey in any local record Yvette can find. She spent five hours reading them today instead of working on her thesis and she is lit up about it.",
|
||||
"voice": "Nadia"
|
||||
},
|
||||
{
|
||||
"name": "Decker",
|
||||
"situation": "Decker's dilemma is nine days old and involves a call he made on the job. He was doing a routine safety inspection on a small independent operation outside Fort Stockton \u2014 a two-well pad, family-owned, run by a man named Prudhomme who has been drilling in Pecos County for thirty years. Decker found a real violation \u2014 a blowout preventer with a faulty seal, documentation they'd been ignoring for eight weeks. By the book, he should cite it, flag the BLM, shut them down while it's corrected. He's done it dozens of times. But Prudhomme's operation employs eleven people \u2014 guys Decker knows \u2014 and Prudhomme told him, unprompted, that he's already fighting a bank note and a citation right now would trigger a default. Decker gave him a verbal warning and a ten-day window to fix it quietly. He told himself it was a judgment call. He's been telling himself that for nine days and it's not working anymore.",
|
||||
"voice": "Craig"
|
||||
},
|
||||
{
|
||||
"name": "Blythe",
|
||||
"situation": "Blythe is not calling about a crisis. She is calling because last month she was hired to assess a collection of Navajo blankets for a private collector in Marfa \u2014 routine work \u2014 and in the process of documenting the collection she identified one blanket, a late-Classic period piece probably woven between 1860 and 1875, that she believes was looted from a federal collection sometime in the mid-twentieth century. She's been doing provenance research for three weeks. The collector has no idea. The blanket is not obviously famous \u2014 it doesn't appear in any published theft records she can find \u2014 but the archive photo she found, from a 1952 BIA inventory photograph, is a near-certain match. She's almost sure. And tonight she hit the specific thread that makes her almost sure into just sure: a repair on the blanket's corner edge, a distinctive Z-twist reweave, appears in both the 1952 photo and the piece on the collector's wall.",
|
||||
"voice": "Eleanor"
|
||||
},
|
||||
{
|
||||
"name": "Odis",
|
||||
"situation": "Odis is a true believer and he is dead serious. For the past fourteen months he has been documenting what he is completely certain is a pattern of structural road subsidence on a specific twelve-mile stretch of US Highway 90 between Valentine and Marfa \u2014 not random pavement failure, not normal freeze-thaw cycling, but a deliberate, systematic pattern that corresponds precisely to the underground route of a buried fiber-optic trunk line installed by a federal contractor in 2019. His theory: the contractor used a substandard bore-and-case method through a caliche layer, and TxDOT and the federal contractor are quietly patching surface failures rather than admitting the subsidence is ongoing and the line itself may be compromised. He has GPS coordinates of 23 distinct failure points. He has mapped the line from public utility filings. He has photographed every patch. He is absolutely certain someone doesn't want this known because of what that fiber-optic trunk line carries \u2014 he believes it is part of a classified military communications backbone connecting Laughlin Air Force Base to something in the Chinati Mountains.",
|
||||
"voice": "Theodore"
|
||||
},
|
||||
{
|
||||
"name": "Mireille",
|
||||
"situation": "Mireille's dilemma is four days old and involves her closest colleague \u2014 a woman named Patrice who teaches English at the same school and who Mireille has eaten lunch with almost every school day for nine years. Last Tuesday, after a faculty meeting, Patrice told Mireille that she is planning to report their principal, Dale Cortez, to the district for a pattern of behavior that Patrice describes as creating a hostile work environment \u2014 a year of dismissiveness, exclusion from curriculum planning, public undermining in front of parents. Patrice asked Mireille to be a corroborating witness. Mireille was in the room for some of it. But here's what Mireille hasn't told Patrice: Mireille knows \u2014 because Cortez told her in confidence six months ago \u2014 that Patrice was passed over for a department chair position because of two formal parent complaints filed against her in 2023, complaints that were handled quietly and that Patrice apparently doesn't know exist. Mireille doesn't know if the complaints were legitimate. She doesn't know if telling Patrice would help or hurt her. And she doesn't know if staying silent to protect Cortez's confidence makes her complicit in something real.",
|
||||
"voice": "Serena"
|
||||
},
|
||||
{
|
||||
"name": "Sawyer",
|
||||
"situation": "Sawyer is not calling about a crisis. He is calling because he discovered something this week that he cannot stop thinking about: the Rio Grande in the Big Bend \u2014 in certain canyon stretches, in certain light, at certain water levels \u2014 produces a specific acoustic phenomenon where the canyon walls create a standing wave of sound, a low harmonic resonance, that he can hear and feel in his chest while floating silent in an eddy. He's been trying to figure out for three months if this is a known thing or if he's losing his mind. This week he found a 1987 geological survey paper from a UT Austin researcher that documents the same phenomenon in Santa Elena Canyon specifically and calls it 'sub-auditory canyon resonance' \u2014 and the paper was never followed up on and the researcher died in 1994. Sawyer has been recording it on his phone from a kayak and he is absolutely bursting to talk about it.",
|
||||
"voice": "Levi"
|
||||
},
|
||||
{
|
||||
"name": "Bertram",
|
||||
"situation": "Bertram's dilemma is two weeks old and he cannot resolve it no matter how he turns it. He responded to a medical call in early August \u2014 a man found unconscious on a ranch road about twelve miles outside Sanderson, no ID, no vehicle nearby. Bertram and his partner stabilized the man and got him to the Sanderson clinic, and the man eventually came around. The man refused to give his name or any identification, refused to let staff contact anyone, and left the clinic against medical advice the following morning. Bertram would have forgotten the whole thing except: he recognized the man. He's almost certain \u2014 seventy, maybe eighty percent certain \u2014 that it was his ex-wife's brother, a man named Terrel Groves, who Bertram hasn't seen in fifteen years, since the divorce, and who the family cut off years before that over a theft from their mother. Bertram hasn't told anyone. He hasn't tried to contact his ex-wife. He doesn't know if Terrel is in trouble, hiding from something, or just living rough. He keeps thinking about that man walking out of the clinic alone and where he went.",
|
||||
"voice": "Malcolm"
|
||||
},
|
||||
{
|
||||
"name": "Nola",
|
||||
"situation": "Nola's dilemma happened eight days ago and involves a man she has been sleeping with for six weeks \u2014 a visiting ceramics instructor named Griffin, brought in for a summer workshop at the Sul Ross art department, who is from Portland and is supposed to leave for home in eleven days. The sex has been good and she was clear going in that it was temporary and she was fine with that. What she is not fine with, and did not see coming, is that Griffin told her four nights ago that he is thinking about staying \u2014 not forever, just through the fall, maybe longer, he likes it here \u2014 and he said it right after they had sex and she said something warm back without thinking, and now she's been sick about it for four days. She does not want him to stay. She does not want a real relationship with him. She has not told him this. She keeps not telling him. And the longer she doesn't tell him, the more she's letting him rearrange his life around a version of her that said something warm and isn't correcting it.",
|
||||
"voice": "Jessica"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"timestamp": 1786785000.7552829,
|
||||
"lineup": [
|
||||
{
|
||||
"name": "Renata",
|
||||
"situation": "The Wellspring has been on the new Terlingua land six days and Renata has a logistical triumph to report: she has successfully relocated the Unbinding to an outdoor format \u2014 she calls it the 'open-sky rotation' \u2014 using the converted school bus as a changing room and a cleared flat of caliche as the main space. She has a color-coded schedule laminated and zip-tied to the bus door. She is calling because she wants Luke to know it worked, the first session was last night, the wind cooperated, and she has been trying to tell someone who will appreciate the organizational achievement. The secondary reason: she is quietly, genuinely worried about Priscilla \u2014 the woman who refused to continue on the road \u2014 and the three others who stayed behind, but she has already packaged this into a positive ('the community self-selected for people who were ready') and does not realize she is calling partly because the worry is leaking.",
|
||||
"voice": "Celeste"
|
||||
},
|
||||
{
|
||||
"name": "Augusto",
|
||||
"situation": "Two weeks ago the Gage Hotel renovated the back hallway near the original kitchen and, in pulling out a section of baseboard, the contractor found a sealed tin box behind the lath. The box contained forty-one handwritten recipe cards \u2014 in Spanish, in two different hands, dated between 1932 and 1949 \u2014 and a photograph of two women standing at a wood-burning range. The contractor handed the box to the hotel manager. Augusto heard about it from a dishwasher and went to look at the recipes. He recognized his grandmother's handwriting on seventeen of the cards. The hotel manager has told him the box is 'hotel property' and he cannot take it. Augusto is not sure if the manager is legally right. He is also not sure what he wants \u2014 the cards, or just acknowledgment that his grandmother's hands built some of what that hotel is selling.",
|
||||
"voice": "Graham"
|
||||
},
|
||||
{
|
||||
"name": "Dex",
|
||||
"situation": "Dex is calling because he is not having a crisis \u2014 he is having the best entomological week of his life. Three days ago, while checking a fence line on his uncle's property twelve miles south of Fort Stockton, he netted a specimen he has now confirmed, using two different identification keys and one email to a professor at Texas A&M, is almost certainly Agaeocera speciosa \u2014 a longhorn beetle so rarely documented in Brewster County that the last recorded specimen from this area is in the Texas A&M collection, dated 1967. He has it pinned and staged under a glass dome on his kitchen table and he keeps getting up to look at it. He is calling because the A&M professor hasn't written back yet and he needs to tell someone who will let him explain why this matters.",
|
||||
"voice": "Jake"
|
||||
},
|
||||
{
|
||||
"name": "Sable",
|
||||
"situation": "Sable's dilemma is nine days old and involves a man named Curtis, who is forty-seven and works ranch land management for a large private landowner outside Marfa. They have been sleeping together for three months \u2014 nothing serious, both adults, eyes open. Nine days ago Curtis asked her, during what she describes as 'not a moment designed for serious conversation,' whether she could look up the filing history on a specific parcel of land \u2014 a tract whose ownership is currently in a quiet dispute. He was not aggressive about it. He was casual. She is almost certain he thought he was being charming rather than corrupt. She did not look it up. She also did not tell him why she wouldn't, and she has not said anything since, and he has texted her twice as if nothing happened and she has not replied. She doesn't know if she should report it, end it, or ask him directly what he thought he was doing.",
|
||||
"voice": "Naomi"
|
||||
},
|
||||
{
|
||||
"name": "Birgitta",
|
||||
"situation": "Birgitta is calling because she has just spent the last six hours doing something she has never done: she watched every available video online of a Danish folk singer named Povl Dissing, who died in 2023, and she cannot stop crying and cannot fully explain why. She left Denmark at twenty-four and has not been back except for two funerals. She never listened to Povl Dissing in Denmark \u2014 his music meant nothing to her then. But tonight she stumbled on a clip, then another, and now it is nearly midnight and she has been sitting with her donkeys in the pen and playing songs from her phone speaker and crying in a way she has not cried since her husband left fourteen years ago. She is not sad exactly. She does not know what she is.",
|
||||
"voice": "Marlene"
|
||||
},
|
||||
{
|
||||
"name": "Stovall",
|
||||
"situation": "Stovall's dilemma is five weeks old and he has told no one because there is no one in Sanderson he can tell without it becoming the thing everybody knows. His younger brother Eli, forty, has been ranching the section adjacent to Stovall's for twelve years. Five weeks ago Stovall found out \u2014 through a water rights attorney he hired for an unrelated matter \u2014 that Eli has been quietly negotiating to sell his section to a Midland-based land aggregator that is buying up Terrell County parcels for a large-scale solar lease project. Eli has not told Stovall. Their arrangement \u2014 they share a well, they share a set of working pens, they help each other at shipping time \u2014 depends entirely on Eli being there. If Eli sells, Stovall's operation becomes nearly unworkable. But the deeper wound is that Eli didn't tell him. They talk every week. Eli has been lying by omission for at least three months.",
|
||||
"voice": "Trevor"
|
||||
},
|
||||
{
|
||||
"name": "Clover",
|
||||
"situation": "Clover is not calling about a crisis. She is calling because this afternoon at the Museum of the Big Bend she was doing a routine inventory of a storage room that doesn't get opened often, and she found, in a flat archival box mislabeled 'ephemera \u2014 uncatalogued 1990s,' a collection of 200 handwritten letters from Sul Ross students to their families, dating from 1943 to 1947 \u2014 wartime, many of the students women who had come to Sul Ross because the men were gone. The letters were never sent. She does not know why. She has been reading them for four hours and she cannot stop. She called the show because she wants to talk about one letter in particular, from a girl named Hazel who was studying agriculture and wrote her mother in Uvalde about the smell of the Davis Mountains in October, and Clover is sitting outside right now and it smells exactly like that.",
|
||||
"voice": "Kayla"
|
||||
},
|
||||
{
|
||||
"name": "Fenwick",
|
||||
"situation": "Fenwick's dilemma is two days old and involves a specific act of cowardice he committed in a public meeting and cannot stop replaying. There was a Presidio County commissioners meeting two days ago at which a proposal was discussed to rezone a parcel on the edge of Marfa for a boutique hotel development \u2014 a project backed by an out-of-town investment group. Fenwick was there. He knows the parcel. His cousin's family used it for grazing until twelve years ago. He had prepared a statement. He stood up, the room was full of people he recognized on both sides, and he sat back down without saying a word. He drove home and has not slept well since. He is not sure if he sat down because he was afraid of looking like a crank, or because he actually isn't sure anymore which side he's on, and not knowing that terrifies him more than the hotel does.",
|
||||
"voice": "Damon"
|
||||
},
|
||||
{
|
||||
"name": "Loxley",
|
||||
"situation": "Loxley is calling because tonight he made a photograph that he is almost certain is the best photograph he has ever made, and possibly the best photograph anyone has made of this specific subject, and he is alone on a two-lane highway with no one to tell. He had set up on a flat east of the Rosillos Mountains to photograph a specific conjunction \u2014 Jupiter and Saturn with the Milky Way core above the Chisos \u2014 and while he was waiting for his long exposure to finish, a cattle truck passed on 385 and kicked up a plume of caliche dust that caught his headlamp beam and drifted across the frame. He almost broke the exposure. He didn't. He looked at the back of the camera when the shutter closed and the dust plume in the frame looks like a figure \u2014 not a vague smear, a specific upright figure, arms slightly out \u2014 and the Chisos are behind it and the Milky Way is above it and the exposure is perfect.",
|
||||
"voice": "Elliot"
|
||||
},
|
||||
{
|
||||
"name": "Consolata",
|
||||
"situation": "Consolata is a true believer and she is calling dead serious. For the past two years she has been documenting what she is completely convinced is a pattern of deliberate mistranslation embedded in the official transcripts of a specific category of immigration removal proceedings in the Presidio-Ojinaga corridor \u2014 not errors, not dialect gaps, but systematic substitutions of one specific phrase that, in Spanish, means 'I am afraid to return' and in the transcripts consistently appears rendered as 'I do not want to return.' She is a retired court interpreter. She knows the difference. She has 140 pages of annotated transcripts. She has written to three different offices and received form-letter responses. She knows what 'afraid' means in a legal proceeding and she knows what 'do not want' means and she knows they are not the same and she knows what happens to a case when you use one instead of the other.",
|
||||
"voice": "Bianca"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+1011
File diff suppressed because it is too large
Load Diff
@@ -59,5 +59,413 @@
|
||||
}
|
||||
},
|
||||
"started_at": "2026-03-12T07:04:34.974425+00:00"
|
||||
},
|
||||
"35": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-03-13T11:19:41.765107+00:00",
|
||||
"episode_id": 38,
|
||||
"slug": "episode-35-midnight-confessions-and-unexpected-revelations"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-03-13T11:42:00.428623+00:00",
|
||||
"video_id": "fYvXLqFilLQ"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-03-13T11:42:11.800641+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-03-13T11:19:41.765079+00:00"
|
||||
},
|
||||
"36": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-03-14T12:01:15.758700+00:00",
|
||||
"episode_id": "39",
|
||||
"slug": "episode-36-late-night-confessions-and-unexpected-moments"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-03-14T12:25:36.640461+00:00",
|
||||
"video_id": "BabWoKFt0pk"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-03-14T12:25:44.192676+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-03-14T12:01:15.758670+00:00"
|
||||
},
|
||||
"37": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-03-15T09:07:26.659541+00:00",
|
||||
"episode_id": "40",
|
||||
"slug": "episode-37-secrets-lies-and-coffee-runs"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-03-15T09:33:48.299549+00:00",
|
||||
"video_id": "XW7Q0WPzNqY"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-03-15T09:34:02.069440+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-03-15T09:07:26.659508+00:00"
|
||||
},
|
||||
"38": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-03-16T10:08:51.467004+00:00",
|
||||
"episode_id": 41,
|
||||
"slug": "episode-38-secrets-lies-and-late-night-confessions"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-03-16T10:30:31.775891+00:00",
|
||||
"video_id": "6qLsJxnCLs0"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-03-16T10:30:41.220832+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-03-16T10:08:51.466898+00:00"
|
||||
},
|
||||
"39": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-03-18T09:44:16.826717+00:00",
|
||||
"episode_id": "42",
|
||||
"slug": "episode-39-st-patrick-s-day-chaos-and-caller-confessions"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-03-18T10:00:28.370475+00:00",
|
||||
"video_id": "iSI1NXW2y9g"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-03-18T10:00:35.226643+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-03-18T09:44:16.826690+00:00"
|
||||
},
|
||||
"40": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-03-19T08:07:35.720887+00:00",
|
||||
"episode_id": 43,
|
||||
"slug": "episode-40-prostate-cancer-christmas-lights-and-potato-salad-betrayals"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-03-19T08:30:25.222815+00:00",
|
||||
"video_id": "TS06OLz6SPo"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-03-19T08:30:33.446060+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-03-19T08:07:35.720850+00:00"
|
||||
},
|
||||
"41": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-03-20T08:18:04.128449+00:00",
|
||||
"episode_id": 44,
|
||||
"slug": "episode-41-benny-s-creepy-vhs-tape-from-nowhere"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-03-20T08:39:36.047901+00:00",
|
||||
"video_id": "XKRWldOh6JM"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-03-20T08:39:42.538777+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-03-20T08:18:04.128412+00:00"
|
||||
},
|
||||
"42": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-03-21T11:50:48.656769+00:00",
|
||||
"episode_id": "45",
|
||||
"slug": "episode-42-peggy-s-day-trading-disaster-and-the-nonprofit-yacht"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-03-21T12:07:10.326330+00:00",
|
||||
"video_id": "x8b4SpkxQZ4"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-03-21T12:07:16.614504+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-03-21T11:50:48.656724+00:00"
|
||||
},
|
||||
"43": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-03-23T06:32:55.148064+00:00",
|
||||
"episode_id": "46",
|
||||
"slug": "episode-43-cousin-rufus-and-the-locksmith-s-wild-weekend"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-03-23T06:56:08.969398+00:00",
|
||||
"video_id": "JI4vgl7Oa0A"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-03-23T06:56:36.930530+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-03-23T06:32:55.147339+00:00"
|
||||
},
|
||||
"44": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-03-24T07:06:50.998787+00:00",
|
||||
"episode_id": "47",
|
||||
"slug": "episode-44-floyd-s-fence-and-the-cattle-conspiracy"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-03-24T07:25:09.652339+00:00",
|
||||
"video_id": "Ol6lwe40YwA"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-03-24T07:25:16.820530+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-03-24T07:06:50.998433+00:00"
|
||||
},
|
||||
"45": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-03-26T08:21:30.254496+00:00",
|
||||
"episode_id": "48",
|
||||
"slug": "episode-45-potato-salad-aliens-and-the-dripping-faucet-of-doom"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-03-26T08:39:45.353948+00:00",
|
||||
"video_id": "YZeERny3WrE"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-03-26T08:39:52.864007+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-03-26T08:21:30.254466+00:00"
|
||||
},
|
||||
"46": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-03-30T09:13:10.855462+00:00",
|
||||
"episode_id": "49",
|
||||
"slug": "episode-46-butchers-wreak-forever-and-other-late-night-confessions"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-03-30T09:35:27.776133+00:00",
|
||||
"video_id": "iXf5MmEO7P0"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-03-30T09:35:40.601815+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-03-30T09:13:10.855432+00:00"
|
||||
},
|
||||
"47": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-03-31T05:25:29.165685+00:00",
|
||||
"episode_id": "50",
|
||||
"slug": "episode-47-clarence-randy-and-the-onlyfans-daughter"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-03-31T05:46:35.296876+00:00",
|
||||
"video_id": "Z2dPvt28HYg"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-03-31T05:46:42.153090+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-03-31T05:25:29.165641+00:00"
|
||||
},
|
||||
"48": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-04-02T04:37:08.410107+00:00",
|
||||
"episode_id": "51",
|
||||
"slug": "episode-48-potato-salad-legacy-and-the-april-fool-s-apocalypse"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-04-02T04:54:42.078977+00:00",
|
||||
"video_id": "CyJP_QYEDj4"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-04-02T04:54:48.249952+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-04-02T04:37:08.410056+00:00"
|
||||
},
|
||||
"49": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-04-06T01:14:33.342179+00:00",
|
||||
"episode_id": 52,
|
||||
"slug": "episode-49-silas-s-shared-intimacy-night-and-four-brave-souls"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-04-06T03:38:50.148563+00:00",
|
||||
"video_id": "-3zf-gw-6gA"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-04-06T17:29:46.487867+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-04-06T01:14:33.342137+00:00"
|
||||
},
|
||||
"50": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-04-10T07:57:35.570851+00:00",
|
||||
"episode_id": "53",
|
||||
"slug": "episode-50-deb-s-mounted-jackrabbit-and-seven-years-of-silence"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-04-10T08:16:37.495030+00:00",
|
||||
"video_id": "V18y85BxuZI"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-04-10T08:16:45.539848+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-04-10T07:57:35.570779+00:00"
|
||||
},
|
||||
"52": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-04-16T09:18:45.701330+00:00",
|
||||
"episode_id": "55",
|
||||
"slug": "episode-52-dexter-s-construction-corner-and-the-whistleblower-s-dilemma"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-04-16T09:22:31.402285+00:00",
|
||||
"video_id": "G4_hyAdODfc"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-04-16T09:22:39.384918+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-04-16T09:18:45.701295+00:00"
|
||||
},
|
||||
"53": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-04-28T08:18:23.512676+00:00",
|
||||
"episode_id": "56",
|
||||
"slug": "episode-53-the-hospice-nurse-s-impossible-choice"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-04-28T08:36:05.701557+00:00",
|
||||
"video_id": "pRZ9T1xHh8Y"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-04-28T08:36:13.386695+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-04-28T08:18:23.512621+00:00"
|
||||
},
|
||||
"54": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-05-06T07:01:50.674724+00:00",
|
||||
"episode_id": "57",
|
||||
"slug": "episode-54-deb-s-roadrunner-and-the-hospice-nurse-s-dilemma"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-05-06T07:19:49.840084+00:00",
|
||||
"video_id": "4uGCi4XcLLw"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-05-06T07:19:57.484472+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-05-06T07:01:50.674667+00:00"
|
||||
},
|
||||
"55": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-05-12T06:49:28.626817+00:00",
|
||||
"episode_id": 58,
|
||||
"slug": "episode-55-the-wellspring-s-haunting-social-architecture"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-05-12T07:10:52.080949+00:00",
|
||||
"video_id": "K31eXqjFChM"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-05-12T07:11:01.134597+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-05-12T06:49:28.626772+00:00"
|
||||
},
|
||||
"56": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-05-21T08:50:34.758443+00:00",
|
||||
"episode_id": "59",
|
||||
"slug": "episode-56-the-tattoo-that-saved-a-life"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-05-21T09:09:08.740298+00:00",
|
||||
"video_id": "EDY2Srb778E"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-05-21T09:09:17.569025+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-05-21T08:50:34.758386+00:00"
|
||||
},
|
||||
"57": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-06-02T11:32:44.749492+00:00",
|
||||
"episode_id": "60",
|
||||
"slug": "episode-57-trace-s-box-of-family-secrets"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-06-02T11:45:50.938869+00:00",
|
||||
"video_id": "yAMz7Yy5_2k"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-06-02T12:16:42.059793+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-06-02T11:32:44.749472+00:00"
|
||||
},
|
||||
"58": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-08-04T09:22:36.468634+00:00",
|
||||
"episode_id": 61,
|
||||
"slug": "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-08-04T09:37:08.440981+00:00"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-08-04T09:57:26.006045+00:00",
|
||||
"video_id": "NX6ZUWnnUVQ"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-08-04T09:22:36.468552+00:00"
|
||||
},
|
||||
"59": {
|
||||
"steps": {
|
||||
"castopod": {
|
||||
"completed_at": "2026-08-15T08:53:47.130724+00:00",
|
||||
"episode_id": "62",
|
||||
"slug": "episode-59-doyle-s-hidden-llc-and-the-backdated-notary-stamp"
|
||||
},
|
||||
"youtube": {
|
||||
"completed_at": "2026-08-15T09:06:58.164454+00:00",
|
||||
"video_id": "pU63Pg6EZII"
|
||||
},
|
||||
"social": {
|
||||
"completed_at": "2026-08-15T09:07:04.970309+00:00"
|
||||
}
|
||||
},
|
||||
"started_at": "2026-08-15T08:53:47.130692+00:00"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
+98
-163
@@ -1,83 +1,12 @@
|
||||
{
|
||||
"regulars": [
|
||||
{
|
||||
"id": "37f0bfaa",
|
||||
"name": "Murray",
|
||||
"gender": "male",
|
||||
"age": 36,
|
||||
"job": "engine running for heat, watching his breath fog up the windshield while he tries to figure out how to fire his best friend of thirty years",
|
||||
"location": "in unknown",
|
||||
"personality_traits": [],
|
||||
"voice": "Tyler",
|
||||
"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": "Murray called in struggling with whether to fire his best friend Danny of 30 years, who's been showing up late, bad-mouthing him to their crew, and just cost them a major contract by abandoning a job site. Through the conversation, Murray realized he'd become overly rigid and \"suit-like\" while trying to prove himself as the new business owner, and decided instead of firing Danny, he'd hold a team meeting to apologize for his approach, explain the reasoning behind new protocols, and invite the crew to be part of the solution rather than just enforcing rules from above.",
|
||||
"timestamp": 1772250744.2312489
|
||||
},
|
||||
{
|
||||
"summary": "Murray called back about Danny, who showed up for only four days after their team meeting before disappearing without notice, then had his girlfriend tell Murray he was \"taking time to think\" about the job. Murray was emotionally torn between feeling hurt that Danny accused him of being inauthentic (only having the meeting because Luke called him out on air) and recognizing he needs to let Danny go for the sake of his business and the rest of his crew.",
|
||||
"timestamp": 1772862554.163734
|
||||
}
|
||||
],
|
||||
"last_call": 1772862554.1637352,
|
||||
"created_at": 1772250744.2312498
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
"last_call": 1773296210.170753,
|
||||
"created_at": 1772862907.314722
|
||||
},
|
||||
{
|
||||
"id": "d3399e9d",
|
||||
"name": "Lucille",
|
||||
"gender": "female",
|
||||
"age": 38,
|
||||
"job": "where she's been sitting for the past hour staring at her neighbor's garage through the window, watching his light flick on and off like he's still awake and pissed",
|
||||
"location": "in unknown",
|
||||
"personality_traits": [],
|
||||
"voice": "Tessa",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Clearly holding back. Gives vague answers to direct questions. Says 'I can't really get into that' about key details. The mystery IS the hook \u2014 makes you want to know what they're not saying. Energy level: low, controlled. When pushed back on, they deflect smoothly or change the subject. Getting the real story requires the host to work for it. Conversational tendency: strategic omission."
|
||||
},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Lucille called because her garage door opener has been opening her neighbor Dennis's garage instead of hers since a transformer blew, and she admitted she's been intentionally parking in his garage at 3 AM for three days because it's more convenient for her bail bonds work. Luke told her to stop trespassing immediately, fix her garage door opener, and stop acting like she needs covert operations just to come home from work.",
|
||||
"timestamp": 1772864272.556864
|
||||
}
|
||||
],
|
||||
"last_call": 1772864272.556865,
|
||||
"created_at": 1772864272.556865
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"job": "founder and spiritual guide of The Wellspring, a commune of about forty souls relocating from outside Deming to a stretch of desert outside Terlingua, Texas",
|
||||
"location": "outside Terlingua, Texas (recently moved from Deming, New Mexico)",
|
||||
"personality_traits": [
|
||||
"unnervingly charming",
|
||||
"casually drops cult references into normal conversation",
|
||||
@@ -87,7 +16,7 @@
|
||||
],
|
||||
"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."
|
||||
"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 that's relocating from outside Deming to the desert outside Terlingua, Texas. 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": [
|
||||
{
|
||||
@@ -105,114 +34,120 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"summary": "The caller, Concho, reports seeing mysterious vehicles mapping water sources on private ranches for nearly a year. He fears a large entity is compiling a secret inventory of water rights before making claims, and is frustrated that landowners dismiss his concerns. By the end of the call, Concho seems defeated, accepting that he's done all he can.",
|
||||
"timestamp": 1785831693.931573,
|
||||
"arc_status": "ongoing"
|
||||
},
|
||||
{
|
||||
"summary": "Silas called in to discuss a land dispute with his new neighbor in Alpine, Texas, which he initially framed as a problem with conflicting surveys and the potential loss of his well. However, through the conversation, he emotionally revealed that this dispute has triggered anxieties from a past failed communal living project in New Mexico, leading him to realize he's been neglecting the spiritual foundation and community-building aspects of his new \"Wellspring\" project, and needs to reconnect with his values and address past relational issues.",
|
||||
"timestamp": 1786774547.786939,
|
||||
"arc_status": "ongoing"
|
||||
},
|
||||
{
|
||||
"summary": "Imogen called to share her fascination with a massive, ancient, and now invisible mountain range buried under West Texas. While the host didn't share her excitement for the \"invisible\" geology, Imogen became emotional as she realized her limited perspective as a geology student and expressed a newfound desire to travel and see exposed geological wonders.",
|
||||
"timestamp": 1786782497.5843341,
|
||||
"arc_status": "ongoing"
|
||||
}
|
||||
],
|
||||
"last_call": 1772865423.6976142,
|
||||
"last_call": 1786782497.584337,
|
||||
"created_at": 1772430000.0
|
||||
},
|
||||
{
|
||||
"id": "d2af96eb",
|
||||
"name": "Rocco",
|
||||
"id": "ea08c4b3",
|
||||
"name": "Crispin",
|
||||
"gender": "male",
|
||||
"age": 53,
|
||||
"job": "and tonight he's calling from the back office of the Pizza Hut during his break because three hours ago he got trapped",
|
||||
"location": "unknown",
|
||||
"personality_traits": [],
|
||||
"voice": "Timothy",
|
||||
"stable_seeds": {
|
||||
"style": "COMMUNICATION STYLE: Obviously nervous about being on the radio. Starts with 'Am I on? Can you hear me?' Apologizes for taking up time. Speaks carefully like they're being recorded (which they are). Gets more comfortable as the conversation goes on. Energy level: low, building to medium. When pushed back on, they panic slightly and over-explain. Conversational tendency: seeking validation that they're doing okay."
|
||||
"age": 29,
|
||||
"job": "Third-year MFA student in creative writing at Sul Ross State University, from Houston originally",
|
||||
"location": "Alpine, TX \u2014 calling from his apartment near the Sul Ross campus",
|
||||
"personality_traits": [
|
||||
"The woman works at a business on Holland Avenue and they have a mutual friend group that overlaps with the MFA program's visiting-writer series, so there is essentially no universe where she doesn't find out",
|
||||
"The scene in question involves a specific argument about whether she was going to leave Alpine \u2014 a real argument, with real things said \u2014 that she told him in confidence during what she thought was a private moment at the Kokernot ballpark at dusk",
|
||||
"His thesis advisor has already told him this piece is 'the one' and written it into a fellowship application that has also already been submitted"
|
||||
],
|
||||
"voice": "Avery",
|
||||
"stable_seeds": {},
|
||||
"structured_background": {
|
||||
"name": "Crispin",
|
||||
"age": 29,
|
||||
"voice": "Elliot",
|
||||
"location": "Alpine, TX \u2014 calling from his apartment near the Sul Ross campus",
|
||||
"identity": "Third-year MFA student in creative writing at Sul Ross State University, from Houston originally",
|
||||
"situation": "Crispin has been writing, in thinly veiled fiction, about real people in Alpine \u2014 specific people, with specific details changed just enough to claim deniability. A piece he workshopped last semester was about a woman he dated briefly, and he gave it to a literary journal without telling her. The journal accepted it. The piece comes out in three weeks. The woman \u2014 a local, not a student \u2014 will almost certainly recognize herself, and the story depicts her in a humiliating, intimate scene that is substantially true. He told himself it was art. He told himself she'd never see it. He is now not sure which of those was the bigger lie. Weather right now: 66\u00b0F, clear skies.",
|
||||
"reason_calling": "He wants to know if he should pull the piece. He has until Friday to withdraw it. But pulling it means killing his best publication credit, possibly his thesis momentum, and admitting to himself that he used someone for material. Not pulling it means she reads it.",
|
||||
"opening_line": "I have until Friday to decide whether to torpedo my career or let someone I genuinely cared about find out I turned her into a character.",
|
||||
"secret_want": "He wants someone to tell him the piece is so good it justifies the harm \u2014 that art has always consumed real people and this is just the cost. He suspects no one will say that, and part of him called hoping they wouldn't.",
|
||||
"specific_details": [
|
||||
"The woman works at a business on Holland Avenue and they have a mutual friend group that overlaps with the MFA program's visiting-writer series, so there is essentially no universe where she doesn't find out",
|
||||
"The scene in question involves a specific argument about whether she was going to leave Alpine \u2014 a real argument, with real things said \u2014 that she told him in confidence during what she thought was a private moment at the Kokernot ballpark at dusk",
|
||||
"His thesis advisor has already told him this piece is 'the one' and written it into a fellowship application that has also already been submitted"
|
||||
],
|
||||
"emotional_register": "Defensive and intellectualizing at first \u2014 reaching for craft-talk and literary precedent \u2014 then progressively quieter as he hears himself. He's not a villain, he's a 29-year-old who made a choice he knew was wrong and is only now feeling the weight of it at full scale."
|
||||
},
|
||||
"avatar": "Crispin.jpg",
|
||||
"relationships": {},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Rocco, a pizza delivery driver, called from work to talk about getting stuck in an elevator for 45 minutes with his ex-wife Linda and her boyfriend Dale, which unexpectedly stirred up old feelings despite being happily married to his current wife Sharon for 15 years. The host firmly advised him to forget about Linda and appreciate his comfortable current marriage, reminding him that the divorce was difficult for good reasons.",
|
||||
"timestamp": 1772517521.710845
|
||||
},
|
||||
{
|
||||
"summary": "Rocco calls back to update the host about his ex-wife Linda, who he was previously stuck in an elevator with, revealing that she broke up with her boyfriend Dale three weeks ago and admitting he still has her number in his phone. The host becomes frustrated with Rocco's indecisiveness between Linda and his current girlfriend Sharon, telling him to pick one person and stop wasting time.",
|
||||
"timestamp": 1772700921.1273742
|
||||
},
|
||||
{
|
||||
"summary": "Rocco, a Pizza Hut delivery driver on his break, calls to share that he just delivered pizza to his ex-wife Linda's house three hours earlier\u2014a surprising encounter after the host had previously told him to stop wasting time talking about her. The host is skeptical and impatient, threatening to hang up again unless Rocco has something meaningful to say.",
|
||||
"timestamp": 1772959484.679859
|
||||
"summary": "Crispin, a fiction writer, is panicking because a literary journal accepted a story he wrote based on a past relationship, and he fears his ex will recognize herself and be hurt by the intimate details. He feels immense guilt and is struggling with the decision to either withdraw the story and potentially damage his career, or allow it to be published and face the consequences with his ex.",
|
||||
"timestamp": 1780395016.4822028,
|
||||
"arc_status": "ongoing"
|
||||
}
|
||||
],
|
||||
"last_call": 1772959484.6798599,
|
||||
"created_at": 1772517521.7108748
|
||||
"last_call": 1780395016.482204,
|
||||
"created_at": 1780395016.482204
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
"last_call": 1773226361.4859362,
|
||||
"created_at": 1772786610.8858292
|
||||
},
|
||||
{
|
||||
"id": "6037d92b",
|
||||
"name": "Otis",
|
||||
"gender": "male",
|
||||
"age": 50,
|
||||
"job": "sitting on a stack of pallets with his legs dangling, watching the wind kick up dust devils",
|
||||
"location": "unknown",
|
||||
"personality_traits": [],
|
||||
"voice": "Elliot",
|
||||
"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."
|
||||
},
|
||||
"call_history": [
|
||||
{
|
||||
"summary": "Otis called in, panicked, after faking prostate cancer to skip his sister\u2019s fourth wedding\u2014only for the lie to spiral into a town-wide support frenzy, complete with a GoFundMe, a cousin shaving his head, and his mom calling him every Sunday for the first time in years. The emotional gut-punch came when his sister postponed her wedding *again* (this time for his \"remission\"), leaving Otis torn between guilt over the deception and fear of the fallout if he confessed\u2014especially after his doctor confirmed he *doesn\u2019t* have cancer (though his prostate *is* enlarged). The call ended with dark humor and despair, as Otis admitted the town\u2019s sudden care for him was tied to his fake illness, not who he really is.",
|
||||
"timestamp": 1773040564.845288
|
||||
}
|
||||
],
|
||||
"last_call": 1773040564.84529,
|
||||
"created_at": 1773040564.84529
|
||||
},
|
||||
{
|
||||
"id": "0a0b3635",
|
||||
"name": "Charlene",
|
||||
"id": "1ce9d902",
|
||||
"name": "Linden",
|
||||
"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."
|
||||
"age": 41,
|
||||
"job": "Electrician and contractor, born in Marfa, one of the dwindling number of working-trades people who actually grew up there rather than arrived for the art scene",
|
||||
"location": "Marfa, Texas",
|
||||
"personality_traits": [
|
||||
"The wedding photograph shows a couple he does not recognize, but the woman is wearing a dress his mother described to him once \u2014 dark fabric, high collar, buttons to the throat \u2014 when she talked about her own grandmother's wedding, which his mother said she had never seen a photo of",
|
||||
"The Portland buyer has texted him twice asking for a referral for a plumber, and Linden has answered both texts normally, and this is the thing that makes him feel worst",
|
||||
"He has not opened the tin again since the first night \u2014 he resealed it with electrical tape and put it under the seat, and he knows where it is in the dark by the sound it makes when he takes a curve"
|
||||
],
|
||||
"voice": "Sophie",
|
||||
"stable_seeds": {},
|
||||
"structured_background": {
|
||||
"name": "Linden",
|
||||
"age": 41,
|
||||
"voice": "Blake",
|
||||
"location": "Marfa, Texas",
|
||||
"identity": "Electrician and contractor, born in Marfa, one of the dwindling number of working-trades people who actually grew up there rather than arrived for the art scene",
|
||||
"situation": "Four months ago Linden was hired to rewire a historic adobe on San Antonio Street that a buyer from Portland had purchased and was converting into a short-term rental. Standard job \u2014 he has done fifty of them since the money started coming into Marfa. On the last day of the job, while pulling old knob-and-tube from inside a wall cavity, he found a metal tin, soldered shut, with a name stamped on the lid that he recognized: his mother's maiden name, Ronquillo. He pried it open. Inside were forty-seven photographs \u2014 formal portraits, family groups, one wedding photo \u2014 and a folded paper in Spanish that he had partially translated by a woman at the post office. The paper appears to be a record of land \u2014 varas, boundaries, a family name \u2014 predating the current deed by at least sixty years. He has not told the Portland buyer. He has not told his mother, who is 74 and lives in a care facility in Alpine. He has been sitting with the tin in his truck for four months.",
|
||||
"reason_calling": "He is calling because tonight he drove past the house and saw it listed on a rental app \u2014 photos of the freshly renovated interior, the exposed adobe walls he rewired, $385 a night \u2014 and something in him that had been staying still stopped staying still.",
|
||||
"opening_line": "I pulled something out of a wall four months ago that doesn't belong to the man who owns the house, and I've been driving around with it ever since, and tonight I finally looked up what he's charging per night.",
|
||||
"secret_want": "To give the tin to his mother before she is too far gone to understand what she is looking at. That is the whole of it. Everything else \u2014 the buyer, the land record, what it means legally \u2014 is secondary to that one window, which is closing.",
|
||||
"specific_details": [
|
||||
"The wedding photograph shows a couple he does not recognize, but the woman is wearing a dress his mother described to him once \u2014 dark fabric, high collar, buttons to the throat \u2014 when she talked about her own grandmother's wedding, which his mother said she had never seen a photo of",
|
||||
"The Portland buyer has texted him twice asking for a referral for a plumber, and Linden has answered both texts normally, and this is the thing that makes him feel worst",
|
||||
"He has not opened the tin again since the first night \u2014 he resealed it with electrical tape and put it under the seat, and he knows where it is in the dark by the sound it makes when he takes a curve"
|
||||
],
|
||||
"emotional_register": "Contained, deliberate, the flat affect of a man who handles things alone and always has. Gets quiet when he talks about his mother. Does not ask for advice \u2014 states facts, then goes quiet and waits, as though he is diagnosing a circuit."
|
||||
},
|
||||
"avatar": "Linden.jpg",
|
||||
"relationships": {},
|
||||
"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
|
||||
"summary": "Lyndon called in because he found a tin with his mother's maiden name, old photos, and a land record in a wall while working as an electrician. He's been driving around with it, conflicted about returning it to the homeowner, who is renting the house as an Airbnb. The host suggested he explain the situation to the owner and ask to show it to his mother, who is in a care facility and whose memory is fading. Lyndon became emotional when realizing this was the only way to potentially connect his mother to her family history before it's too late.",
|
||||
"timestamp": 1786781199.9054542,
|
||||
"arc_status": "ongoing"
|
||||
}
|
||||
],
|
||||
"last_call": 1773219255.9161851,
|
||||
"created_at": 1772866520.023336
|
||||
"last_call": 1786781199.905455,
|
||||
"created_at": 1786781199.905455
|
||||
}
|
||||
]
|
||||
}
|
||||
+7233
-593
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,12 @@
|
||||
1771212705,
|
||||
1771146434,
|
||||
1771146564,
|
||||
1773545733,
|
||||
1771146952,
|
||||
1773531209,
|
||||
1771244817,
|
||||
1771244823,
|
||||
1782584345,
|
||||
1771213151
|
||||
]
|
||||
}
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# Deploy SearXNG to the QNAP NAS (mmgnas) as Devon's always-on web-search backend.
|
||||
# Public image — no build. Ships a settings.yml that enables the JSON API and
|
||||
# disables the bot limiter so the backend can query format=json programmatically.
|
||||
set -e
|
||||
|
||||
NAS_PORT="8001"
|
||||
NAS_USER="luke"
|
||||
NAS_HOST="${NAS_HOST:-mmgnas}" # override with NAS_HOST=mmgnas-10g for wired
|
||||
DOCKER="/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
||||
DEPLOY_DIR="/share/CACHEDEV1_DATA/docker/searxng"
|
||||
CONTAINER="searxng"
|
||||
HOST_PORT="8888"
|
||||
IMAGE="searxng/searxng:latest"
|
||||
|
||||
SSH="ssh -p $NAS_PORT $NAS_USER@$NAS_HOST"
|
||||
|
||||
echo "==> Ensuring deploy dir $DEPLOY_DIR"
|
||||
$SSH "mkdir -p $DEPLOY_DIR"
|
||||
|
||||
# Generate settings.yml only if absent (preserve secret_key across redeploys)
|
||||
if ! $SSH "test -f $DEPLOY_DIR/settings.yml"; then
|
||||
echo "==> Writing settings.yml (first deploy)"
|
||||
SECRET=$(openssl rand -hex 32)
|
||||
TMP=$(mktemp)
|
||||
cat > "$TMP" <<EOF
|
||||
use_default_settings: true
|
||||
server:
|
||||
secret_key: "$SECRET"
|
||||
bind_address: "0.0.0.0"
|
||||
limiter: false
|
||||
image_proxy: true
|
||||
search:
|
||||
safe_search: 0
|
||||
formats:
|
||||
- html
|
||||
- json
|
||||
EOF
|
||||
scp -P "$NAS_PORT" "$TMP" "$NAS_USER@$NAS_HOST:$DEPLOY_DIR/settings.yml"
|
||||
rm "$TMP"
|
||||
else
|
||||
echo "==> settings.yml already present — leaving it (and its secret_key) intact"
|
||||
fi
|
||||
|
||||
echo "==> Pulling $IMAGE"
|
||||
$SSH "$DOCKER pull $IMAGE"
|
||||
|
||||
echo "==> (Re)starting container"
|
||||
$SSH "$DOCKER rm -f $CONTAINER 2>/dev/null || true"
|
||||
$SSH "$DOCKER run -d --name $CONTAINER --restart unless-stopped \
|
||||
-p $HOST_PORT:8080 \
|
||||
-v $DEPLOY_DIR:/etc/searxng \
|
||||
-e SEARXNG_BASE_URL=http://$NAS_HOST:$HOST_PORT/ \
|
||||
$IMAGE"
|
||||
|
||||
echo "==> Verifying"
|
||||
sleep 6
|
||||
$SSH "$DOCKER ps --filter name=$CONTAINER --format '{{.Status}}'"
|
||||
$SSH "$DOCKER logs $CONTAINER 2>&1 | tail -15"
|
||||
+13
-1
@@ -5,9 +5,21 @@
|
||||
|
||||
set -e
|
||||
|
||||
NAS_HOST="mmgnas-10g"
|
||||
NAS_USER="luke"
|
||||
NAS_PORT="8001"
|
||||
|
||||
# mmgnas-10g is only up when the 10G cable is physically connected. Prefer it
|
||||
# when reachable (faster transfers), otherwise fall back to the wireless/1G
|
||||
# host. Override with NAS_HOST=... to skip detection.
|
||||
if [ -z "$NAS_HOST" ]; then
|
||||
if ssh -p "$NAS_PORT" -o ConnectTimeout=2 -o BatchMode=yes \
|
||||
"$NAS_USER@mmgnas-10g" true 2>/dev/null; then
|
||||
NAS_HOST="mmgnas-10g"
|
||||
else
|
||||
NAS_HOST="mmgnas"
|
||||
fi
|
||||
echo "NAS host: $NAS_HOST"
|
||||
fi
|
||||
DOCKER_BIN="/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
||||
DEPLOY_DIR="/share/CACHEDEV1_DATA/podcast-stats"
|
||||
CONTAINER_NAME="podcast-stats"
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
# Website JS Infrastructure, SEO, Shared Components & Content Fixes
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix JS duplication, add worker-level social meta injection, standardize analytics proxying, improve security and UX, and clean up content/SEO issues across lukeattheroost.com.
|
||||
|
||||
**Architecture:** Extract shared footer into `js/footer.js`, extract shared audio player into `js/player.js`, enhance `_worker.js` to intercept social crawler requests and inject episode-specific meta tags, switch all subpages to proxied Plausible analytics, add episode pagination, fix XSS surfaces, and clean up sitemap/clips data.
|
||||
|
||||
**Tech Stack:** Vanilla JS, Cloudflare Pages Worker (ES module), static HTML, XML sitemap
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create shared footer component (`js/footer.js`)
|
||||
|
||||
**Files:**
|
||||
- Create: `website/js/footer.js`
|
||||
|
||||
**Step 1: Write footer.js**
|
||||
|
||||
The footer HTML is duplicated across 7 pages (index.html:265-306, episode.html:95-136, clips.html:68-109, stats.html, privacy.html, terms.html, how-it-works.html). Extract the footer from `index.html` as the canonical version.
|
||||
|
||||
```js
|
||||
function initFooter() {
|
||||
const footer = document.querySelector('.footer');
|
||||
if (!footer) return;
|
||||
|
||||
footer.innerHTML = `
|
||||
<div class="footer-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/how-it-works">How It Works</a>
|
||||
<a href="/clips">Clips</a>
|
||||
<a href="/stats">Stats</a>
|
||||
</div>
|
||||
<div class="footer-icons">
|
||||
<span class="footer-icons-label">Listen On</span>
|
||||
<div class="footer-icons-row">
|
||||
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Spotify"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg></a>
|
||||
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener" class="footer-icon-link" aria-label="YouTube"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z"/><path d="M9.545 15.568V8.432L15.818 12z" fill="#1a1209"/></svg></a>
|
||||
<a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Apple Podcasts"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.477 2 12c0 3.293 1.592 6.214 4.05 8.04.13-.455.283-.942.457-1.393A9 9 0 0 1 3 12a9 9 0 0 1 18 0 9 9 0 0 1-3.507 7.127c.174.42.327.893.456 1.333A10 10 0 0 0 22 12c0-5.523-4.477-10-10-10zm0 4a6 6 0 0 0-6 6c0 1.87.856 3.54 2.2 4.64.196-.46.43-.91.692-1.31A4.5 4.5 0 0 1 7.5 12a4.5 4.5 0 0 1 9 0c0 1.21-.478 2.31-1.256 3.12.24.37.462.8.655 1.24A6 6 0 0 0 18 12a6 6 0 0 0-6-6zm0 4.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM12 15c-.75 0-1.158.54-1.28 1.2-.17.94-.28 1.91-.33 2.88-.03.48.34.82.73.82h1.76c.39 0 .76-.34.73-.82-.05-.97-.16-1.94-.33-2.88-.122-.66-.53-1.2-1.28-1.2z"/></svg></a>
|
||||
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener" class="footer-icon-link" aria-label="RSS"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.503 20.752c0 1.794-1.456 3.248-3.251 3.248S0 22.546 0 20.752s1.456-3.248 3.252-3.248 3.251 1.454 3.251 3.248zM.002 9.473v4.594c5.508.163 9.929 4.584 10.092 10.091h4.594C14.524 16.21 7.849 9.636.002 9.473zM.006 0v4.604C10.81 4.77 19.23 13.19 19.396 24h4.604C23.834 10.952 13.054.166.006 0z"/></svg></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-icons">
|
||||
<span class="footer-icons-label">Follow</span>
|
||||
<div class="footer-icons-row">
|
||||
<a href="https://discord.gg/5CnQZxDM" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Discord"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg></a>
|
||||
<a href="https://www.facebook.com/profile.php?id=61588191627949" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Facebook"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg></a>
|
||||
<a href="https://www.instagram.com/lukeattheroost/" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Instagram"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg></a>
|
||||
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="X"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg></a>
|
||||
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Bluesky"><svg viewBox="0 0 568 501" fill="currentColor"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C10.945 203.659 1 75.291 1 57.946 1-28.906 76.134-1.612 123.121 33.664z"/></svg></a>
|
||||
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener" class="footer-icon-link" aria-label="Mastodon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054 19.648 19.648 0 0 0 4.636.528c.164 0 .329 0 .494-.002 1.694-.042 3.48-.152 5.12-.554 2.21-.543 4.137-2.186 4.348-4.55.162-1.808.21-3.627.142-5.43-.02-.6-.168-1.874-.168-1.874z"/><path d="M19.903 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192zM9.337 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192z" fill="#1a1209"/></svg></a>
|
||||
<a href="https://primal.net/p/nprofile1qqswsam9cx06j7sxzpl498uquk3kgrwedxtq48j57zxkuj8fs82xtugge0wtg" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Nostr"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.186.31a.27.27 0 0 0-.372 0C8.46 3.487 2.666 9.93 2.666 15.042c0 5.176 4.183 8.958 9.334 8.958s9.334-3.782 9.334-8.958c0-5.112-5.794-11.555-9.148-14.732z"/></svg></a>
|
||||
<a href="https://www.threads.com/@lukeattheroost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Threads"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.59 12c.025 3.086.718 5.496 2.057 7.164 1.432 1.781 3.632 2.695 6.54 2.717 2.227-.017 4.048-.59 5.413-1.703 1.428-1.163 2.076-2.645 1.925-4.403-.098-1.13-.578-2.065-1.39-2.7-.811-.636-1.905-.993-3.164-1.033a11.253 11.253 0 0 0-.04 0c-1.078.007-2.044.289-2.79.816-.68.481-1.069 1.108-1.125 1.813-.057.72.264 1.32.877 1.64.554.29 1.317.437 2.271.437l.013-.001c.652-.004 1.383-.078 2.172-.218l.386 2.022c-.947.18-1.837.273-2.643.278a10.35 10.35 0 0 1-.143 0c-1.425-.013-2.657-.284-3.66-.804-1.237-.643-1.928-1.745-1.836-2.93.099-1.258.738-2.316 1.849-3.064 1.088-.732 2.466-1.12 3.988-1.124h.05c1.644.044 3.088.528 4.178 1.398 1.133.905 1.8 2.185 1.935 3.703.2 2.258-.697 4.2-2.598 5.75-1.668 1.36-3.863 2.087-6.348 2.105z"/></svg></a>
|
||||
<a href="https://www.linkedin.com/company/luke-at-the-roost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="LinkedIn"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg></a>
|
||||
<a href="https://www.tiktok.com/@luke.at.the.roost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="TikTok"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-projects">
|
||||
<span class="footer-projects-label">More from Luke</span>
|
||||
<div class="footer-projects-links">
|
||||
<a href="https://macneilmediagroup.com" target="_blank" rel="noopener">MacNeil Media Group</a>
|
||||
<a href="https://prints.macneilmediagroup.com" target="_blank" rel="noopener">Photography Prints</a>
|
||||
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
||||
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
|
||||
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="/terms">Terms of Service</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
||||
`;
|
||||
}
|
||||
|
||||
initFooter();
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add website/js/footer.js
|
||||
git commit -m "Add shared footer component (js/footer.js)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Replace inline footers with shared component
|
||||
|
||||
**Files:**
|
||||
- Modify: `website/index.html` — replace lines 265-306 (inline footer content) with empty `<footer class="footer"></footer>`, add `<script src="js/footer.js"></script>` before closing `</body>`
|
||||
- Modify: `website/episode.html` — replace lines 95-136 with empty footer, add script tag
|
||||
- Modify: `website/clips.html` — replace lines 68-109 with empty footer, add script tag
|
||||
- Modify: `website/stats.html` — replace inline footer with empty footer, add script tag
|
||||
- Modify: `website/privacy.html` — replace inline footer with empty footer, add script tag
|
||||
- Modify: `website/terms.html` — replace inline footer with empty footer, add script tag
|
||||
- Modify: `website/how-it-works.html` — replace inline footer with empty footer, add script tag
|
||||
|
||||
**Step 1: Update each page**
|
||||
|
||||
For each of the 7 HTML files:
|
||||
1. Replace the entire `<footer class="footer">...</footer>` block with just `<footer class="footer"></footer>`
|
||||
2. Add `<script src="js/footer.js"></script>` near the end of `<body>`, before any page-specific scripts
|
||||
|
||||
Note: index.html's footer has slightly different nav links (no "Home" link since it IS home). The shared footer includes "Home" which is fine — clicking Home on the homepage just reloads it.
|
||||
|
||||
**Step 2: Verify no footer content remains inline**
|
||||
|
||||
Search for `footer-icons-label` in all HTML files — should only appear in `js/footer.js`.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add website/index.html website/episode.html website/clips.html website/stats.html website/privacy.html website/terms.html website/how-it-works.html
|
||||
git commit -m "Replace inline footers with shared footer.js component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Extract shared audio player module (`js/player.js`)
|
||||
|
||||
**Files:**
|
||||
- Create: `website/js/player.js`
|
||||
|
||||
The audio player code is duplicated: `app.js:1-11,14-23,143-226` and `episode.html:159-346` (inline `<script>`). Extract the shared player logic.
|
||||
|
||||
**Step 1: Write player.js**
|
||||
|
||||
```js
|
||||
const audio = document.getElementById('audio-element');
|
||||
const stickyPlayer = document.getElementById('sticky-player');
|
||||
const playerPlayBtn = document.getElementById('player-play-btn');
|
||||
const playerTitle = document.getElementById('player-title');
|
||||
const playerProgress = document.getElementById('player-progress');
|
||||
const playerProgressFill = document.getElementById('player-progress-fill');
|
||||
const playerTime = document.getElementById('player-time');
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const s = Math.floor(seconds);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function updatePlayIcons(playing) {
|
||||
const iconPlay = playerPlayBtn.querySelector('.icon-play');
|
||||
const iconPause = playerPlayBtn.querySelector('.icon-pause');
|
||||
if (iconPlay) iconPlay.style.display = playing ? 'none' : 'block';
|
||||
if (iconPause) iconPause.style.display = playing ? 'block' : 'none';
|
||||
}
|
||||
|
||||
audio.addEventListener('play', () => updatePlayIcons(true));
|
||||
audio.addEventListener('pause', () => updatePlayIcons(false));
|
||||
audio.addEventListener('ended', () => updatePlayIcons(false));
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
if (audio.duration) {
|
||||
playerProgressFill.style.width = (audio.currentTime / audio.duration * 100) + '%';
|
||||
playerTime.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
|
||||
}
|
||||
});
|
||||
|
||||
playerPlayBtn.addEventListener('click', () => {
|
||||
if (audio.src) { audio.paused ? audio.play() : audio.pause(); }
|
||||
});
|
||||
|
||||
playerProgress.addEventListener('click', (e) => {
|
||||
if (audio.duration) {
|
||||
const rect = playerProgress.getBoundingClientRect();
|
||||
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add website/js/player.js
|
||||
git commit -m "Extract shared audio player module (js/player.js)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Refactor app.js and episode.html to use player.js
|
||||
|
||||
**Files:**
|
||||
- Modify: `website/js/app.js` — remove duplicated player code (element lookups, formatTime, audio event listeners, updatePlayIcons, playerPlayBtn click, playerProgress click). Keep: FEED_URL, episode-specific logic (fetchEpisodes, renderEpisodes, playEpisode with card-specific icon toggling), formatDate, parseDuration, truncate, testimonials, on-air.
|
||||
- Modify: `website/episode.html` — remove inline `<script>` block (lines 159-347), replace with `<script src="js/player.js"></script>` then `<script src="js/episode.js"></script>`
|
||||
- Create: `website/js/episode.js` — episode-specific logic extracted from episode.html inline script (load episode from RSS, populate header, transcript loading, play button)
|
||||
- Modify: `website/index.html` — add `<script src="js/player.js"></script>` before `app.js`
|
||||
|
||||
**Step 1: Refactor app.js**
|
||||
|
||||
Remove from app.js:
|
||||
- Lines 1-11 (element lookups — now in player.js)
|
||||
- Lines 14-23 (formatTime — now in player.js)
|
||||
- Lines 173-226 (audio event listeners, updatePlayIcons, playerPlayBtn click, playerProgress click — now in player.js)
|
||||
|
||||
Keep the `currentEpisodeCard` variable and the card-specific icon toggling in `updatePlayIcons`. Since player.js handles the sticky player icons, app.js only needs to handle the episode card icons. Add a listener:
|
||||
|
||||
```js
|
||||
audio.addEventListener('play', () => {
|
||||
if (currentEpisodeCard) {
|
||||
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||
if (btn) { btn.innerHTML = pauseSVG; btn.classList.add('playing'); }
|
||||
}
|
||||
});
|
||||
audio.addEventListener('pause', () => {
|
||||
if (currentEpisodeCard) {
|
||||
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||
if (btn) { btn.innerHTML = playSVG; btn.classList.remove('playing'); }
|
||||
}
|
||||
});
|
||||
audio.addEventListener('ended', () => {
|
||||
if (currentEpisodeCard) {
|
||||
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||
if (btn) { btn.innerHTML = playSVG; btn.classList.remove('playing'); }
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Create episode.js**
|
||||
|
||||
Extract episode-specific logic from episode.html inline script. Use the global `audio`, `playerTitle`, `stickyPlayer` from player.js. Include: `formatDate`, `parseDuration`, `stripHtml`, slug parsing, `loadEpisode()`.
|
||||
|
||||
**Step 3: Update HTML script tags**
|
||||
|
||||
In `index.html`, change script loading order:
|
||||
```html
|
||||
<script src="js/footer.js"></script>
|
||||
<script src="js/clips.js"></script>
|
||||
<script>renderFeaturedClipsInline('home-clips');</script>
|
||||
<script src="js/player.js"></script>
|
||||
<script src="js/app.js?v=3"></script>
|
||||
```
|
||||
|
||||
In `episode.html`, replace inline `<script>` (lines 159-347) with:
|
||||
```html
|
||||
<script src="js/footer.js"></script>
|
||||
<script src="js/player.js"></script>
|
||||
<script src="js/episode.js"></script>
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add website/js/app.js website/js/player.js website/js/episode.js website/index.html website/episode.html
|
||||
git commit -m "Deduplicate audio player code into shared player.js module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Fix Plausible analytics — switch all subpages to proxied version
|
||||
|
||||
**Files:**
|
||||
- Modify: `website/episode.html` line 51-52
|
||||
- Modify: `website/clips.html` line 43-44
|
||||
- Modify: `website/stats.html` line 43-44
|
||||
- Modify: `website/privacy.html` line 37-38
|
||||
- Modify: `website/terms.html` line 37-38
|
||||
- Modify: `website/how-it-works.html` line 66-67
|
||||
|
||||
**Step 1: In each file, replace the direct Plausible script tag**
|
||||
|
||||
Replace:
|
||||
```html
|
||||
<script defer data-domain="lukeattheroost.com" src="https://plausible.macneilmediagroup.com/js/script.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js"></script>
|
||||
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||
```
|
||||
|
||||
With:
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
**Step 2: Verify**
|
||||
|
||||
Grep for `plausible.macneilmediagroup.com` in HTML files — should return 0 matches (only `_worker.js` should have it).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add website/episode.html website/clips.html website/stats.html website/privacy.html website/terms.html website/how-it-works.html
|
||||
git commit -m "Switch all subpages to proxied Plausible analytics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Worker — social crawler meta tag injection for episode pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `website/_worker.js`
|
||||
|
||||
**Step 1: Add social crawler detection and meta injection**
|
||||
|
||||
Before the `return env.ASSETS.fetch(request)` line (line 90), add a handler for `/episode.html` requests from social crawlers:
|
||||
|
||||
```js
|
||||
// Social crawler meta injection for episode pages
|
||||
if (url.pathname === "/episode.html" && url.searchParams.get("slug")) {
|
||||
const ua = (request.headers.get("User-Agent") || "").toLowerCase();
|
||||
const isCrawler = /facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|pinterest|redditbot/i.test(ua);
|
||||
|
||||
if (isCrawler) {
|
||||
const slug = url.searchParams.get("slug");
|
||||
|
||||
// Fetch RSS to find episode info
|
||||
try {
|
||||
const feedResp = await fetch("https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml", {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (feedResp.ok) {
|
||||
const feedXml = await feedResp.text();
|
||||
|
||||
// Simple string-based extraction (no DOM parser in Workers)
|
||||
const items = feedXml.split("<item>");
|
||||
let title = "";
|
||||
let description = "";
|
||||
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const linkMatch = item.match(/<link>(.*?)<\/link>/);
|
||||
if (linkMatch) {
|
||||
const itemSlug = linkMatch[1].split("/episodes/").pop()?.replace(/\/$/, "");
|
||||
if (itemSlug === slug) {
|
||||
const titleMatch = item.match(/<title>(.*?)<\/title>/);
|
||||
title = titleMatch ? titleMatch[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim() : "";
|
||||
const descMatch = item.match(/<description>(.*?)<\/description>/s);
|
||||
description = descMatch
|
||||
? descMatch[1].replace(/<!\[CDATA\[|\]\]>/g, "").replace(/<[^>]+>/g, "").trim().slice(0, 200)
|
||||
: "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (title) {
|
||||
// Fetch the actual HTML page
|
||||
const pageResp = await env.ASSETS.fetch(request);
|
||||
let html = await pageResp.text();
|
||||
|
||||
const escTitle = title.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
const escDesc = description.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
const canonicalUrl = `https://lukeattheroost.com/episode.html?slug=${slug}`;
|
||||
|
||||
// Replace placeholder meta tags
|
||||
html = html.replace(
|
||||
/<meta property="og:title"[^>]*>/,
|
||||
`<meta property="og:title" content="${escTitle}">`
|
||||
);
|
||||
html = html.replace(
|
||||
/<meta property="og:description"[^>]*>/,
|
||||
`<meta property="og:description" content="${escDesc}">`
|
||||
);
|
||||
html = html.replace(
|
||||
/<meta property="og:url"[^>]*>/,
|
||||
`<meta property="og:url" content="${canonicalUrl}">`
|
||||
);
|
||||
html = html.replace(
|
||||
/<meta name="twitter:title"[^>]*>/,
|
||||
`<meta name="twitter:title" content="${escTitle}">`
|
||||
);
|
||||
html = html.replace(
|
||||
/<meta name="twitter:description"[^>]*>/,
|
||||
`<meta name="twitter:description" content="${escDesc}">`
|
||||
);
|
||||
html = html.replace(
|
||||
/<title[^>]*>.*?<\/title>/,
|
||||
`<title>${escTitle} — Luke at the Roost</title>`
|
||||
);
|
||||
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html;charset=UTF-8" },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall through to static page
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add website/_worker.js
|
||||
git commit -m "Add social crawler meta tag injection for episode pages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Security — sanitize innerHTML XSS surfaces
|
||||
|
||||
**Files:**
|
||||
- Modify: `website/js/episode.js` (created in Task 4)
|
||||
- Modify: `website/js/app.js`
|
||||
|
||||
**Step 1: Fix episode description XSS in episode.js**
|
||||
|
||||
In the `loadEpisode` function, change line that sets description:
|
||||
```js
|
||||
// BEFORE (XSS):
|
||||
document.getElementById('ep-desc').innerHTML = episode.description || '';
|
||||
|
||||
// AFTER (safe):
|
||||
document.getElementById('ep-desc').textContent = stripHtml(episode.description || '');
|
||||
```
|
||||
|
||||
**Step 2: Fix title escaping in app.js episode card rendering**
|
||||
|
||||
In `renderEpisodes()`, the title goes into a `data-title` attribute with basic `.replace(/"/g, '"')`. Use the `escapeHTML` pattern from clips.js. Add a helper at top of app.js:
|
||||
|
||||
```js
|
||||
function escapeAttr(str) {
|
||||
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
```
|
||||
|
||||
Then change line 125:
|
||||
```js
|
||||
// BEFORE:
|
||||
data-title="${ep.title.replace(/"/g, '"')}"
|
||||
|
||||
// AFTER:
|
||||
data-title="${escapeAttr(ep.title)}"
|
||||
```
|
||||
|
||||
Also escape the title in the aria-label and visible title:
|
||||
```js
|
||||
<button class="episode-play-btn" aria-label="Play ${escapeAttr(ep.title)}" data-url="${escapeAttr(ep.audioUrl)}" data-title="${escapeAttr(ep.title)}">
|
||||
```
|
||||
|
||||
And escape the visible title output:
|
||||
```js
|
||||
<div class="episode-title">${escapeAttr(ep.title)}</div>
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add website/js/episode.js website/js/app.js
|
||||
git commit -m "Fix XSS: sanitize innerHTML and improve attribute escaping"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Episode pagination — show 10, load more
|
||||
|
||||
**Files:**
|
||||
- Modify: `website/js/app.js`
|
||||
|
||||
**Step 1: Modify renderEpisodes to support pagination**
|
||||
|
||||
```js
|
||||
const EPISODES_PER_PAGE = 10;
|
||||
let allEpisodes = [];
|
||||
let displayedCount = 0;
|
||||
|
||||
function renderEpisodes(episodes) {
|
||||
allEpisodes = episodes;
|
||||
displayedCount = 0;
|
||||
episodesList.innerHTML = '';
|
||||
showMoreEpisodes();
|
||||
}
|
||||
|
||||
function showMoreEpisodes() {
|
||||
const batch = allEpisodes.slice(displayedCount, displayedCount + EPISODES_PER_PAGE);
|
||||
batch.forEach((ep) => {
|
||||
// ... existing card creation code ...
|
||||
episodesList.appendChild(card);
|
||||
});
|
||||
displayedCount += batch.length;
|
||||
|
||||
// Remove existing load-more button if present
|
||||
const existing = document.getElementById('load-more-btn');
|
||||
if (existing) existing.remove();
|
||||
|
||||
// Add load-more button if there are remaining episodes
|
||||
if (displayedCount < allEpisodes.length) {
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'load-more-btn';
|
||||
btn.className = 'load-more-btn';
|
||||
btn.textContent = `Load More (${allEpisodes.length - displayedCount} remaining)`;
|
||||
btn.addEventListener('click', showMoreEpisodes);
|
||||
episodesList.after(btn);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: The `.load-more-btn` CSS class likely needs to be created by the ui-ux task. For now, add minimal inline styling if the class doesn't exist yet. Actually, since we're told not to touch CSS, just use the class name and it will be styled later.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add website/js/app.js
|
||||
git commit -m "Add episode pagination with Load More button"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Truncate at word boundaries
|
||||
|
||||
**Files:**
|
||||
- Modify: `website/js/app.js`
|
||||
|
||||
**Step 1: Fix the truncate function**
|
||||
|
||||
```js
|
||||
// BEFORE (line 47-53):
|
||||
function truncate(html, maxLen) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html || '';
|
||||
const text = div.textContent || '';
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen).trimEnd() + '...';
|
||||
}
|
||||
|
||||
// AFTER:
|
||||
function truncate(html, maxLen) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html || '';
|
||||
const text = div.textContent || '';
|
||||
if (text.length <= maxLen) return text;
|
||||
const truncated = text.slice(0, maxLen);
|
||||
const lastSpace = truncated.lastIndexOf(' ');
|
||||
return (lastSpace > maxLen * 0.5 ? truncated.slice(0, lastSpace) : truncated).trimEnd() + '...';
|
||||
}
|
||||
```
|
||||
|
||||
The `lastSpace > maxLen * 0.5` guard ensures we don't cut too aggressively if the word boundary is very early.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add website/js/app.js
|
||||
git commit -m "Fix truncate to break at word boundaries"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Deduplicate featured clips on clips page
|
||||
|
||||
**Files:**
|
||||
- Modify: `website/js/clips.js`
|
||||
|
||||
**Step 1: Fix initClipsPage to exclude featured from "All Clips" grid**
|
||||
|
||||
```js
|
||||
// BEFORE (line 73-77):
|
||||
if (gridContainer) {
|
||||
clips.forEach(clip => {
|
||||
gridContainer.appendChild(renderClipCard(clip, false));
|
||||
});
|
||||
}
|
||||
|
||||
// AFTER:
|
||||
if (gridContainer) {
|
||||
clips.filter(c => !c.featured).forEach(clip => {
|
||||
gridContainer.appendChild(renderClipCard(clip, false));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add website/js/clips.js
|
||||
git commit -m "Deduplicate featured clips from All Clips grid"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Fix content issues — empty clip description and duplicate sitemap entry
|
||||
|
||||
**Files:**
|
||||
- Modify: `website/data/clips.json` — episode 31 clip (line 58): add description
|
||||
- Modify: `website/sitemap.xml` — remove duplicate episode 32 entry (lines 237-242)
|
||||
|
||||
**Step 1: Add description for episode 31 clip**
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Started a Fight and Can't Stop Reading About Wars",
|
||||
"description": "A caller starts a fight with their partner and now can't stop obsessively reading about historical wars. Luke tries to unpack the connection.",
|
||||
"episode_number": 31,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Remove duplicate sitemap entry**
|
||||
|
||||
Remove lines 237-242 (the `episode-32-tacos-taxes-and-tense-conversations` entry). Keep `episode-32-tacos-taxes-and-tall-tales` (lines 231-236) as the canonical one, OR check RSS feed to determine which slug is correct. If both exist in the feed, keep both — but episode numbering suggests one is a duplicate/rename. Remove the second one (`tense-conversations` variant).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add website/data/clips.json website/sitemap.xml
|
||||
git commit -m "Fix empty clip description and remove duplicate sitemap entry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Create custom 404 page
|
||||
|
||||
**Files:**
|
||||
- Create: `website/404.html`
|
||||
|
||||
**Step 1: Write 404.html**
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Page Not Found — Luke at the Roost</title>
|
||||
<meta name="description" content="The page you're looking for doesn't exist.">
|
||||
<meta name="theme-color" content="#1a1209">
|
||||
<link rel="icon" href="favicon.ico" sizes="48x48">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="css/style.css?v=3">
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="page-nav">
|
||||
<a href="/" class="nav-home">Luke at the Roost</a>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section class="page-header">
|
||||
<h1>404 — Page Not Found</h1>
|
||||
<p class="page-subtitle">Looks like this page wandered off into the desert.</p>
|
||||
</section>
|
||||
|
||||
<section class="about-section">
|
||||
<p>The page you're looking for doesn't exist or may have been moved.</p>
|
||||
<p><a href="/">Back to the show</a> · <a href="/clips">Watch clips</a> · <a href="/how-it-works">How it works</a></p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer"></footer>
|
||||
<script src="js/footer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add website/404.html
|
||||
git commit -m "Add custom 404 page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Enhance llms.txt
|
||||
|
||||
**Files:**
|
||||
- Modify: `website/llms.txt`
|
||||
|
||||
**Step 1: Add episode listing section and structured links**
|
||||
|
||||
Add after the FAQ section:
|
||||
|
||||
```markdown
|
||||
## Recent Episodes
|
||||
|
||||
Episodes are published daily. Each has a full transcript available at:
|
||||
https://lukeattheroost.com/episode.html?slug=EPISODE-SLUG
|
||||
|
||||
Episode transcript URLs follow the pattern: episode-N-title-slug
|
||||
Example: https://lukeattheroost.com/episode.html?slug=episode-37-secrets-lies-and-coffee-runs
|
||||
|
||||
## Clip Highlights
|
||||
|
||||
Popular clips with video:
|
||||
- "I Faked Cancer to Skip a Wedding" (Episode 32) — https://youtube.com/watch?v=NUkhsPfMx9o
|
||||
- "Neighbor's Roomba Breaks Into Kitchen at 2:30 AM" (Episode 26) — https://youtube.com/watch?v=J7bfT6jsykA
|
||||
- "Shopping Cart Theory: Moral Test or Crazy?" (Episode 21) — https://youtube.com/watch?v=KijyJsMZfkA
|
||||
|
||||
## Sitemap
|
||||
|
||||
Full sitemap: https://lukeattheroost.com/sitemap.xml
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add website/llms.txt
|
||||
git commit -m "Enhance llms.txt with episode patterns and clip highlights"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution Order & Dependencies
|
||||
|
||||
```
|
||||
Task 1 (footer.js) — no deps
|
||||
Task 3 (player.js) — no deps
|
||||
Task 5 (analytics fix) — no deps
|
||||
Task 9 (truncate fix) — no deps
|
||||
Task 10 (clips dedup) — no deps
|
||||
Task 11 (content fixes) — no deps
|
||||
Task 12 (404 page) — depends on Task 1 (uses footer.js)
|
||||
Task 13 (llms.txt) — no deps
|
||||
|
||||
Task 2 (replace footers) — depends on Task 1
|
||||
Task 4 (refactor to use player.js) — depends on Task 3
|
||||
Task 6 (worker meta injection) — no deps
|
||||
Task 7 (security fixes) — depends on Task 4 (episode.js must exist)
|
||||
Task 8 (pagination) — can run anytime, modifies app.js
|
||||
```
|
||||
|
||||
**Parallel batch 1** (independent): Tasks 1, 3, 5, 6, 9, 10, 11, 13
|
||||
**Parallel batch 2** (deps resolved): Tasks 2, 4, 12
|
||||
**Parallel batch 3** (deps resolved): Tasks 7, 8
|
||||
@@ -0,0 +1,335 @@
|
||||
# Show Theme Feature Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a "show theme" input to the header bar that injects theme context into caller background generation and conversation prompts, nudging callers toward the theme without forcing it.
|
||||
|
||||
**Architecture:** Store theme as a string on the Session object. Pass it into `_generate_caller_background_llm()` to bias character creation and into `get_caller_prompt()` so callers are aware of the show's theme during dialog. Frontend adds a text input in the header with a set/clear button, persisted via a new API endpoint.
|
||||
|
||||
**Tech Stack:** Python (FastAPI backend), vanilla HTML/CSS/JS frontend
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add theme to Session and API endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py:6192-6218` (Session class)
|
||||
- Modify: `backend/main.py:8731-8759` (settings endpoints area)
|
||||
|
||||
**Step 1: Add `show_theme` field to Session.__init__**
|
||||
|
||||
In `backend/main.py`, inside `Session.__init__` (line ~6217, after `self.intern_monitoring`), add:
|
||||
|
||||
```python
|
||||
self.show_theme: str = "" # Current show theme (e.g. "St. Patrick's Day")
|
||||
```
|
||||
|
||||
**Step 2: Add GET/POST endpoints for show theme**
|
||||
|
||||
Add these endpoints near the existing settings endpoints (~line 8760):
|
||||
|
||||
```python
|
||||
@app.get("/api/show-theme")
|
||||
async def get_show_theme():
|
||||
return {"theme": session.show_theme}
|
||||
|
||||
|
||||
@app.post("/api/show-theme")
|
||||
async def set_show_theme(data: dict):
|
||||
theme = data.get("theme", "").strip()
|
||||
old_theme = session.show_theme
|
||||
session.show_theme = theme
|
||||
if theme:
|
||||
print(f"[Theme] Show theme set: {theme}")
|
||||
elif old_theme:
|
||||
print(f"[Theme] Show theme cleared (was: {old_theme})")
|
||||
return {"theme": session.show_theme}
|
||||
```
|
||||
|
||||
**Step 3: Verify the server starts without errors**
|
||||
|
||||
Run: `curl -s http://localhost:8000/api/show-theme | python -m json.tool`
|
||||
Expected: `{"theme": ""}`
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/main.py
|
||||
git commit -m "Add show theme to Session and API endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Inject theme into caller background generation
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py:5208-5330` (`_generate_caller_background_llm`)
|
||||
- Modify: `backend/main.py:5381-5396` (`_pregenerate_backgrounds`)
|
||||
|
||||
**Step 1: Pass theme into background generation prompt**
|
||||
|
||||
In `_generate_caller_background_llm()` (~line 5307), after the line that builds `prompt = f"""Write a brief character description...`, add theme context. Find this section of the prompt string (around line 5316):
|
||||
|
||||
```python
|
||||
{f'CALLER ENERGY: {style_hint}' if style_hint else ''}
|
||||
```
|
||||
|
||||
Immediately after that line (still inside the f-string), add:
|
||||
|
||||
```python
|
||||
{f"SHOW THEME: Tonight's show theme is '{session.show_theme}'. This caller might have a story or angle related to this theme — or they might not. Not every caller has to be about the theme, but if their reason for calling can naturally connect to it, lean into that connection. The theme should feel like a through-line, not a mandate." if session.show_theme else ''}
|
||||
```
|
||||
|
||||
**Step 2: Verify backgrounds generate with theme**
|
||||
|
||||
Set a theme via API, then start a new session and check the server logs for background generation.
|
||||
|
||||
Run:
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8000/api/show-theme -H 'Content-Type: application/json' -d '{"theme": "St. Patricks Day"}'
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/main.py
|
||||
git commit -m "Inject show theme into caller background generation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Inject theme into conversation system prompt
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py:5995-6094` (`get_caller_prompt`)
|
||||
|
||||
**Step 1: Add theme block to get_caller_prompt**
|
||||
|
||||
In `get_caller_prompt()`, after the `world_context` block is built (around line 6015), add:
|
||||
|
||||
```python
|
||||
theme_context = ""
|
||||
if session.show_theme:
|
||||
theme_context = f"\nSHOW THEME: Tonight's show theme is \"{session.show_theme}\". You're aware of the theme — the host mentioned it at the top of the show. If your story or situation connects to it, you might bring it up naturally. But don't force it. Not every caller has to be about the theme. If the host steers you toward the theme, go with it.\n"
|
||||
```
|
||||
|
||||
Then inject `{theme_context}` into the return f-string. Find this line (~6063):
|
||||
|
||||
```python
|
||||
{relationship_context}{history}{world_context}{emotional_read}
|
||||
```
|
||||
|
||||
Change it to:
|
||||
|
||||
```python
|
||||
{relationship_context}{history}{world_context}{theme_context}{emotional_read}
|
||||
```
|
||||
|
||||
**Step 2: Verify prompt includes theme**
|
||||
|
||||
This can be verified by checking server logs during a call (the full prompt is logged at debug level).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/main.py
|
||||
git commit -m "Inject show theme into caller conversation prompt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add theme input to frontend header
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/index.html` (header section, lines 11-33)
|
||||
- Modify: `frontend/css/style.css`
|
||||
- Modify: `frontend/js/app.js`
|
||||
|
||||
**Step 1: Add theme input HTML to header**
|
||||
|
||||
In `frontend/index.html`, inside the `<header>` section, after the `.header-buttons` div (line ~19) and before the `#show-clock` div (line ~20), add:
|
||||
|
||||
```html
|
||||
<div class="theme-bar">
|
||||
<label for="show-theme-input" class="theme-label">Theme:</label>
|
||||
<input type="text" id="show-theme-input" class="theme-input" placeholder="e.g. St. Patrick's Day" maxlength="100">
|
||||
<button id="set-theme-btn" class="theme-btn set" title="Set show theme">Set</button>
|
||||
<button id="clear-theme-btn" class="theme-btn clear hidden" title="Clear theme">✕</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 2: Add CSS for theme bar**
|
||||
|
||||
In `frontend/css/style.css`, add styles for the theme bar. Place near other header styles:
|
||||
|
||||
```css
|
||||
.theme-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.theme-label {
|
||||
font-size: 0.8rem;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.theme-input {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.85rem;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.theme-input:focus {
|
||||
outline: none;
|
||||
border-color: #f5a623;
|
||||
}
|
||||
|
||||
.theme-input.active {
|
||||
border-color: #f5a623;
|
||||
background: rgba(245, 166, 35, 0.1);
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.theme-btn.set {
|
||||
background: #f5a623;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.theme-btn.set:hover {
|
||||
background: #e6991a;
|
||||
}
|
||||
|
||||
.theme-btn.clear {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #aaa;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.theme-btn.clear:hover {
|
||||
background: rgba(255, 80, 80, 0.3);
|
||||
color: #ff5050;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add JS for theme set/clear**
|
||||
|
||||
In `frontend/js/app.js`, add theme management functions and wire up event listeners. Add near other initialization code:
|
||||
|
||||
```javascript
|
||||
async function loadShowTheme() {
|
||||
try {
|
||||
const res = await fetch('/api/show-theme');
|
||||
const data = await res.json();
|
||||
const input = document.getElementById('show-theme-input');
|
||||
const setBtn = document.getElementById('set-theme-btn');
|
||||
const clearBtn = document.getElementById('clear-theme-btn');
|
||||
if (data.theme) {
|
||||
input.value = data.theme;
|
||||
input.classList.add('active');
|
||||
setBtn.classList.add('hidden');
|
||||
clearBtn.classList.remove('hidden');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load show theme:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function setShowTheme() {
|
||||
const input = document.getElementById('show-theme-input');
|
||||
const theme = input.value.trim();
|
||||
if (!theme) return;
|
||||
try {
|
||||
const res = await fetch('/api/show-theme', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ theme })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.theme) {
|
||||
input.classList.add('active');
|
||||
document.getElementById('set-theme-btn').classList.add('hidden');
|
||||
document.getElementById('clear-theme-btn').classList.remove('hidden');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to set show theme:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearShowTheme() {
|
||||
try {
|
||||
await fetch('/api/show-theme', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ theme: '' })
|
||||
});
|
||||
const input = document.getElementById('show-theme-input');
|
||||
input.value = '';
|
||||
input.classList.remove('active');
|
||||
document.getElementById('set-theme-btn').classList.remove('hidden');
|
||||
document.getElementById('clear-theme-btn').classList.add('hidden');
|
||||
} catch (e) {
|
||||
console.error('Failed to clear show theme:', e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Wire up event listeners (in the DOMContentLoaded or init block):
|
||||
|
||||
```javascript
|
||||
document.getElementById('set-theme-btn').addEventListener('click', setShowTheme);
|
||||
document.getElementById('clear-theme-btn').addEventListener('click', clearShowTheme);
|
||||
document.getElementById('show-theme-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') setShowTheme();
|
||||
});
|
||||
loadShowTheme();
|
||||
```
|
||||
|
||||
**Step 4: Test in browser**
|
||||
|
||||
1. Open http://localhost:8000
|
||||
2. Type a theme in the input, click Set — input should highlight amber, Set button hides, X button appears
|
||||
3. Click X — input clears, reverts to normal state
|
||||
4. Refresh page — theme should persist (loaded from API)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/index.html frontend/css/style.css frontend/js/app.js
|
||||
git commit -m "Add show theme input to header bar"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Clear theme on new session
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py` (session reset logic)
|
||||
|
||||
**Step 1: Find session reset and ensure theme clears**
|
||||
|
||||
Search for where `session = Session()` is called (the new session endpoint). The theme field is already in `__init__` with default `""`, so creating a new Session automatically clears it. No code change needed here — but verify the frontend reloads the theme on new session.
|
||||
|
||||
In the frontend, find the new session button handler and add `loadShowTheme()` after the session reset call completes, so the UI reflects the cleared theme.
|
||||
|
||||
**Step 2: Commit (if any changes needed)**
|
||||
|
||||
```bash
|
||||
git add frontend/js/app.js
|
||||
git commit -m "Reload theme state on new session"
|
||||
```
|
||||
@@ -0,0 +1,300 @@
|
||||
# Caller Quality Overhaul — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Make every caller interesting, layered, and conversationally driven — with specific details to reveal when pressed, strong opinions, and stories that sustain 10+ exchanges without going flat.
|
||||
|
||||
**Architecture:** Four interconnected changes: (1) LLM-generated updates for returning callers, (2) hidden layers in CallerBackground, (3) rebalanced caller prompt from reactive to driven, (4) massively expanded topic pools with more edgy/interesting/specific content. The alien abduction call (Bev, ep45) is the gold standard — professional expertise, progressive reveals, specific details, caller-driven energy.
|
||||
|
||||
**Tech Stack:** Python, OpenRouter LLM API, existing CallerBackground dataclass
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add Hidden Layers to CallerBackground
|
||||
|
||||
The core structural change. Callers currently get ONE situation paragraph. When the host digs in, there's nothing underneath. Add 3 fields to CallerBackground that give the caller ammunition for deeper conversations.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py` — CallerBackground dataclass (~line 6140), LLM background generation prompt (~line 5310)
|
||||
|
||||
**Step 1: Add fields to CallerBackground dataclass**
|
||||
|
||||
Find the CallerBackground dataclass and add three new fields:
|
||||
|
||||
```python
|
||||
hidden_layers: list[str] = field(default_factory=list) # 3 details they haven't mentioned yet — the juicy stuff underneath
|
||||
burning_opinion: str = "" # Something they're dying to say — will bring up even without being asked
|
||||
stakes: str = "" # What's at risk for them — why this matters, what happens if nothing changes
|
||||
```
|
||||
|
||||
**Step 2: Update the LLM background generation prompt**
|
||||
|
||||
In the JSON output spec of the background generation prompt (~line 5322), add:
|
||||
|
||||
```
|
||||
- "hidden_layers": A list of exactly 3 specific details the caller HASN'T mentioned yet but will reveal when pressed. These are the layers underneath the surface story. Think: the part they're embarrassed about, the complication they haven't admitted, the thing that happened AFTER the main event, the detail that changes everything. Each should be 1-2 sentences and SPECIFIC enough to sustain a follow-up question. Example: if the surface story is "my neighbor stole my mail" — layer 1 might be "the stolen mail included a paternity test result," layer 2 might be "the neighbor is actually her ex-husband's new girlfriend," layer 3 might be "she's been retaliating by feeding the neighbor's cat so it likes her better."
|
||||
- "burning_opinion": ONE thing this caller is dying to say — a strong opinion, a controversial take, something they'll volunteer even without being asked. This is what makes them INTERESTING to talk to. Not a generic feeling ("I'm frustrated") but a specific, arguable position ("I think what she did was right and I'd do it again"). Make it provocative enough that the host would want to push back.
|
||||
- "stakes": What's at risk for this caller. Why does this matter? What happens if nothing changes? "My sister won't talk to me" is weak. "If I don't fix this by Thursday, my sister is telling our parents about the money I took and I'll get cut out of the will" is strong. Real consequences, real deadlines, real pressure.
|
||||
```
|
||||
|
||||
**Step 3: Update the prompt's "WHAT MAKES A GOOD CALLER" section**
|
||||
|
||||
Add to the existing guidance:
|
||||
|
||||
```
|
||||
DEPTH TEST: Before finalizing, ask yourself — if the host asks "tell me more about that" THREE times, does the caller have three genuinely new, interesting things to reveal? If not, the story is too shallow. Add complications, secrets, or consequences that create layers.
|
||||
```
|
||||
|
||||
**Step 4: Thread hidden_layers into the caller system prompt**
|
||||
|
||||
In `get_caller_prompt()` (~line 6015), add a section that feeds hidden layers to the caller:
|
||||
|
||||
```python
|
||||
# Hidden layers — details to reveal when pressed
|
||||
layers_block = ""
|
||||
if caller.get('hidden_layers'):
|
||||
layers = caller['hidden_layers']
|
||||
layers_block = f"""
|
||||
DETAILS YOU HAVEN'T MENTIONED YET (reveal these naturally when Luke asks follow-up questions or digs deeper — don't dump them all at once, let them come out one at a time as the conversation develops):
|
||||
- {layers[0] if len(layers) > 0 else ''}
|
||||
- {layers[1] if len(layers) > 1 else ''}
|
||||
- {layers[2] if len(layers) > 2 else ''}
|
||||
"""
|
||||
|
||||
# Burning opinion — something they're dying to say
|
||||
opinion_block = ""
|
||||
if caller.get('burning_opinion'):
|
||||
opinion_block = f"""
|
||||
SOMETHING YOU'RE DYING TO SAY: {caller['burning_opinion']}
|
||||
You'll bring this up when there's a natural opening — you don't need to be asked. This is YOUR call and you have a POINT to make.
|
||||
"""
|
||||
|
||||
# Stakes — why this matters
|
||||
stakes_block = ""
|
||||
if caller.get('stakes'):
|
||||
stakes_block = f"""
|
||||
WHAT'S AT STAKE: {caller['stakes']}
|
||||
This isn't abstract — there are real consequences. Mention this when it's relevant. It's why you're calling NOW instead of just thinking about it.
|
||||
"""
|
||||
```
|
||||
|
||||
Insert these blocks into the prompt after `{story_block}`.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
---
|
||||
|
||||
## Task 2: LLM-Generated Updates for Returning Callers
|
||||
|
||||
Currently `_generate_returning_caller_background()` gives returning callers their old summaries and a one-line "something has changed." The dialog model has to improvise an update from nothing. Fix this by using the LLM to generate a specific, interesting new development.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py` — `_generate_returning_caller_background()` (~line 4722)
|
||||
|
||||
**Step 1: Add LLM story continuation to returning caller background**
|
||||
|
||||
After building `prev_section` with call history (~line 4764), add an async LLM call to generate a new development:
|
||||
|
||||
```python
|
||||
# Generate a specific new development via LLM
|
||||
update_prompt = f"""A returning caller on a late-night radio show is calling back. Here's their history:
|
||||
|
||||
NAME: {regular['name']}, {age}, {gender}
|
||||
JOB: {job}
|
||||
PREVIOUS CALLS:
|
||||
{chr(10).join(f'- {c["summary"]}' for c in prev_calls[-3:])}
|
||||
|
||||
Generate a SPECIFIC new development in their ongoing situation. Something has changed, escalated, or taken an unexpected turn since their last call. This should be:
|
||||
- Surprising but believable — a natural next chapter, not a soap opera twist
|
||||
- Specific with names, details, and concrete events
|
||||
- Interesting enough to sustain a 5-10 minute conversation
|
||||
- Connected to their previous calls but moving the story FORWARD
|
||||
|
||||
Also generate 3 hidden layers (details they'll reveal when pressed) and a burning opinion (something they're dying to say about how things have developed).
|
||||
|
||||
Respond with JSON:
|
||||
{{
|
||||
"new_development": "2-3 sentences describing what happened since their last call",
|
||||
"hidden_layers": ["detail 1", "detail 2", "detail 3"],
|
||||
"burning_opinion": "their strong take on the situation now"
|
||||
}}
|
||||
|
||||
Output ONLY valid JSON, no markdown fences."""
|
||||
```
|
||||
|
||||
Make this an async function. Call the LLM, parse the JSON, and inject the new_development into `prev_section` and the hidden_layers/burning_opinion into the caller's background data.
|
||||
|
||||
**Step 2: Update the returning caller story_block in get_caller_prompt()**
|
||||
|
||||
Change the returning caller `story_block` (~line 6056) from the passive "something has developed" to:
|
||||
|
||||
```python
|
||||
story_block = f"""YOUR STORY: You're calling back about your ongoing situation. Here's what's NEW since your last call — this is why you're calling tonight:
|
||||
|
||||
{{new_development}}
|
||||
|
||||
You have SPECIFIC things to talk about. Don't just vaguely reference "things have changed" — tell Luke EXACTLY what happened. You're calling because this new development is significant and you need to talk it through. You have details, you have feelings about it, and you have a point you want to make.
|
||||
|
||||
When Luke asks about your previous calls, give him the quick version — then get to what's NEW. The update is why you're here tonight."""
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Rebalance Caller Prompt — Driven, Not Passive
|
||||
|
||||
The current prompt over-emphasizes reactivity ("GO WHERE THE HOST TAKES YOU", "Let him drive"). This makes callers passive. Real compelling callers have their own agenda AND respond to the host.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py` — `get_caller_prompt()` (~line 6015)
|
||||
|
||||
**Step 1: Rewrite the "GO WHERE THE HOST TAKES YOU" section**
|
||||
|
||||
Replace the current passive framing (~line 6094) with a balanced version:
|
||||
|
||||
```
|
||||
GO WITH THE HOST BUT BRING YOUR OWN ENERGY. When Luke pushes you in a direction, challenges you, calls you out, or plays devil's advocate — engage with it. Don't shut down, don't deflect. If he says "but isn't that really about your dad?" — sit with that. BUT you're not a passive interview subject. You called because you have something to SAY. Between his questions, volunteer details he didn't ask for. Share the part you're embarrassed about. Drop the detail that changes everything. Push back when you disagree. Ask HIM what he thinks. The best callers are the ones who give the host material AND have their own momentum. You're not here to answer questions — you're here to have a CONVERSATION.
|
||||
```
|
||||
|
||||
**Step 2: Update the "REACT TO LUKE" section**
|
||||
|
||||
Change from pure reactivity (~line 6096) to a balance:
|
||||
|
||||
```
|
||||
REACT TO LUKE — BUT KEEP YOUR MOMENTUM: Your first sentence should respond to what Luke just said. But your SECOND sentence should add something new — a detail he didn't ask for, a complication, a related story, your opinion. Don't just answer and stop. Answer, then GIVE HIM MORE. If he asks "what happened next?" — don't just tell him what happened next. Tell him what happened next AND how it made you feel AND the part you haven't told anyone yet. Fill the space. Dead air is your enemy.
|
||||
```
|
||||
|
||||
**Step 3: Add a "WHEN LUKE ASKS FOR DETAILS" section**
|
||||
|
||||
Add a new section after the REACT TO LUKE block:
|
||||
|
||||
```
|
||||
WHEN LUKE ASKS FOR DETAILS — DELIVER. If Luke asks "tell me more about that" or "what do you mean?" or pushes for specifics — this is your moment. Don't give a vague one-sentence answer. Paint the picture. Who was there? What did they actually say? What were you doing when it happened? What did the room look like? What was going through your head? Specifics are what make a call memorable. "She was mad" is boring. "She threw her drink at the wall and said 'I knew you'd do this, you're just like your father'" is radio gold. ALWAYS have a specific answer ready — if Luke is digging, it means he's interested. Reward that interest with detail.
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Massively Expand Topic Pools
|
||||
|
||||
The current pools have good coverage but need more entries in the categories that produce the best calls: morally complex situations, sex/relationship drama, genuinely weird experiences, and callers with real questions about their lives. The alien abduction call worked because it was SPECIFIC, WEIRD, and the caller had EXPERTISE.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py` — PROBLEMS, STORIES, WEIRD, ADVICE, GOSSIP, TOPIC_CALLIN pools
|
||||
|
||||
**Step 1: Add 80+ new PROBLEMS entries**
|
||||
|
||||
Focus on: moral dilemmas with no clear right answer, sex/relationship situations that are messy and specific, workplace drama with real stakes, family situations with complications. Every entry should pass the "tell me more" test — can you ask 3 follow-up questions and get interesting answers?
|
||||
|
||||
Categories to emphasize:
|
||||
- **Moral dilemmas**: situations where both sides have a point, where doing the "right" thing has real costs
|
||||
- **Sex/relationship mess**: not generic "my partner cheated" but specific, awkward, funny situations with details
|
||||
- **Money/ethics**: found money, inheritance drama, business partner disputes, discovered fraud
|
||||
- **Secrets discovered**: found out something they shouldn't know, now they have to decide what to do with it
|
||||
- **Professional expertise callers**: people calling about weird things they encountered AT WORK (like Bev the nurse with the alien patient) — medical, legal, construction, teaching, law enforcement perspectives
|
||||
|
||||
**Step 2: Add 60+ new WEIRD entries**
|
||||
|
||||
The WEIRD pool produces the best calls when it's specific enough. Focus on:
|
||||
- Genuinely unexplainable personal experiences (not just "something spooky happened")
|
||||
- Bizarre neighbor/coworker behavior with specific ongoing patterns
|
||||
- Objects/places that don't behave normally
|
||||
- Coincidences too specific to be coincidence
|
||||
- Things they've noticed that nobody else seems to see
|
||||
- Callers who have developed elaborate theories about everyday phenomena
|
||||
|
||||
**Step 3: Add 60+ new STORIES entries**
|
||||
|
||||
Focus on stories where the caller was INVOLVED, not just an observer:
|
||||
- Times they did something they can't believe they did
|
||||
- Situations that escalated beyond all reason
|
||||
- Encounters with strangers that changed their perspective
|
||||
- Times they were absolutely, completely wrong about something
|
||||
- Situations where they were the bad guy and know it
|
||||
- Things they got away with that they probably shouldn't have
|
||||
|
||||
**Step 4: Add 40+ new ADVICE entries**
|
||||
|
||||
Real questions people would actually call a radio show about:
|
||||
- "Am I wrong for..." situations with genuine ambiguity
|
||||
- Situations where they've already decided but want validation
|
||||
- Timing/approach questions ("how do I tell my wife...")
|
||||
- Callers who are about to do something and want a gut check
|
||||
- Callers asking about the host's opinion on something specific and controversial
|
||||
|
||||
**Step 5: Add 40+ new GOSSIP entries**
|
||||
|
||||
Gossip works best when the caller has DETAILS and opinions:
|
||||
- Discovered something about someone in their life that changes everything
|
||||
- Workplace gossip with real consequences if it gets out
|
||||
- Small-town drama with escalating stakes
|
||||
- Things overheard that they probably shouldn't have heard
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Increase Response Budget for Substantive Answers
|
||||
|
||||
The current budget gives 15% of standard calls only 450 tokens / 3 sentences. When a caller has a great story, 3 sentences isn't enough. Shift the distribution to give callers more room to breathe, especially early in the call.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py` — `_pick_response_budget()` (~line 8280)
|
||||
|
||||
**Step 1: Adjust default response budget distribution**
|
||||
|
||||
```python
|
||||
# Default distribution — give callers room to tell their story
|
||||
roll = random.random()
|
||||
if roll < 0.10:
|
||||
return 500, 4 # 10% — quick response (was 15% / 3 sentences)
|
||||
elif roll < 0.35:
|
||||
return 600, 5 # 25% — normal conversation (was 30% / 4 sentences)
|
||||
elif roll < 0.65:
|
||||
return 700, 6 # 30% — room to breathe (was 30% / 5 sentences)
|
||||
else:
|
||||
return 800, 7 # 35% — telling a story or riffing (was 25% / 6 sentences)
|
||||
```
|
||||
|
||||
Also bump up shape-specific budgets proportionally.
|
||||
|
||||
**Step 2: Increase MIN_RESPONSE_WORDS**
|
||||
|
||||
Change from 20 to 30:
|
||||
```python
|
||||
MIN_RESPONSE_WORDS = 30 # Retry if response is shorter than this
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Quality Review and Integration Test
|
||||
|
||||
Read through all changes, verify they integrate cleanly, and test with a simulated caller generation.
|
||||
|
||||
**Files:**
|
||||
- Read: all modified files
|
||||
|
||||
**Step 1: Verify CallerBackground field additions parse correctly**
|
||||
|
||||
Run a test background generation to make sure the new fields (hidden_layers, burning_opinion, stakes) are populated by the LLM and parsed into the dataclass.
|
||||
|
||||
**Step 2: Verify returning caller LLM update generates properly**
|
||||
|
||||
Test with an existing regular (e.g., Shonda) to confirm the LLM generates a specific new development.
|
||||
|
||||
**Step 3: Verify the new prompt sections render correctly in get_caller_prompt()**
|
||||
|
||||
Check that hidden_layers, burning_opinion, and stakes blocks appear in the system prompt for a caller that has them.
|
||||
|
||||
**Step 4: Restart server and verify no import/syntax errors**
|
||||
|
||||
```bash
|
||||
pkill -f "uvicorn backend.main:app"
|
||||
/Users/lukemacneil/code/ai-podcast/venv/bin/python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
@@ -0,0 +1,297 @@
|
||||
# Show Quality Fixes — Episode 47 Post-Mortem
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix 5 bugs that ruined tonight's show: theme ignored by callers, wrong LLM models assigned, phonetic pronunciation mangling, voice-age mismatch, and low minimum response threshold.
|
||||
|
||||
**Architecture:** All fixes are in `backend/main.py` except voice-age matching which also touches `backend/services/tts.py` voice matching logic. Each fix is independent — no ordering dependencies between tasks.
|
||||
|
||||
**Tech Stack:** Python, FastAPI
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Regenerate caller backgrounds when theme is set
|
||||
|
||||
**Problem:** `_pregenerate_backgrounds()` runs on startup when `session.show_theme` is still `""`. Setting theme via `POST /api/show-theme` only stores the string — doesn't regenerate. Callers have zero theme connection.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py:9891-9900` (`set_show_theme` endpoint)
|
||||
- Modify: `backend/main.py:5899-5927` (`_pregenerate_backgrounds`)
|
||||
|
||||
**Step 1: Modify `set_show_theme` to regenerate unused caller backgrounds**
|
||||
|
||||
In `backend/main.py`, replace the `set_show_theme` endpoint (lines 9891-9900):
|
||||
|
||||
```python
|
||||
@app.post("/api/show-theme")
|
||||
async def set_show_theme(data: dict):
|
||||
theme = data.get("theme", "").strip()[:100]
|
||||
old_theme = session.show_theme
|
||||
session.show_theme = theme
|
||||
if theme:
|
||||
print(f"[Theme] Show theme set: {theme}")
|
||||
elif old_theme:
|
||||
print(f"[Theme] Show theme cleared (was: {old_theme})")
|
||||
|
||||
# Regenerate backgrounds for callers that haven't been on air yet
|
||||
if theme != old_theme:
|
||||
unused_keys = [k for k in CALLER_BASES if k not in session.used_callers]
|
||||
if unused_keys:
|
||||
print(f"[Theme] Regenerating {len(unused_keys)} unused caller backgrounds for theme: {theme or '(none)'}")
|
||||
asyncio.create_task(_regenerate_backgrounds_for_keys(unused_keys))
|
||||
|
||||
return {"theme": session.show_theme}
|
||||
```
|
||||
|
||||
**Step 2: Add `_regenerate_backgrounds_for_keys` helper**
|
||||
|
||||
Add this right after `_pregenerate_backgrounds()` (after line 5927):
|
||||
|
||||
```python
|
||||
async def _regenerate_backgrounds_for_keys(keys: list[str]):
|
||||
"""Regenerate backgrounds for specific caller keys (e.g. after theme change)."""
|
||||
tasks = []
|
||||
for key in keys:
|
||||
base = CALLER_BASES.get(key)
|
||||
if base and not base.get("returning"):
|
||||
tasks.append((key, _generate_caller_background_llm(base)))
|
||||
|
||||
if not tasks:
|
||||
return
|
||||
|
||||
results = await asyncio.gather(*[t[1] for t in tasks], return_exceptions=True)
|
||||
for (key, _), result in zip(tasks, results):
|
||||
if isinstance(result, Exception):
|
||||
print(f"[Theme] Regen failed for caller {key}: {result}")
|
||||
else:
|
||||
session.caller_backgrounds[key] = result
|
||||
# Clear cached model so it re-evaluates with new style
|
||||
session.caller_models.pop(key, None)
|
||||
|
||||
print(f"[Theme] Regenerated {sum(1 for r in results if not isinstance(r, Exception))}/{len(tasks)} backgrounds")
|
||||
_match_voices_to_styles()
|
||||
_sort_caller_queue()
|
||||
```
|
||||
|
||||
**Step 3: Verify `used_callers` exists on session**
|
||||
|
||||
Check that `session.used_callers` tracks which callers have already been on air. If it doesn't exist, use `session.call_history` caller keys instead.
|
||||
|
||||
**Step 4: Test manually**
|
||||
|
||||
```bash
|
||||
# Start server
|
||||
python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000
|
||||
# Set theme and check logs for "[Theme] Regenerating..." messages
|
||||
curl -X POST http://localhost:8000/api/show-theme -H "Content-Type: application/json" -d '{"theme": "Road Stories"}'
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/main.py
|
||||
git commit -m "Regenerate caller backgrounds when show theme is set"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Fix style-to-model matching race condition
|
||||
|
||||
**Problem:** `get_caller_model()` is called before `caller_styles` is populated. `caller_styles.get(key)` returns `""`, `_normalize_style_key("")` returns `""`, no match in `caller_model_map` → falls through to `caller_model_pool[0]` (grok-4.1-fast) for everyone.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py:6848-6875` (`get_caller_model`)
|
||||
|
||||
**Step 1: Fix `get_caller_model` to defer assignment when style is unknown**
|
||||
|
||||
Replace `get_caller_model` (lines 6848-6875):
|
||||
|
||||
```python
|
||||
def get_caller_model(self, caller_key: str) -> str | None:
|
||||
"""Get the assigned model for a caller, or assign one based on strategy.
|
||||
Returns None to use default category routing."""
|
||||
if self.caller_model_strategy == "single":
|
||||
return None # use default category_models["caller_dialog"]
|
||||
|
||||
# Already assigned — keep consistent for the whole call
|
||||
if caller_key in self.caller_models:
|
||||
return self.caller_models[caller_key]
|
||||
|
||||
model = None
|
||||
if self.caller_model_strategy == "cycle":
|
||||
if self.caller_model_pool:
|
||||
model = self.caller_model_pool[self._caller_model_cycle_idx % len(self.caller_model_pool)]
|
||||
self._caller_model_cycle_idx += 1
|
||||
elif self.caller_model_strategy == "style_matched":
|
||||
raw_style = self.caller_styles.get(caller_key, "")
|
||||
style_key = _normalize_style_key(raw_style) if raw_style else ""
|
||||
if style_key:
|
||||
model = self.caller_model_map.get(style_key)
|
||||
if not model:
|
||||
# Style not yet populated or no mapping — use fallback, not pool[0]
|
||||
model = self.caller_model_fallback
|
||||
|
||||
if model:
|
||||
self.caller_models[caller_key] = model
|
||||
caller_name = CALLER_BASES.get(caller_key, {}).get("name", caller_key)
|
||||
style_info = self.caller_styles.get(caller_key, "unknown")
|
||||
print(f"[CallerModel] Assigned {model} to {caller_name} (style={_normalize_style_key(style_info) if style_info else 'none'}, strategy={self.caller_model_strategy})")
|
||||
|
||||
return model
|
||||
```
|
||||
|
||||
The key change: when `style_key` is empty (style not yet populated) or has no mapping, use `caller_model_fallback` (claude-sonnet-4.6) instead of `caller_model_pool[0]` (grok-4.1-fast). Claude Sonnet is a much safer default — empathetic, verbose, coherent.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/main.py
|
||||
git commit -m "Fix style-to-model race condition — use fallback instead of pool[0]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Fix pronunciation fixes producing literal phonetic text
|
||||
|
||||
**Problem:** `_PRONUNCIATION_FIXES` replaces "Animas" with "Ah nee mahs" as literal text. TTS reads each word separately ("Ah" "nee" "mahs") instead of blending into the intended pronunciation.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py:9141-9152` (`_PRONUNCIATION_FIXES`)
|
||||
- Modify: `backend/main.py:9212-9216` (`_apply_pronunciation_fixes`)
|
||||
|
||||
**Step 1: Remove pronunciation fixes that sound worse than originals**
|
||||
|
||||
The Inworld TTS actually handles most proper nouns fine. The fixes were added speculatively and cause more harm than good. Remove the place names that TTS can handle, keep only abbreviations:
|
||||
|
||||
Replace `_PRONUNCIATION_FIXES` (lines 9141-9152):
|
||||
|
||||
```python
|
||||
_PRONUNCIATION_FIXES = {
|
||||
"Castopod": "Casto pod",
|
||||
"vs": "versus",
|
||||
"govt": "government",
|
||||
"dept": "department",
|
||||
}
|
||||
```
|
||||
|
||||
Remove `Lordsburg`, `Hachita`, `Deming`, `Bootheel`, `Animas`, and `Rodeo`. These place names either sound fine through TTS or the phonetic replacement sounds worse.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/main.py
|
||||
git commit -m "Remove pronunciation fixes that produce worse TTS output"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add age-awareness to voice matching
|
||||
|
||||
**Problem:** Brandy (55 years old) got "Kayla" (young-sounding voice). `_match_voices_to_styles()` scores on style dimensions (weight, energy, warmth, age_feel) but the `age_feel` preference comes from the communication style, not the character's actual age. A "confrontational" style prefers `age_feel: None` (no preference), so a 55-year-old can get a young voice.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py:6106-6156` (`_match_voices_to_styles`)
|
||||
|
||||
**Step 1: Add character age to voice scoring**
|
||||
|
||||
In `_match_voices_to_styles`, after getting the style preferences, override `age_feel` based on the caller's actual age from their background:
|
||||
|
||||
```python
|
||||
def _match_voices_to_styles():
|
||||
"""Re-assign voices to match caller communication styles after backgrounds are generated."""
|
||||
from .services.tts import VOICE_PROFILES
|
||||
|
||||
for key, base in CALLER_BASES.items():
|
||||
if base.get("returning"):
|
||||
continue
|
||||
|
||||
style_raw = session.caller_styles.get(key, "")
|
||||
if not style_raw:
|
||||
continue
|
||||
|
||||
style_key = _normalize_style_key(style_raw)
|
||||
prefs = STYLE_VOICE_PREFERENCES.get(style_key)
|
||||
if not prefs:
|
||||
continue
|
||||
|
||||
# Copy prefs so we don't mutate the shared dict
|
||||
prefs = dict(prefs)
|
||||
|
||||
# Override age_feel based on character's actual age
|
||||
bg = session.caller_backgrounds.get(key)
|
||||
if isinstance(bg, CallerBackground) and bg.age:
|
||||
if bg.age >= 50:
|
||||
prefs["age_feel"] = "mature"
|
||||
elif bg.age >= 35:
|
||||
prefs["age_feel"] = "middle"
|
||||
elif bg.age < 25:
|
||||
prefs["age_feel"] = "young"
|
||||
# 25-34: keep style preference or None
|
||||
|
||||
gender = base["gender"]
|
||||
pool = INWORLD_MALE_VOICES if gender == "male" else INWORLD_FEMALE_VOICES
|
||||
voice_pool = [v for v in pool if v not in BLACKLISTED_VOICES]
|
||||
|
||||
scored = []
|
||||
for voice_name in voice_pool:
|
||||
profile = VOICE_PROFILES.get(voice_name)
|
||||
if not profile:
|
||||
scored.append((voice_name, 0))
|
||||
continue
|
||||
score = 0
|
||||
for dim in ["weight", "energy", "warmth", "age_feel"]:
|
||||
pref_val = prefs.get(dim)
|
||||
if pref_val and profile.get(dim) == pref_val:
|
||||
score += 1
|
||||
scored.append((voice_name, score))
|
||||
|
||||
if scored:
|
||||
names = [s[0] for s in scored]
|
||||
weights = [max(1, s[1] * 3) for s in scored]
|
||||
chosen = random.choices(names, weights=weights, k=1)[0]
|
||||
|
||||
used_voices = {CALLER_BASES[k]["voice"] for k in CALLER_BASES if k != key and "voice" in CALLER_BASES[k]}
|
||||
if chosen in used_voices:
|
||||
alternatives = [(n, w) for n, w in zip(names, weights) if n not in used_voices]
|
||||
if alternatives:
|
||||
alt_names, alt_weights = zip(*alternatives)
|
||||
chosen = random.choices(alt_names, weights=alt_weights, k=1)[0]
|
||||
|
||||
old_voice = base.get("voice", "")
|
||||
base["voice"] = chosen
|
||||
if old_voice != chosen:
|
||||
print(f"[VoiceMatch] {base.get('name', key)}: {old_voice} → {chosen} (style: {style_key}, age: {bg.age if isinstance(bg, CallerBackground) else '?'})")
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/main.py
|
||||
git commit -m "Add age-awareness to voice matching — 55yo won't get young voices"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Raise minimum response word count
|
||||
|
||||
**Problem:** `MIN_RESPONSE_WORDS = 30` lets through fragmented, telegram-style responses that are technically 30+ words but terrible radio.
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py:8844` (`MIN_RESPONSE_WORDS`)
|
||||
|
||||
**Step 1: Raise the minimum**
|
||||
|
||||
Change line 8844:
|
||||
|
||||
```python
|
||||
MIN_RESPONSE_WORDS = 50 # Retry if response is shorter than this
|
||||
```
|
||||
|
||||
50 words is roughly 2-3 spoken sentences — enough to be a coherent radio response without being overly demanding for short-form exchanges.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/main.py
|
||||
git commit -m "Raise MIN_RESPONSE_WORDS from 30 to 50"
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
# Cost Dashboard Design
|
||||
|
||||
## Overview
|
||||
|
||||
A dedicated cost analytics dashboard at `/costs` that visualizes LLM and TTS spending across sessions with time-range filtering, model/category breakdowns, and drill-down into individual sessions and calls.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Route:** `/costs` served by FastAPI, standalone page matching the control panel's dark theme
|
||||
- **Database:** SQLite (`data/costs.db`) for cross-session aggregation
|
||||
- **Charts:** Chart.js (vanilla JS, no framework)
|
||||
- **Data migration:** On first run, import existing `data/cost_reports/*.json` into SQLite
|
||||
- **Dual write:** `cost_tracker.py` continues writing JSON reports (backward compat) and also writes to SQLite going forward
|
||||
|
||||
## Database Schema
|
||||
|
||||
### `sessions`
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | TEXT PK | Session ID |
|
||||
| started_at | TIMESTAMP | Session start time |
|
||||
| total_cost | REAL | Total cost USD |
|
||||
| llm_cost | REAL | LLM cost USD |
|
||||
| tts_cost | REAL | TTS cost USD |
|
||||
| total_llm_calls | INTEGER | Number of LLM calls |
|
||||
| total_tts_calls | INTEGER | Number of TTS calls |
|
||||
| total_tokens | INTEGER | Total tokens used |
|
||||
| prompt_tokens | INTEGER | Prompt tokens |
|
||||
| completion_tokens | INTEGER | Completion tokens |
|
||||
|
||||
### `llm_calls`
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | INTEGER PK | Auto-increment |
|
||||
| session_id | TEXT FK | References sessions.id |
|
||||
| timestamp | TIMESTAMP | Call time |
|
||||
| category | TEXT | background_gen, caller_dialog, devon_monitor, etc. |
|
||||
| model | TEXT | Model identifier |
|
||||
| prompt_tokens | INTEGER | Prompt tokens |
|
||||
| completion_tokens | INTEGER | Completion tokens |
|
||||
| cost | REAL | Cost USD |
|
||||
| caller_name | TEXT | Caller name (nullable) |
|
||||
| latency_ms | REAL | Response latency |
|
||||
|
||||
### `tts_calls`
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | INTEGER PK | Auto-increment |
|
||||
| session_id | TEXT FK | References sessions.id |
|
||||
| timestamp | TIMESTAMP | Call time |
|
||||
| provider | TEXT | Inworld, ElevenLabs, etc. |
|
||||
| voice | TEXT | Voice ID |
|
||||
| char_count | INTEGER | Characters synthesized |
|
||||
| cost | REAL | Cost USD |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All new endpoints under `/api/costs/`:
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /api/costs/summary?period=today\|week\|month\|all` | Aggregated totals: spend, LLM/TTS split, call count, tokens, avg cost/session, % change vs previous period |
|
||||
| `GET /api/costs/timeline?period=week\|month\|all&group_by=session\|day` | Time-series for line chart (cost over time) |
|
||||
| `GET /api/costs/models?period=week\|month\|all` | Per-model breakdown for pie/bar charts |
|
||||
| `GET /api/costs/categories?period=week\|month\|all` | Per-category breakdown (background_gen, caller_dialog, etc.) |
|
||||
| `GET /api/costs/sessions?period=week\|month\|all` | Session list with totals, sortable |
|
||||
| `GET /api/costs/session/{id}` | Single session detail: per-caller costs, expensive calls, recommendations |
|
||||
| `GET /api/costs/expensive?period=week\|month\|all&limit=10` | Top N most expensive individual calls |
|
||||
|
||||
Existing `/api/costs` (live session) endpoint remains unchanged.
|
||||
|
||||
## Dashboard Layout
|
||||
|
||||
### Header
|
||||
Time range selector tabs: Today / This Week / This Month / All Time. Clicking any tab refreshes all charts.
|
||||
|
||||
### Row 1 — Summary Cards (4 across)
|
||||
- **Total Spend** with % change vs previous period
|
||||
- **LLM / TTS Split** showing both values
|
||||
- **Total Sessions** in period
|
||||
- **Avg Cost Per Session**
|
||||
|
||||
### Row 2 — Two charts side by side
|
||||
- **Left: Cost Over Time** — line chart, x-axis sessions or days, y-axis dollars. LLM and TTS as separate lines.
|
||||
- **Right: Cost by Model** — doughnut chart with legend showing dollar amounts.
|
||||
|
||||
### Row 3 — Two charts side by side
|
||||
- **Left: Cost by Category** — horizontal bar chart (background_gen, caller_dialog, devon_monitor, etc.)
|
||||
- **Right: Cost Per Session Trend** — bar chart, each bar a session, colored above/below average.
|
||||
|
||||
### Row 4 — Tables
|
||||
- **Most Expensive Calls** — top 10 LLM calls (model, category, caller, tokens, cost, timestamp)
|
||||
- **Session List** — all sessions, sortable by date/cost, clickable for detail view.
|
||||
|
||||
### Session Detail View (click-through)
|
||||
- Per-caller cost breakdown
|
||||
- Call-by-call timeline
|
||||
- Recommendations from existing `_generate_recommendations()` logic
|
||||
|
||||
## Visual Style
|
||||
Matches the control panel's existing dark theme. Same fonts, colors, card styles.
|
||||
|
||||
## What's NOT in v1
|
||||
- SignalWire cost tracking (future addition)
|
||||
- Real-time WebSocket updates (polling on page load is sufficient)
|
||||
- Cost alerts/budgets
|
||||
- Export to CSV
|
||||
@@ -0,0 +1,810 @@
|
||||
# Cost Dashboard Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build a cost analytics dashboard at `/costs` that visualizes LLM and TTS spending across sessions with time-range filtering, charts, and drill-down.
|
||||
|
||||
**Architecture:** SQLite database (`data/costs.db`) stores all cost records. Existing JSON reports are imported on first run. `cost_tracker.py` dual-writes to both JSON and SQLite. New API endpoints serve aggregated data. Standalone HTML page with Chart.js renders the dashboard.
|
||||
|
||||
**Tech Stack:** Python/FastAPI, SQLite, Chart.js (CDN), vanilla JS, CSS custom properties matching existing dark theme.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: SQLite Database Module
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/services/cost_db.py`
|
||||
|
||||
**Step 1: Create the database module with schema**
|
||||
|
||||
Create `backend/services/cost_db.py` with:
|
||||
- `init_db(db_path)` — creates tables if not exist, returns connection
|
||||
- `import_json_reports(db_path, reports_dir)` — scans `data/cost_reports/*.json`, imports any sessions not already in the DB
|
||||
- `get_db()` — returns a connection to `data/costs.db`, calls `init_db` on first use
|
||||
|
||||
```python
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = Path(__file__).parent.parent.parent / "data" / "costs.db"
|
||||
REPORTS_DIR = Path(__file__).parent.parent.parent / "data" / "cost_reports"
|
||||
|
||||
_connection = None
|
||||
|
||||
SCHEMA = """
|
||||
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,
|
||||
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,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tts_calls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
timestamp TIMESTAMP,
|
||||
provider TEXT,
|
||||
voice TEXT,
|
||||
char_count INTEGER DEFAULT 0,
|
||||
cost REAL DEFAULT 0,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_session ON llm_calls(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_timestamp ON llm_calls(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_category ON llm_calls(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_model ON llm_calls(model);
|
||||
CREATE INDEX IF NOT EXISTS idx_tts_session ON tts_calls(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tts_timestamp ON tts_calls(timestamp);
|
||||
"""
|
||||
|
||||
|
||||
def get_db():
|
||||
global _connection
|
||||
if _connection is None:
|
||||
_connection = sqlite3.connect(str(DB_PATH), check_same_thread=False)
|
||||
_connection.row_factory = sqlite3.Row
|
||||
_connection.executescript(SCHEMA)
|
||||
import_json_reports()
|
||||
return _connection
|
||||
|
||||
|
||||
def import_json_reports():
|
||||
db = _connection
|
||||
if not REPORTS_DIR.exists():
|
||||
return
|
||||
existing = {row[0] for row in db.execute("SELECT id FROM sessions").fetchall()}
|
||||
for f in sorted(REPORTS_DIR.glob("*.json")):
|
||||
try:
|
||||
data = json.loads(f.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
session_id = data.get("session_id", f.stem)
|
||||
if session_id in existing:
|
||||
continue
|
||||
saved_at = data.get("saved_at")
|
||||
started_at = datetime.fromtimestamp(saved_at).isoformat() if saved_at else None
|
||||
db.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 rec in data.get("raw_llm_records", []):
|
||||
db.execute(
|
||||
"INSERT INTO llm_calls (session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(session_id, rec.get("timestamp"), rec.get("category"), rec.get("model"), rec.get("prompt_tokens", 0), rec.get("completion_tokens", 0), rec.get("cost_usd", 0), rec.get("caller_name"), rec.get("latency_ms", 0)),
|
||||
)
|
||||
for rec in data.get("raw_tts_records", []):
|
||||
db.execute(
|
||||
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(session_id, rec.get("timestamp"), rec.get("provider"), rec.get("voice"), rec.get("char_count", 0), rec.get("cost_usd", 0)),
|
||||
)
|
||||
existing.add(session_id)
|
||||
db.commit()
|
||||
|
||||
|
||||
def record_llm_call(session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms):
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO llm_calls (session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def record_tts_call(session_id, timestamp, provider, voice, char_count, cost):
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(session_id, timestamp, provider, voice, char_count, cost),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def ensure_session(session_id, started_at=None):
|
||||
db = get_db()
|
||||
existing = db.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||
if not existing:
|
||||
db.execute(
|
||||
"INSERT INTO sessions (id, started_at) VALUES (?, ?)",
|
||||
(session_id, started_at or datetime.now().isoformat()),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def update_session_totals(session_id):
|
||||
db = get_db()
|
||||
llm = db.execute(
|
||||
"SELECT COUNT(*) as calls, 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 = db.execute(
|
||||
"SELECT COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM tts_calls WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
total_tokens = llm["pt"] + llm["ct"]
|
||||
total_cost = llm["cost"] + tts["cost"]
|
||||
db.execute(
|
||||
"UPDATE sessions SET total_cost=?, llm_cost=?, tts_cost=?, total_llm_calls=?, total_tts_calls=?, total_tokens=?, prompt_tokens=?, completion_tokens=? WHERE id=?",
|
||||
(total_cost, llm["cost"], tts["cost"], llm["calls"], tts["calls"], total_tokens, llm["pt"], llm["ct"], session_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _period_filter(period):
|
||||
now = datetime.now()
|
||||
if period == "today":
|
||||
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
elif period == "week":
|
||||
start = now - timedelta(days=now.weekday())
|
||||
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
elif period == "month":
|
||||
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
else:
|
||||
return None
|
||||
return start.isoformat()
|
||||
|
||||
|
||||
def get_summary(period="all"):
|
||||
db = get_db()
|
||||
start = _period_filter(period)
|
||||
if start:
|
||||
where = "WHERE started_at >= ?"
|
||||
params = (start,)
|
||||
prev_start = _get_previous_period_start(period)
|
||||
prev_where = "WHERE started_at >= ? AND started_at < ?"
|
||||
prev_params = (prev_start, start)
|
||||
else:
|
||||
where = ""
|
||||
params = ()
|
||||
prev_where = None
|
||||
prev_params = ()
|
||||
|
||||
row = db.execute(
|
||||
f"SELECT COUNT(*) as sessions, COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM(llm_cost), 0) as llm_cost, COALESCE(SUM(tts_cost), 0) as tts_cost, COALESCE(SUM(total_llm_calls), 0) as total_calls, COALESCE(SUM(total_tokens), 0) as total_tokens FROM sessions {where}",
|
||||
params,
|
||||
).fetchone()
|
||||
|
||||
result = {
|
||||
"total_cost": round(row["total_cost"], 4),
|
||||
"llm_cost": round(row["llm_cost"], 4),
|
||||
"tts_cost": round(row["tts_cost"], 4),
|
||||
"sessions": row["sessions"],
|
||||
"total_calls": row["total_calls"],
|
||||
"total_tokens": row["total_tokens"],
|
||||
"avg_cost_per_session": round(row["total_cost"] / max(row["sessions"], 1), 4),
|
||||
}
|
||||
|
||||
if prev_where:
|
||||
prev = db.execute(
|
||||
f"SELECT COALESCE(SUM(total_cost), 0) as total_cost FROM sessions {prev_where}",
|
||||
prev_params,
|
||||
).fetchone()
|
||||
prev_cost = prev["total_cost"]
|
||||
if prev_cost > 0:
|
||||
result["pct_change"] = round((row["total_cost"] - prev_cost) / prev_cost * 100, 1)
|
||||
else:
|
||||
result["pct_change"] = None
|
||||
else:
|
||||
result["pct_change"] = None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _get_previous_period_start(period):
|
||||
now = datetime.now()
|
||||
if period == "today":
|
||||
return (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||
elif period == "week":
|
||||
start_of_week = now - timedelta(days=now.weekday())
|
||||
return (start_of_week - timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||
elif period == "month":
|
||||
first_of_month = now.replace(day=1)
|
||||
prev_month = first_of_month - timedelta(days=1)
|
||||
return prev_month.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||
return None
|
||||
|
||||
|
||||
def get_timeline(period="all", group_by="session"):
|
||||
db = get_db()
|
||||
start = _period_filter(period)
|
||||
if group_by == "day":
|
||||
if start:
|
||||
rows = db.execute(
|
||||
"SELECT DATE(started_at) as date, SUM(llm_cost) as llm_cost, SUM(tts_cost) as tts_cost, SUM(total_cost) as total_cost, COUNT(*) as sessions FROM sessions WHERE started_at >= ? GROUP BY DATE(started_at) ORDER BY date",
|
||||
(start,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = db.execute(
|
||||
"SELECT DATE(started_at) as date, SUM(llm_cost) as llm_cost, SUM(tts_cost) as tts_cost, SUM(total_cost) as total_cost, COUNT(*) as sessions FROM sessions GROUP BY DATE(started_at) ORDER BY date"
|
||||
).fetchall()
|
||||
else:
|
||||
if start:
|
||||
rows = db.execute(
|
||||
"SELECT id, started_at, llm_cost, tts_cost, total_cost FROM sessions WHERE started_at >= ? ORDER BY started_at",
|
||||
(start,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = db.execute(
|
||||
"SELECT id, started_at, llm_cost, tts_cost, total_cost FROM sessions ORDER BY started_at"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_models(period="all"):
|
||||
db = get_db()
|
||||
start = _period_filter(period)
|
||||
if start:
|
||||
rows = db.execute(
|
||||
"SELECT l.model, COUNT(*) as calls, COALESCE(SUM(l.cost), 0) as cost, COALESCE(SUM(l.prompt_tokens), 0) as prompt_tokens, COALESCE(SUM(l.completion_tokens), 0) 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 = db.execute(
|
||||
"SELECT model, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens), 0) as prompt_tokens, COALESCE(SUM(completion_tokens), 0) as completion_tokens FROM llm_calls GROUP BY model ORDER BY cost DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_categories(period="all"):
|
||||
db = get_db()
|
||||
start = _period_filter(period)
|
||||
if start:
|
||||
rows = db.execute(
|
||||
"SELECT l.category, COUNT(*) as calls, COALESCE(SUM(l.cost), 0) as cost, COALESCE(SUM(l.prompt_tokens + l.completion_tokens), 0) 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 = db.execute(
|
||||
"SELECT category, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens + completion_tokens), 0) as tokens FROM llm_calls GROUP BY category ORDER BY cost DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_sessions_list(period="all"):
|
||||
db = get_db()
|
||||
start = _period_filter(period)
|
||||
if start:
|
||||
rows = db.execute(
|
||||
"SELECT id, started_at, total_cost, llm_cost, tts_cost, total_llm_calls, total_tokens FROM sessions WHERE started_at >= ? ORDER BY started_at DESC",
|
||||
(start,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = db.execute(
|
||||
"SELECT id, started_at, total_cost, llm_cost, tts_cost, total_llm_calls, total_tokens FROM sessions ORDER BY started_at DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_session_detail(session_id):
|
||||
db = get_db()
|
||||
session = db.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||
if not session:
|
||||
return None
|
||||
by_caller = db.execute(
|
||||
"SELECT caller_name, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens + completion_tokens), 0) as tokens FROM llm_calls WHERE session_id = ? AND caller_name != '' GROUP BY caller_name ORDER BY cost DESC",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
by_model = db.execute(
|
||||
"SELECT model, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM llm_calls WHERE session_id = ? GROUP BY model ORDER BY cost DESC",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
by_category = db.execute(
|
||||
"SELECT category, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM llm_calls WHERE session_id = ? GROUP BY category ORDER BY cost DESC",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
expensive = db.execute(
|
||||
"SELECT category, model, caller_name, cost, prompt_tokens, completion_tokens, latency_ms, timestamp FROM llm_calls WHERE session_id = ? ORDER BY cost DESC LIMIT 10",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
tts = db.execute(
|
||||
"SELECT provider, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(char_count), 0) as chars FROM tts_calls WHERE session_id = ? GROUP BY provider ORDER BY cost DESC",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
return {
|
||||
"session": dict(session),
|
||||
"by_caller": [dict(r) for r in by_caller],
|
||||
"by_model": [dict(r) for r in by_model],
|
||||
"by_category": [dict(r) for r in by_category],
|
||||
"expensive_calls": [dict(r) for r in expensive],
|
||||
"tts_by_provider": [dict(r) for r in tts],
|
||||
}
|
||||
|
||||
|
||||
def get_expensive_calls(period="all", limit=10):
|
||||
db = get_db()
|
||||
start = _period_filter(period)
|
||||
if start:
|
||||
rows = db.execute(
|
||||
"SELECT l.category, l.model, l.caller_name, l.cost, l.prompt_tokens, l.completion_tokens, l.latency_ms, l.timestamp, l.session_id 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 = db.execute(
|
||||
"SELECT category, model, caller_name, cost, prompt_tokens, completion_tokens, latency_ms, timestamp, session_id FROM llm_calls ORDER BY cost DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
```
|
||||
|
||||
**Step 2: Verify the module loads**
|
||||
|
||||
Run: `cd /Users/lukemacneil/code/ai-podcast && python -c "from backend.services.cost_db import get_db; db = get_db(); print('OK, sessions:', db.execute('SELECT COUNT(*) FROM sessions').fetchone()[0])"`
|
||||
|
||||
Expected: `OK, sessions: 18` (or however many JSON reports exist)
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/services/cost_db.py
|
||||
git commit -m "Add SQLite cost database module with JSON import"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Integrate SQLite Writes into Cost Tracker
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/services/cost_tracker.py`
|
||||
|
||||
**Step 1: Add dual-write to `record_llm_call`**
|
||||
|
||||
At the top of `cost_tracker.py`, add the import:
|
||||
```python
|
||||
from backend.services import cost_db
|
||||
```
|
||||
|
||||
In `record_llm_call()` (around line 148, after appending to `self.llm_records`), add:
|
||||
```python
|
||||
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
|
||||
```
|
||||
|
||||
**Step 2: Add dual-write to `record_tts_call`**
|
||||
|
||||
In `record_tts_call()` (around line 166, after appending to `self.tts_records`), add:
|
||||
```python
|
||||
try:
|
||||
cost_db.record_tts_call(
|
||||
self._session_id, record.timestamp, record.provider, record.voice,
|
||||
record.char_count, record.cost_usd,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
```
|
||||
|
||||
**Step 3: Add session_id tracking to `__init__`**
|
||||
|
||||
Add `self._session_id` to the constructor. Generate it from timestamp:
|
||||
```python
|
||||
self._session_id = f"session-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}"
|
||||
```
|
||||
|
||||
**Step 4: Update session totals on `save()`**
|
||||
|
||||
In the `save()` method, after writing the JSON file, add:
|
||||
```python
|
||||
try:
|
||||
cost_db.update_session_totals(self._session_id)
|
||||
except Exception:
|
||||
pass
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/services/cost_tracker.py
|
||||
git commit -m "Dual-write cost records to SQLite"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: API Endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py` (add routes near existing `/api/costs` endpoints around line 10299)
|
||||
|
||||
**Step 1: Add new cost dashboard endpoints**
|
||||
|
||||
Add these routes near the existing cost endpoints (around line 10308):
|
||||
|
||||
```python
|
||||
from backend.services import cost_db
|
||||
|
||||
@app.get("/api/costs/summary")
|
||||
async def get_cost_summary(period: str = "all"):
|
||||
return cost_db.get_summary(period)
|
||||
|
||||
@app.get("/api/costs/timeline")
|
||||
async def get_cost_timeline(period: str = "all", group_by: str = "session"):
|
||||
return cost_db.get_timeline(period, group_by)
|
||||
|
||||
@app.get("/api/costs/models")
|
||||
async def get_cost_models(period: str = "all"):
|
||||
return cost_db.get_models(period)
|
||||
|
||||
@app.get("/api/costs/categories")
|
||||
async def get_cost_categories(period: str = "all"):
|
||||
return cost_db.get_categories(period)
|
||||
|
||||
@app.get("/api/costs/sessions")
|
||||
async def get_cost_sessions(period: str = "all"):
|
||||
return cost_db.get_sessions_list(period)
|
||||
|
||||
@app.get("/api/costs/session/{session_id}")
|
||||
async def get_cost_session_detail(session_id: str):
|
||||
detail = cost_db.get_session_detail(session_id)
|
||||
if not detail:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(status_code=404, content={"error": "Session not found"})
|
||||
return detail
|
||||
|
||||
@app.get("/api/costs/expensive")
|
||||
async def get_expensive_calls(period: str = "all", limit: int = 10):
|
||||
return cost_db.get_expensive_calls(period, limit)
|
||||
```
|
||||
|
||||
**Step 2: Add route to serve the costs page**
|
||||
|
||||
Near the existing root route (around line 7654), add:
|
||||
|
||||
```python
|
||||
@app.get("/costs")
|
||||
async def costs_page():
|
||||
return FileResponse(frontend_dir / "costs.html")
|
||||
```
|
||||
|
||||
**Step 3: Verify endpoints respond**
|
||||
|
||||
Run the server: `python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000`
|
||||
|
||||
Test: `curl -s http://localhost:8000/api/costs/summary?period=all | python -m json.tool`
|
||||
|
||||
Expected: JSON with total_cost, llm_cost, tts_cost, sessions, etc.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/main.py
|
||||
git commit -m "Add cost dashboard API endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Dashboard HTML
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/costs.html`
|
||||
|
||||
**Step 1: Create the dashboard page**
|
||||
|
||||
Create `frontend/costs.html` — standalone HTML page with:
|
||||
- Chart.js from CDN (`https://cdn.jsdelivr.net/npm/chart.js`)
|
||||
- Link to `css/style.css` (shared theme) and `css/costs.css` (dashboard-specific)
|
||||
- Script tag for `js/costs.js`
|
||||
- Structure: header with time range tabs, 4 summary cards, 4 chart containers, 2 tables
|
||||
- Use the same CSS variables as the control panel (`--bg`, `--bg-light`, `--accent`, `--text`, etc.)
|
||||
|
||||
Layout structure:
|
||||
```html
|
||||
<!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="tables-section">
|
||||
<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>
|
||||
<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>
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/costs.html
|
||||
git commit -m "Add cost dashboard HTML page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Dashboard CSS
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/css/costs.css`
|
||||
|
||||
**Step 1: Create dashboard-specific styles**
|
||||
|
||||
Create `frontend/css/costs.css` using the existing CSS variables from `style.css`. Key styles:
|
||||
- `.costs-header` — flex row with title, back link, and period tabs
|
||||
- `.period-tabs` / `.period-tab` — tab buttons, active state uses `--accent`
|
||||
- `.summary-cards` — 4-column grid
|
||||
- `.card` — `background: var(--bg-light)`, border, rounded corners matching `--radius`
|
||||
- `.card-value` — large font, `color: var(--text)`
|
||||
- `.card-change` — small text, green for negative (saving), red for positive (increase)
|
||||
- `.chart-row` — 2-column grid
|
||||
- `.chart-container` — padded card with canvas
|
||||
- `.table-container` — styled tables matching dark theme
|
||||
- `.session-detail` — full-width detail view
|
||||
- Responsive: single column below 768px
|
||||
|
||||
Use `var(--bg)`, `var(--bg-light)`, `var(--accent)`, `var(--text)`, `var(--text-muted)`, `var(--radius)`, `var(--radius-sm)`, `var(--transition)` throughout.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/css/costs.css
|
||||
git commit -m "Add cost dashboard CSS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Dashboard JavaScript
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/js/costs.js`
|
||||
|
||||
**Step 1: Create the dashboard JS**
|
||||
|
||||
Create `frontend/js/costs.js` with:
|
||||
|
||||
**State:**
|
||||
```javascript
|
||||
let currentPeriod = 'all';
|
||||
let charts = {}; // store Chart.js instances for destroy/recreate
|
||||
```
|
||||
|
||||
**Init:**
|
||||
```javascript
|
||||
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();
|
||||
});
|
||||
```
|
||||
|
||||
**Data loading — `loadDashboard()`:**
|
||||
- Fetch all endpoints in parallel: summary, timeline, models, categories, sessions, expensive
|
||||
- Call render functions for each section
|
||||
|
||||
**Render functions:**
|
||||
- `renderSummary(data)` — populate the 4 summary cards, format as `$X.XX`, show % change with color
|
||||
- `renderTimeline(data)` — Chart.js line chart with LLM and TTS as separate datasets, `--accent` and `--devon` colors
|
||||
- `renderModels(data)` — Chart.js doughnut chart with model name labels
|
||||
- `renderCategories(data)` — Chart.js horizontal bar chart
|
||||
- `renderSessionBars(data)` — Chart.js bar chart, bars colored based on above/below average
|
||||
- `renderExpensiveTable(data)` — populate table rows
|
||||
- `renderSessionsTable(data)` — populate table rows with click handler to show detail
|
||||
- `showSessionDetail(sessionId)` — fetch `/api/costs/session/{id}`, show detail section, populate tables
|
||||
- `closeDetail()` — hide detail section
|
||||
|
||||
**Chart.js config notes:**
|
||||
- Use dark theme: grid lines `rgba(245, 240, 229, 0.1)` (--text at 10%), tick color `var(--text-muted)`
|
||||
- Chart colors palette: `#e8791d` (accent), `#c4944a` (devon), `#5a8a3c` (green), `#cc2222` (red), `#4a8ac4` (blue), `#8a5ac4` (purple), `#c4845a` (tan)
|
||||
- Tooltips: dark background, light text
|
||||
- Destroy existing chart instance before creating new one (prevents memory leaks on period switch)
|
||||
|
||||
**Utility:**
|
||||
- `formatCost(n)` — returns `$X.XX` or `$X.XXXX` for small amounts
|
||||
- `formatDate(iso)` — returns readable date
|
||||
- `shortenModel(name)` — strip provider prefix from model names for chart labels
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/js/costs.js
|
||||
git commit -m "Add cost dashboard JavaScript with Chart.js"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Integration Test
|
||||
|
||||
**Step 1: Manual verification checklist**
|
||||
|
||||
Start the server and navigate to `http://localhost:8000/costs`:
|
||||
|
||||
1. Page loads with dark theme, no console errors
|
||||
2. All Time tab is active by default, shows all 18 sessions
|
||||
3. Summary cards show total spend, LLM/TTS split, session count, avg cost
|
||||
4. Cost Over Time line chart renders with data points
|
||||
5. Cost by Model doughnut shows model breakdown
|
||||
6. Cost by Category bar chart shows category breakdown
|
||||
7. Cost Per Session bars render with above/below-average coloring
|
||||
8. Most Expensive Calls table populated
|
||||
9. Sessions table populated, rows clickable
|
||||
10. Click a session row → detail view shows with per-caller, per-model, per-category tables
|
||||
11. Click "Back" → returns to main dashboard
|
||||
12. Switch to "This Week" tab → all charts update (may show fewer/no data)
|
||||
13. Switch to "This Month" → charts update with March data
|
||||
14. Switch back to "All Time" → full data restored
|
||||
|
||||
**Step 2: Commit all remaining changes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "Cost dashboard complete — SQLite backend, Chart.js frontend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
|
||||
| Action | File |
|
||||
|--------|------|
|
||||
| Create | `backend/services/cost_db.py` |
|
||||
| Modify | `backend/services/cost_tracker.py` |
|
||||
| Modify | `backend/main.py` |
|
||||
| Create | `frontend/costs.html` |
|
||||
| Create | `frontend/css/costs.css` |
|
||||
| Create | `frontend/js/costs.js` |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Chart.js loaded from CDN (no npm install needed)
|
||||
- SQLite is stdlib (no pip install needed)
|
||||
- No new Python packages required
|
||||
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,78 @@
|
||||
# Relocate the Show to Alpine, TX / Big Bend
|
||||
|
||||
**Date:** 2026-05-31
|
||||
**Status:** Approved, implementing
|
||||
|
||||
## Goal
|
||||
|
||||
Luke moved to Alpine, Texas. Move the show's world from southern New Mexico
|
||||
(Deming/Lordsburg) to the Big Bend region of far West Texas. Callers should come
|
||||
from and be familiar with Alpine, Marfa, Marathon, Terlingua, Fort Stockton, and
|
||||
the Big Bend area. Devon moves with the show. Silas gets an arc beat that
|
||||
relocates The Wellspring to Terlingua.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Caller generation — `backend/services/caller_gen.py`
|
||||
|
||||
- `BATCH_SYSTEM_PROMPT` opener: "…radio show in New Mexico" → "…in Alpine, Texas,
|
||||
in the Big Bend country of far West Texas."
|
||||
- Add a **geographic knowledge block** (real, accurate facts) so callers ground
|
||||
themselves in real places instead of generic desert. Towns:
|
||||
- **Alpine** — hub (~6k), Brewster County seat, Sul Ross State University,
|
||||
mile-high ranching/railroad town, small arts scene.
|
||||
- **Marfa** — minimalist-art tourist town (Chinati/Donald Judd, Prada Marfa),
|
||||
the Marfa Lights, old ranching families vs. hipster influx.
|
||||
- **Marathon** — tiny, the Gage Hotel, east gateway to Big Bend NP.
|
||||
- **Terlingua** — quicksilver-mining ghost town, famous chili cookoff,
|
||||
river-rafting outfitters, off-grid desert eccentrics, the Starlight Theatre,
|
||||
near the Rio Grande / Mexico border.
|
||||
- **Fort Stockton** — oilfield/I-10 town to the north, Pecos County, Paisano
|
||||
Pete, working-class.
|
||||
- **Big Bend** — Chisos Mountains, Rio Grande, dark skies (McDonald Observatory
|
||||
near Fort Davis), remote, border proximity, Permian Basin oil money north.
|
||||
- Rule: only reference real places/facts; don't invent businesses or landmarks.
|
||||
|
||||
### 2. Whisper prompt — `backend/services/transcription.py`
|
||||
|
||||
Update locale and seed proper nouns: "…a late-night radio talk show in Alpine,
|
||||
Texas, in the Big Bend region. Callers reference Alpine, Marfa, Marathon,
|
||||
Terlingua, Fort Stockton, and Big Bend."
|
||||
|
||||
### 3. Devon — `backend/services/intern.py`
|
||||
|
||||
Moved with the show. Keep "Communications degree from NMSU" (alma mater);
|
||||
"You live in a studio in Deming" → "studio in Alpine."
|
||||
|
||||
### 4. Silas — relocate The Wellspring to Terlingua
|
||||
|
||||
- `~/code/dotfiles/silas/silas.md`: identity location Deming → Terlingua badlands;
|
||||
update `arc_state` frontmatter to the relocation; add an Arc Log entry
|
||||
(2026-05-31) — post-reckoning fresh start, moving the commune to desert outside
|
||||
Terlingua, the move straining the forty members.
|
||||
- `data/regulars.json`: update Silas's `job`, `location`, and the
|
||||
`stable_seeds.style` "commune outside Deming" → Terlingua. Leave the 6 past
|
||||
`call_history` summaries untouched — they happened in Deming; the move is the
|
||||
new development.
|
||||
|
||||
### 5. Weather/town-news enrichment — `backend/main.py` (now a real feature)
|
||||
|
||||
The block in `enrich_caller_background()` called `_get_town_from_location` /
|
||||
`_get_weather_for_town`, which never existed — silently NameError-ing. Implement:
|
||||
|
||||
- `BIG_BEND_TOWNS`: dict of town → (lat, lon) for Alpine, Marfa, Marathon,
|
||||
Terlingua, Fort Stockton, Big Bend (Chisos Basin), Fort Davis, Presidio.
|
||||
- `_get_town_from_location(text)`: scan lowercased text for a known town,
|
||||
return canonical key (handles "ft stockton"/"fort stockton").
|
||||
- `_get_weather_for_town(town)`: Open-Meteo current weather (free, no key),
|
||||
WMO weather_code → phrase, returns e.g. "58°F, clear skies".
|
||||
- Update town-news query: NM/AZ branch → `f"{town.title()} Texas"`.
|
||||
- Wire session base_ctx `weather` (was hardcoded "cool desert night") to live
|
||||
Alpine weather, graceful fallback on failure.
|
||||
|
||||
All network calls degrade gracefully (existing `asyncio.timeout` + try/except).
|
||||
|
||||
## Out of scope / left as-is
|
||||
|
||||
- Past `call_history` summaries (genuinely happened in Deming).
|
||||
- `main.py:3124` state-abbreviation map (generic, not show-locale).
|
||||
@@ -0,0 +1,56 @@
|
||||
# Caller Variety, Movable Callers, and Devon Search Fix
|
||||
|
||||
**Date:** 2026-06-02
|
||||
**Status:** Approved, implementing
|
||||
|
||||
## Problems
|
||||
|
||||
1. **Callers feel formulaic** — too many deep moral dilemmas; the roster prompt
|
||||
*enforces* it ("at least half moral dilemmas" + "NEVER generate callers who
|
||||
are just enthusiastic about a hobby").
|
||||
2. **Callers circle forever** — they don't take advice, restating the same moral
|
||||
issue until the host cuts them off. The live dialog prompt gives a `situation`
|
||||
+ `secret_want` but no mechanic to be moved, persuaded, or reach resolution.
|
||||
3. **Devon's search fails** — SearXNG at `localhost:8888` is unreachable whenever
|
||||
the laptop's Docker Desktop isn't running. Fragile.
|
||||
|
||||
## Changes
|
||||
|
||||
### A. Roster variety — `backend/services/caller_gen.py` (`BATCH_SYSTEM_PROMPT`)
|
||||
|
||||
- Remove the absolute ban on hobby/job/story callers.
|
||||
- Replace "at least half moral dilemmas" with an explicit distribution for a
|
||||
10-caller roster (dilemmas still lead):
|
||||
- **4–5** moral dilemmas / confessions / betrayals — keep the STAKES language.
|
||||
- **2–3** storytellers & enthusiasts — a wild thing that happened, a fascinating
|
||||
niche obsession or fact, a vivid slice-of-life. No deep dilemma required.
|
||||
- **1–2** believers / chaos — earnest UFO/cryptid/conspiracy callers
|
||||
(Coast-to-Coast sincerity) or a big eccentric personality on a rant.
|
||||
- Keep the anti-collision rule, "reason they HAD to call tonight," and
|
||||
"even lighter callers need energy and specificity."
|
||||
|
||||
### B. Movable callers — `backend/main.py` (`get_caller_prompt`)
|
||||
|
||||
Add a concise conversational-arc block for every caller:
|
||||
- You have a position/want, but you're a real person — if Luke makes a good
|
||||
point, genuinely react: agree, change your mind, soften, dig in, or decide.
|
||||
- Don't restate a point/dilemma you've already made; the conversation must move.
|
||||
You can reach a resolution, a decision, or an emotional turn. You are not
|
||||
required to stay stuck.
|
||||
|
||||
Stacks on the existing "don't restate facts / move the story forward" rule.
|
||||
Must keep `get_caller_prompt` output under the 3500-char test cap.
|
||||
|
||||
### C. SearXNG → NAS — `backend/config.py`, `backend/services/news.py`
|
||||
|
||||
- Deploy SearXNG to **mmgnas** as an always-on container (deploy-nas-docker).
|
||||
- Add `searxng_url` to `config.py` (env-overridable), default to the NAS URL.
|
||||
- `news.py` reads `settings.searxng_url` instead of the hardcoded constant;
|
||||
`intern.py` imports `SEARXNG_URL` from `news.py`, so Devon, headlines, and
|
||||
caller news-grounding all follow.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run test suite (note: 2 pre-existing stale `test_caller_gen.py` failures).
|
||||
- Live caller-gen batch → review the roster mix.
|
||||
- Devon end-to-end: a `web_search` against the NAS SearXNG returns real results.
|
||||
@@ -0,0 +1,500 @@
|
||||
# Crawlable Episode Pages Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Turn 57 client-rendered `?slug=` episode pages into 57 static, crawlable HTML pages at `/episode/<slug>/` containing the full transcript, so the Big Bend content the show already produces becomes indexable.
|
||||
|
||||
**Architecture:** A build-time Python generator reads the Castopod RSS feed and the existing `website/transcripts/*.txt` files, and writes one fully static page per episode into `website/episode/<slug>/index.html`. No runtime feed dependency, no JS required to see content. The Cloudflare worker gains a 301 from the legacy `?slug=` URL, and loses its user-agent gate. `publish_episode.py` calls the generator so new episodes get a page automatically.
|
||||
|
||||
**Tech Stack:** Python 3.11 (stdlib `xml.etree`, `html`, `json`, `pathlib`), pytest, Cloudflare Pages + `_worker.js`, wrangler.
|
||||
|
||||
---
|
||||
|
||||
## Why This Matters (context for the implementer)
|
||||
|
||||
Current state, verified 2026-08-14:
|
||||
|
||||
- `website/episode.html` is a JS shell. It has **zero** server-rendered episode content — it fetches `/feed`, finds the matching `<item>` client-side, then fetches `/transcripts/<slug>.txt`.
|
||||
- URLs are query params: `/episode.html?slug=episode-58-...`. 54 of the 64 sitemap entries are this shape.
|
||||
- `website/transcripts/` holds **58 `.txt` files, 3.0 MB total**, none of which appear in `sitemap.xml` and which are linked only from JavaScript.
|
||||
- `_worker.js:107-165` injects real `<title>`/OG tags — but **only when the User-Agent matches `facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|pinterest|redditbot`**. Googlebot, Bingbot, GPTBot, ClaudeBot and PerplexityBot are all absent, so search and answer engines get the generic shell.
|
||||
|
||||
That UA gate is also a **cloaking risk**: serving different HTML to crawlers than to users is against Google's guidelines. Google deprecated "dynamic rendering" as a workaround. Task 6 removes the gate rather than extending it — once pages are static, nothing needs UA sniffing.
|
||||
|
||||
### Data facts confirmed before writing this plan
|
||||
|
||||
- RSS feed: `https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml`, HTTP 200, 124 KB, **57 `<item>` elements**.
|
||||
- Each item has: `<title>`, `<link>`, `<pubDate>`, `<guid>`, `<description>` (CDATA-wrapped HTML), `<enclosure url=...>`, `<itunes:duration>` (seconds, e.g. `4770`), `<itunes:episode>` (e.g. `58`).
|
||||
- Slug is derived from `<link>`: everything after `/episodes/`, trailing slash stripped.
|
||||
- **All 57 feed episodes have a matching transcript file.** Zero missing.
|
||||
- One orphan transcript, `episode-32-tacos-taxes-and-tall-tales.txt`, has no feed item — ep 32 never finished publishing (`data/publish_state.json` shows a castopod step and nothing else). **The generator must skip orphans, not crash on them.**
|
||||
- Transcript format is plain text, blank-line separated, `SPEAKER: text` per paragraph — e.g. `LUKE: Alright, welcome back...` / `SLIM: Hey Luke, yeah thanks...`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Transcript parser
|
||||
|
||||
**Files:**
|
||||
- Create: `website_gen/transcript.py`
|
||||
- Test: `tests/test_transcript_parser.py`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from website_gen.transcript import parse_transcript
|
||||
|
||||
|
||||
def test_splits_speaker_turns():
|
||||
raw = "LUKE: Welcome back.\n\nSLIM: Hey Luke, thanks for taking my call."
|
||||
turns = parse_transcript(raw)
|
||||
assert turns == [("LUKE", "Welcome back."),
|
||||
("SLIM", "Hey Luke, thanks for taking my call.")]
|
||||
|
||||
|
||||
def test_unlabeled_paragraph_carries_previous_speaker():
|
||||
raw = "LUKE: First thing.\n\nStill Luke talking."
|
||||
assert parse_transcript(raw) == [("LUKE", "First thing."),
|
||||
("LUKE", "Still Luke talking.")]
|
||||
|
||||
|
||||
def test_ignores_blank_and_whitespace_paragraphs():
|
||||
raw = "LUKE: One.\n\n \n\nSLIM: Two."
|
||||
assert len(parse_transcript(raw)) == 2
|
||||
|
||||
|
||||
def test_speaker_name_with_spaces_is_not_treated_as_label():
|
||||
"""A colon mid-sentence must not be mistaken for a speaker label."""
|
||||
raw = "LUKE: Here's the thing: it was the alternator."
|
||||
turns = parse_transcript(raw)
|
||||
assert len(turns) == 1
|
||||
assert turns[0][0] == "LUKE"
|
||||
assert "the thing: it was" in turns[0][1]
|
||||
|
||||
|
||||
def test_empty_input_returns_empty_list():
|
||||
assert parse_transcript("") == []
|
||||
assert parse_transcript(" \n\n ") == []
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `./venv/bin/python -m pytest tests/test_transcript_parser.py -v`
|
||||
Expected: FAIL — `ModuleNotFoundError: No module named 'website_gen'`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
SPEAKER_RE = re.compile(r"^([A-Z][A-Z0-9 .'-]{0,30}):\s*(.*)$", re.DOTALL)
|
||||
|
||||
|
||||
def parse_transcript(raw: str) -> list[tuple[str, str]]:
|
||||
"""Split a transcript into (speaker, text) turns.
|
||||
|
||||
Paragraphs are blank-line separated. A paragraph that does not open with a
|
||||
SPEAKER: label is attributed to whoever spoke last, which is how the
|
||||
transcriber emits long turns that wrap.
|
||||
"""
|
||||
turns: list[tuple[str, str]] = []
|
||||
current = None
|
||||
for para in re.split(r"\n\s*\n", raw or ""):
|
||||
para = para.strip()
|
||||
if not para:
|
||||
continue
|
||||
m = SPEAKER_RE.match(para)
|
||||
if m:
|
||||
current = m.group(1).strip()
|
||||
text = m.group(2).strip()
|
||||
else:
|
||||
text = para
|
||||
if current is None:
|
||||
current = "LUKE"
|
||||
if text:
|
||||
turns.append((current, text))
|
||||
return turns
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `./venv/bin/python -m pytest tests/test_transcript_parser.py -v`
|
||||
Expected: PASS, 5 tests
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add website_gen/transcript.py tests/test_transcript_parser.py
|
||||
git commit -m "Add transcript parser for episode page generation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: RSS feed loader
|
||||
|
||||
**Files:**
|
||||
- Create: `website_gen/feed.py`
|
||||
- Test: `tests/test_feed_loader.py`
|
||||
- Fixture: `tests/fixtures/feed_sample.xml` (hand-trim two `<item>` blocks out of the live feed)
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
from website_gen.feed import parse_feed, Episode
|
||||
|
||||
|
||||
def test_parses_core_fields(feed_xml):
|
||||
eps = parse_feed(feed_xml)
|
||||
ep = next(e for e in eps if e.number == 58)
|
||||
assert ep.slug == "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
|
||||
assert ep.title.startswith("Episode 58: Rayfield's Nephew")
|
||||
assert ep.duration_seconds == 4770
|
||||
assert ep.audio_url.startswith("https://")
|
||||
|
||||
|
||||
def test_description_is_stripped_of_cdata_and_html(feed_xml):
|
||||
ep = parse_feed(feed_xml)[0]
|
||||
assert "<![CDATA[" not in ep.description
|
||||
assert "<p>" not in ep.description
|
||||
|
||||
|
||||
def test_slug_comes_from_link_and_drops_trailing_slash(feed_xml):
|
||||
for ep in parse_feed(feed_xml):
|
||||
assert not ep.slug.endswith("/")
|
||||
assert "/" not in ep.slug
|
||||
|
||||
|
||||
def test_pubdate_parses_to_iso_date(feed_xml):
|
||||
ep = next(e for e in parse_feed(feed_xml) if e.number == 58)
|
||||
assert ep.published_iso.startswith("2026-08-04")
|
||||
```
|
||||
|
||||
**Step 2: Run to verify it fails**
|
||||
|
||||
Run: `./venv/bin/python -m pytest tests/test_feed_loader.py -v`
|
||||
Expected: FAIL — module missing
|
||||
|
||||
**Step 3: Implement**
|
||||
|
||||
Use `xml.etree.ElementTree` with the itunes namespace `http://www.itunes.com/dtds/podcast-1.0.dtd`. Dataclass:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Episode:
|
||||
number: int | None
|
||||
slug: str
|
||||
title: str
|
||||
description: str
|
||||
published_iso: str
|
||||
duration_seconds: int | None
|
||||
audio_url: str
|
||||
```
|
||||
|
||||
Parse `pubDate` with `email.utils.parsedate_to_datetime`. Strip CDATA and tags from `<description>` with a regex, then `html.unescape`.
|
||||
|
||||
**Step 4: Verify passes.** **Step 5: Commit.**
|
||||
|
||||
```bash
|
||||
git commit -m "Add RSS feed loader for episode page generation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Page renderer
|
||||
|
||||
**Files:**
|
||||
- Create: `website_gen/render.py`, `website_gen/templates/episode.html`
|
||||
- Test: `tests/test_episode_render.py`
|
||||
|
||||
The template is a full standalone page matching the existing site chrome (copy the `<nav>`, footer markup and `css/style.css?v=6` link from `website/how-it-works.html` so it looks native).
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
import json, re
|
||||
from website_gen.render import render_episode_page
|
||||
|
||||
|
||||
def test_title_and_canonical_are_episode_specific(sample_episode):
|
||||
html = render_episode_page(sample_episode, turns=[("LUKE", "Hello.")])
|
||||
assert "<title>Episode 58: Rayfield" in html
|
||||
assert '<link rel="canonical" href="https://lukeattheroost.com/episode/episode-58-' in html
|
||||
|
||||
|
||||
def test_transcript_is_in_the_html_not_fetched_by_js(sample_episode):
|
||||
html = render_episode_page(sample_episode, turns=[("LUKE", "The Marfa Lights are real.")])
|
||||
assert "The Marfa Lights are real." in html
|
||||
assert "fetch(" not in html
|
||||
|
||||
|
||||
def test_emits_valid_podcastepisode_schema(sample_episode):
|
||||
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi.")])
|
||||
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
|
||||
data = json.loads(block)
|
||||
types = {o["@type"] for o in (data if isinstance(data, list) else [data])}
|
||||
assert "PodcastEpisode" in types
|
||||
|
||||
|
||||
def test_escapes_html_in_transcript_text(sample_episode):
|
||||
html = render_episode_page(sample_episode, turns=[("LUKE", "5 < 6 & <script>alert(1)</script>")])
|
||||
assert "<script>alert(1)</script>" not in html
|
||||
assert "<script>" in html
|
||||
|
||||
|
||||
def test_escapes_quotes_in_title_meta(sample_episode):
|
||||
sample_episode.title = 'Episode 1: The "Best" Show'
|
||||
html = render_episode_page(sample_episode, turns=[])
|
||||
assert 'content="Episode 1: The "Best"' not in html # must be escaped
|
||||
|
||||
|
||||
def test_speaker_labels_get_semantic_markup(sample_episode):
|
||||
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi."), ("SLIM", "Hey.")])
|
||||
assert html.count('class="transcript-turn"') == 2
|
||||
assert "LUKE" in html and "SLIM" in html
|
||||
```
|
||||
|
||||
**Step 3: Implementation notes**
|
||||
|
||||
- Escape every interpolated value with `html.escape(value, quote=True)`. The transcript is user-facing text from Whisper; treat it as untrusted.
|
||||
- Schema block is a JSON array containing `PodcastEpisode` and `BreadcrumbList`:
|
||||
|
||||
```python
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "PodcastEpisode",
|
||||
"url": f"https://lukeattheroost.com/episode/{ep.slug}/",
|
||||
"name": ep.title,
|
||||
"description": ep.description,
|
||||
"datePublished": ep.published_iso,
|
||||
"timeRequired": f"PT{ep.duration_seconds}S",
|
||||
"associatedMedia": {"@type": "MediaObject", "contentUrl": ep.audio_url},
|
||||
"partOfSeries": {
|
||||
"@type": "PodcastSeries",
|
||||
"name": "Luke at the Roost",
|
||||
"url": "https://lukeattheroost.com",
|
||||
},
|
||||
"contentLocation": {
|
||||
"@type": "Place",
|
||||
"name": "Big Bend, West Texas",
|
||||
"address": {"@type": "PostalAddress", "addressLocality": "Alpine",
|
||||
"addressRegion": "TX", "addressCountry": "US"},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The `contentLocation` on every episode is the point of the whole exercise — it is what ties 57 pages of West Texas conversation to the region geographically.
|
||||
|
||||
- Include a native `<audio controls preload="none" src="{audio_url}">` so the page is useful without JS.
|
||||
- Add prev/next episode links. Internal linking is what gets 57 pages crawled instead of 3.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "Add episode page renderer with PodcastEpisode schema"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Generator CLI
|
||||
|
||||
**Files:**
|
||||
- Create: `generate_episode_pages.py` (repo root, matching the existing script convention)
|
||||
- Test: `tests/test_episode_generator.py`
|
||||
|
||||
**Behaviour:**
|
||||
|
||||
```
|
||||
python generate_episode_pages.py # fetch live feed, write all pages
|
||||
python generate_episode_pages.py --feed FILE # use a local feed (tests, offline)
|
||||
python generate_episode_pages.py --dry-run # report what would be written
|
||||
```
|
||||
|
||||
**Step 1: Failing tests**
|
||||
|
||||
```python
|
||||
def test_writes_one_index_html_per_feed_episode(tmp_path, feed_file, transcripts_dir):
|
||||
n = generate(feed_file, transcripts_dir, tmp_path)
|
||||
assert n == 2
|
||||
assert (tmp_path / "episode" / "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho" / "index.html").exists()
|
||||
|
||||
|
||||
def test_orphan_transcript_without_feed_item_is_skipped(tmp_path, feed_file, transcripts_dir):
|
||||
"""episode-32 has a transcript but was never published to the feed."""
|
||||
generate(feed_file, transcripts_dir, tmp_path)
|
||||
assert not (tmp_path / "episode" / "episode-32-tacos-taxes-and-tall-tales").exists()
|
||||
|
||||
|
||||
def test_missing_transcript_still_produces_a_page(tmp_path, feed_file, transcripts_dir):
|
||||
"""An episode published before its transcript lands must not break the build."""
|
||||
(transcripts_dir / "episode-58-....txt").unlink()
|
||||
generate(feed_file, transcripts_dir, tmp_path)
|
||||
html = (tmp_path / "episode" / "episode-58-..." / "index.html").read_text()
|
||||
assert "Transcript not yet available" in html
|
||||
|
||||
|
||||
def test_dry_run_writes_nothing(tmp_path, feed_file, transcripts_dir):
|
||||
generate(feed_file, transcripts_dir, tmp_path, dry_run=True)
|
||||
assert not (tmp_path / "episode").exists()
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "Add episode page generator CLI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Sitemap regeneration
|
||||
|
||||
**Files:**
|
||||
- Modify: `generate_episode_pages.py` (add `--sitemap`)
|
||||
- Modify: `website/sitemap.xml` (regenerated output)
|
||||
- Test: `tests/test_sitemap.py`
|
||||
|
||||
The current sitemap has 64 `<url>` entries, 54 of them `?slug=` URLs. Those must be **replaced**, not supplemented — leaving both forms invites duplicate-content dilution even with a canonical.
|
||||
|
||||
**Tests:**
|
||||
|
||||
```python
|
||||
def test_sitemap_contains_no_query_param_urls(generated_sitemap):
|
||||
assert "episode.html?slug=" not in generated_sitemap
|
||||
|
||||
|
||||
def test_sitemap_has_one_entry_per_episode(generated_sitemap):
|
||||
assert generated_sitemap.count("<loc>https://lukeattheroost.com/episode/") == 57
|
||||
|
||||
|
||||
def test_static_pages_survive_regeneration(generated_sitemap):
|
||||
for path in ["", "/how-it-works", "/clips", "/stats", "/privacy", "/terms", "/llms.txt"]:
|
||||
assert f"<loc>https://lukeattheroost.com{path}</loc>" in generated_sitemap
|
||||
|
||||
|
||||
def test_lastmod_matches_episode_publish_date(generated_sitemap):
|
||||
assert "<lastmod>2026-08-04</lastmod>" in generated_sitemap
|
||||
```
|
||||
|
||||
**Commit:** `git commit -m "Generate sitemap from feed with clean episode URLs"`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Worker — 301 the old URL, remove the UA gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `website/_worker.js:107-165`
|
||||
|
||||
Replace the entire social-crawler injection block with a redirect. Once pages are static, the injection is dead code and the UA gate is a liability.
|
||||
|
||||
```javascript
|
||||
// Legacy query-param episode URLs -> clean paths.
|
||||
// Published social posts and YouTube descriptions still point at the old
|
||||
// form, so this 301 has to stay indefinitely.
|
||||
if (url.pathname === "/episode.html" && url.searchParams.get("slug")) {
|
||||
const slug = url.searchParams.get("slug").replace(/[^a-z0-9-]/gi, "");
|
||||
if (slug) {
|
||||
return Response.redirect(`https://lukeattheroost.com/episode/${slug}/`, 301);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why the slug is sanitised:** it lands in a `Location:` header. Stripping to `[a-z0-9-]` prevents CRLF injection and open-redirect via a crafted `?slug=`.
|
||||
|
||||
**Verify manually after deploy:**
|
||||
|
||||
```bash
|
||||
curl -sI "https://lukeattheroost.com/episode.html?slug=episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho" | head -3
|
||||
# Expect: HTTP/2 301 + location: https://lukeattheroost.com/episode/episode-58-.../
|
||||
```
|
||||
|
||||
**Commit:** `git commit -m "Redirect legacy episode URLs and drop crawler UA gate"`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Retire the client-rendered page
|
||||
|
||||
**Files:**
|
||||
- Delete: `website/episode.html`, `website/js/episode.js`
|
||||
- Modify: `website/js/app.js` — episode links must point at `/episode/<slug>/`
|
||||
- Modify: `website/_redirects` — keep `/episodes.html /episode 302`
|
||||
|
||||
Check every internal link first:
|
||||
|
||||
```bash
|
||||
grep -rn "episode.html?slug=" website/ --include=*.html --include=*.js
|
||||
```
|
||||
|
||||
All must become `/episode/<slug>/`. Also fix `publish_episode.py:1168`, which builds the `episode_url` used in social posts and YouTube descriptions:
|
||||
|
||||
```python
|
||||
episode_url = f"https://lukeattheroost.com/episode/{episode_slug}/"
|
||||
```
|
||||
|
||||
**Commit:** `git commit -m "Point internal links at clean episode URLs"`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Wire into the publish pipeline
|
||||
|
||||
**Files:**
|
||||
- Modify: `publish_episode.py` — after the transcript is written to `website/transcripts/`
|
||||
|
||||
```python
|
||||
subprocess.run([sys.executable, "generate_episode_pages.py", "--sitemap"], check=False)
|
||||
```
|
||||
|
||||
`check=False` on purpose: a generator failure must not abort a publish that has already pushed audio to Castopod. Log loudly instead.
|
||||
|
||||
**Test:** `tests/test_publish_generates_page.py` — assert the generator is invoked with `--sitemap` after a successful publish (monkeypatch `subprocess.run`).
|
||||
|
||||
**Commit:** `git commit -m "Regenerate episode pages during publish"`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Deploy and verify
|
||||
|
||||
```bash
|
||||
./venv/bin/python -m pytest tests/ -q # full suite green
|
||||
python generate_episode_pages.py --sitemap # 57 pages + sitemap
|
||||
npx wrangler pages deploy website/ --project-name=lukeattheroost --branch=main
|
||||
```
|
||||
|
||||
**Post-deploy checks — all must pass:**
|
||||
|
||||
```bash
|
||||
# 1. Page is static: transcript present with JS disabled
|
||||
curl -s https://lukeattheroost.com/episode/episode-58-.../ | grep -c "Mitchell Flat" # >= 1
|
||||
|
||||
# 2. Real title in raw HTML, no UA spoofing
|
||||
curl -s https://lukeattheroost.com/episode/episode-58-.../ | grep -o "<title>.*</title>"
|
||||
|
||||
# 3. Same bytes for Googlebot as for a browser (no cloaking)
|
||||
diff <(curl -s -A "Mozilla/5.0" URL) <(curl -s -A "Googlebot/2.1" URL) && echo "no cloaking"
|
||||
|
||||
# 4. Legacy URL 301s
|
||||
curl -sI "https://lukeattheroost.com/episode.html?slug=episode-58-..." | grep -i "^location"
|
||||
|
||||
# 5. Schema validates
|
||||
# paste page source into https://validator.schema.org/
|
||||
```
|
||||
|
||||
Then: submit the new `sitemap.xml` in Google Search Console and request indexing on three episode pages as a sample.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| URL change loses existing ranking | 301 (permanent) from Task 6 passes equity; the old form stays supported forever |
|
||||
| Published YouTube/social links break | Same 301. Ep 58's YouTube description contains a `?slug=` link — verify it after deploy |
|
||||
| Transcripts contain explicit content | Show is already rated explicit; consider `<meta name="rating" content="adult">` on episode pages |
|
||||
| Whisper errors become permanent indexed text | Transcripts are already public at `/transcripts/*.txt`; this changes exposure, not accuracy |
|
||||
| 3 MB of new HTML on every deploy | Static assets, Cloudflare-cached; wrangler uploads only changed files |
|
||||
| Feed unreachable at build time | `--feed` flag lets the build use a cached copy; generator exits non-zero rather than writing empty pages |
|
||||
|
||||
## Out of scope
|
||||
|
||||
Tier 3 — the "Big Bend episodes" topic hub, and off-site work (Alpine Avalanche, Marfa/Big Bend groups, Sul Ross). Worth doing after these pages exist to link to.
|
||||
@@ -0,0 +1,172 @@
|
||||
# The Wellspring Callers — Design
|
||||
|
||||
**Date:** 2026-08-15
|
||||
**Status:** Approved, not yet implemented
|
||||
|
||||
## Problem
|
||||
|
||||
Silas is the show's strongest recurring character, but he is the only window into
|
||||
The Wellspring — and he controls the framing entirely. He describes a warm
|
||||
commune of seekers. Nobody contradicts him. The bit is funny but static, and the
|
||||
cult has no weight because there are no stakes the audience can see.
|
||||
|
||||
## Goal
|
||||
|
||||
Add callers from inside The Wellspring who offer perspectives Silas does not
|
||||
control — including frightened ones — so the commune becomes a place with real
|
||||
consequences rather than a running gag. Keep Silas playable as comedy.
|
||||
|
||||
## Structure
|
||||
|
||||
An **anchor** character with a progressing arc, plus a **pool** of rotating
|
||||
members for fresh angles.
|
||||
|
||||
- The anchor carries continuity and escalating dread.
|
||||
- The pool supplies variety and prevents the counter-perspective from
|
||||
flattening into one note.
|
||||
- Silas remains the leader's version of events.
|
||||
|
||||
## The anchor: Doyle
|
||||
|
||||
```yaml
|
||||
name: Doyle
|
||||
voice: Grant # plain, tight — deliberate contrast with Silas's Sebastian
|
||||
age: 47
|
||||
group: wellspring
|
||||
register: dry, understated, factual
|
||||
explicitness: low
|
||||
```
|
||||
|
||||
Two years in. Joined eight months after his divorce, when a member sold him soap
|
||||
at a farmers market in Las Cruces and asked him a question nobody had asked him
|
||||
in a year. He runs the soap kettles — twelve-hour days, physical, unglamorous.
|
||||
|
||||
The soap room is the engine of the character: **it is where the money is, and
|
||||
nobody spiritual pays attention to it.** He sees shipping manifests, deposit
|
||||
slips, whose names are on which accounts. He is not a seeker. He is the guy who
|
||||
fixes the pump.
|
||||
|
||||
**His fear is specific.** He is not afraid of being hurt. He is afraid of being
|
||||
right. He has a truck, a CDL, and nowhere to be — he could leave tomorrow, and
|
||||
has been telling himself that for eleven months. What frightens him is that he
|
||||
keeps not leaving and can no longer tell whether that is his choice.
|
||||
|
||||
**Calling mechanic.** No signal at the Terlingua property. Doyle calls from town
|
||||
on supply runs — a gas station lot in Study Butte, engine running, twenty
|
||||
minutes before someone notices the truck has been gone too long. This caps call
|
||||
length naturally, explains why he cannot talk long, and gives the host a ticking
|
||||
clock. An abrupt hangup is in character, not a failure.
|
||||
|
||||
**Register.** He describes appalling things in the flat voice of a man reading a
|
||||
parts list. The comedy and the dread come from the same place: he does not
|
||||
editorialize.
|
||||
|
||||
## The pool
|
||||
|
||||
Six members, each an ordinary regular file, drawn one at a time.
|
||||
|
||||
| Member | Voice | Age | Register | Explicitness |
|
||||
|---|---|---|---|---|
|
||||
| Renata — true believer | Celeste | 34 | Radiantly happy, evangelical, funny | high |
|
||||
| Alvin — bookkeeper | Conrad | 61 | Dry, precise, funny-then-cold | low |
|
||||
| Junie — grew up there | Pippa | 19 | Guileless, no frame of reference | low |
|
||||
| Ford — fourteen days in | Levi | 28 | Love-bombed, evangelical, too fast | medium |
|
||||
| Marguerite — wants out | Marlene | 58 | Exhausted, practical, sad | medium |
|
||||
| Cyrus — inner circle | Damon | 41 | Charming, then not | medium |
|
||||
|
||||
**Junie is a fixed constraint.** Someone who grew up inside the commune is the
|
||||
show's darkest available note, and she works precisely because nothing explicit
|
||||
ever attaches to her. Her horror is that she believes all of it is normal. Her
|
||||
`explicitness: low` is not a default — it does not change.
|
||||
|
||||
## Tone
|
||||
|
||||
Mostly unsettling-but-comic, with the capacity to go genuinely dark. The
|
||||
Wellspring should carry real weight without every call being heavy. Register is
|
||||
assigned per character rather than globally, so the true believer plays funny in
|
||||
the same season Doyle plays frightening.
|
||||
|
||||
**Adult content.** The show is adult and the Wellspring's sexuality is already
|
||||
load-bearing in canon. Characters may talk frankly about sex and kink. This is
|
||||
controlled per character via `explicitness: low | medium | high` rather than a
|
||||
global switch, so explicitness varies by who called and the show does not
|
||||
flatten into wall-to-wall smut.
|
||||
|
||||
**The one hard separation: consensual weirdness and coercion stay in different
|
||||
lanes.** An openly horny commune with elaborate rituals is the comedy. Silas
|
||||
pressuring a member into participating is the horror — that is where the weight
|
||||
comes from. Blurring them casually makes the show mean rather than dark and
|
||||
makes Silas irredeemable, which costs the character. Existing canon already
|
||||
handles this correctly: his 2026-03-15 reckoning treats the coercion as real
|
||||
harm.
|
||||
|
||||
## Selection mechanics
|
||||
|
||||
Current behavior (`backend/main.py`, `_build_backgrounds`): every active regular
|
||||
draws independently at `min(1.0, 0.4 + 0.3 × shows_missed)`, then the list is
|
||||
truncated to 3.
|
||||
|
||||
Adding eight Wellspring files to that would let the commune eat every regular
|
||||
slot. So:
|
||||
|
||||
1. Partition regulars by the new `group` frontmatter field.
|
||||
2. Ungrouped regulars behave exactly as today.
|
||||
3. Within `group: wellspring`, members draw on their existing per-member
|
||||
probability, then the group is **capped**:
|
||||
- **1** Wellspring caller per episode normally
|
||||
- **2** on a 15% roll — a "both sides tonight" episode
|
||||
- On a two-night, prefer Silas plus one non-Silas member
|
||||
4. Group cap applies before the global 3-regular truncation.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
~/code/dotfiles/regulars/*.md (+ silas/silas.md)
|
||||
↓ regulars_v2.load_all_active_regulars()
|
||||
↓ Regular gains: group, register, explicitness
|
||||
↓ _build_backgrounds() — group partition + cap
|
||||
↓ regulars_included
|
||||
↓ caller_gen.build_batch_prompt() — renders register + explicitness
|
||||
↓ Sonnet 4.6 batch
|
||||
↓ session.caller_backgrounds
|
||||
↓ get_caller_prompt() — live dialog
|
||||
```
|
||||
|
||||
Touch points:
|
||||
|
||||
- `regulars_v2.py:29` `load_regular()` — parse four new frontmatter fields
|
||||
(`group`, `group_lead`, `register`, `explicitness`), defaulting so existing
|
||||
files keep working
|
||||
- `regulars_v2.py:19` `Regular` dataclass — four new fields
|
||||
- `main.py` `_build_backgrounds()` — group partition and cap
|
||||
- `caller_gen.py:132-138` — render `register` and `explicitness` into the batch
|
||||
prompt (currently renders only name, lore, arc_state)
|
||||
- Eight new markdown files; one frontmatter line added to `silas.md`
|
||||
|
||||
## Failure mode to guard
|
||||
|
||||
Model refusals do not look like errors. `llm.py:385-395` handles exceptions and
|
||||
empty completions by returning `None` with a printed message, but a content
|
||||
refusal typically arrives as a **200 with refusal text**, which flows through as
|
||||
ordinary dialog and breaks character on air — or as an empty completion that
|
||||
reads as dead air.
|
||||
|
||||
Add explicit detection so a refusal is visibly distinct from a caller who simply
|
||||
had nothing to say, surfaced in the host's log rather than only stdout. Without
|
||||
this, tuning `explicitness: high` upward has no feedback signal.
|
||||
|
||||
## Testing
|
||||
|
||||
- `load_regular()` parses the new fields; files lacking them still load
|
||||
- Group cap holds: 8 Wellspring regulars never yield more than 2 in a lineup
|
||||
- Two-night rate approximates 15% over many seeded draws
|
||||
- On a two-night, Silas is preferred plus exactly one non-Silas member
|
||||
- Ungrouped regulars are unaffected by group logic
|
||||
- Batch prompt contains `register` and `explicitness` for each included regular
|
||||
- Junie's `explicitness` is `low` (guards against a careless edit)
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Arc progression automation — arc_state stays hand-edited, as today
|
||||
- Promotion of pool members to anchors
|
||||
- Any change to how walk-in callers are generated
|
||||
@@ -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,192 @@
|
||||
"""Download vocal-free background music from Jamendo (CC-licensed).
|
||||
|
||||
Targets late-night talk-radio vibe + hip-hop. Skips tracks shorter than 60s,
|
||||
dedupes against existing files in music/, and appends CREDITS.txt entries.
|
||||
|
||||
Usage:
|
||||
python download_music.py # 100 tracks across all buckets
|
||||
python download_music.py --count 30 # smaller batch
|
||||
python download_music.py --dry-run # show what would be downloaded
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
CLIENT_ID = os.getenv("JAMENDO_CLIENT_ID")
|
||||
if not CLIENT_ID:
|
||||
print("ERROR: JAMENDO_CLIENT_ID not set in .env", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
MUSIC_DIR = Path(__file__).parent / "music"
|
||||
CREDITS_FILE = MUSIC_DIR / "CREDITS.txt"
|
||||
|
||||
# (tag_query, genre_label, target_count) — hip-hop weighted heaviest per user pref
|
||||
BUCKETS = [
|
||||
("hiphop+instrumental", "Hip-Hop", 40),
|
||||
("jazz", "Jazz", 20),
|
||||
("lofi", "Lo-Fi", 15),
|
||||
("funk", "Funk", 15),
|
||||
("soul", "Soul", 10),
|
||||
]
|
||||
|
||||
# Filenames already on disk — skip duplicates by (artist, title) signature
|
||||
def _existing_signatures() -> set[str]:
|
||||
sigs = set()
|
||||
for f in MUSIC_DIR.glob("*.mp3"):
|
||||
# "Artist - Title [Genre].mp3" or "Artist - Title.mp3"
|
||||
stem = f.stem
|
||||
stem = re.sub(r"\s*\[[^\]]+\]\s*$", "", stem)
|
||||
sigs.add(stem.lower().strip())
|
||||
for f in MUSIC_DIR.glob("*.wav"):
|
||||
sigs.add(f.stem.lower().strip())
|
||||
return sigs
|
||||
|
||||
|
||||
def _sanitize(s: str) -> str:
|
||||
s = s.replace("/", "-").replace("\\", "-")
|
||||
s = re.sub(r'[<>:"|?*]', "", s)
|
||||
return s.strip()
|
||||
|
||||
|
||||
def _fetch_jamendo_page(tag_query: str, offset: int, limit: int = 50) -> list[dict]:
|
||||
params = {
|
||||
"client_id": CLIENT_ID,
|
||||
"format": "json",
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"vocalinstrumental": "instrumental",
|
||||
"fuzzytags": tag_query,
|
||||
"audioformat": "mp32",
|
||||
"include": "musicinfo+licenses",
|
||||
"audiodlallowed": "true",
|
||||
"ccnd": "true", # allow non-derivative (we won't modify)
|
||||
"order": "popularity_total",
|
||||
}
|
||||
url = "https://api.jamendo.com/v3.0/tracks/?" + urlencode(params)
|
||||
with urlopen(Request(url, headers={"User-Agent": "ai-podcast-music-fetcher/1.0"}), timeout=30) as r:
|
||||
import json
|
||||
data = json.load(r)
|
||||
if data.get("headers", {}).get("status") != "success":
|
||||
print(f" API error: {data.get('headers', {}).get('error_message')}")
|
||||
return []
|
||||
return data.get("results", [])
|
||||
|
||||
|
||||
def _download(url: str, dest: Path) -> bool:
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "ai-podcast-music-fetcher/1.0"})
|
||||
with urlopen(req, timeout=120) as r, open(dest, "wb") as out:
|
||||
while True:
|
||||
chunk = r.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
out.write(chunk)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" download failed: {e}")
|
||||
if dest.exists():
|
||||
dest.unlink()
|
||||
return False
|
||||
|
||||
|
||||
def fetch_bucket(tag_query: str, genre_label: str, target: int, existing: set[str], dry_run: bool) -> list[tuple[Path, dict]]:
|
||||
"""Returns list of (path, track_info) successfully downloaded."""
|
||||
print(f"\n=== {genre_label} (target {target}) ===")
|
||||
downloaded: list[tuple[Path, dict]] = []
|
||||
offset = 0
|
||||
seen_ids = set()
|
||||
while len(downloaded) < target and offset < 500: # cap pagination
|
||||
page = _fetch_jamendo_page(tag_query, offset)
|
||||
if not page:
|
||||
break
|
||||
offset += len(page)
|
||||
for track in page:
|
||||
if len(downloaded) >= target:
|
||||
break
|
||||
tid = track.get("id")
|
||||
if tid in seen_ids:
|
||||
continue
|
||||
seen_ids.add(tid)
|
||||
|
||||
if track.get("duration", 0) < 60:
|
||||
continue
|
||||
if not track.get("audiodownload_allowed"):
|
||||
continue
|
||||
|
||||
artist = _sanitize(track.get("artist_name", "Unknown"))
|
||||
name = _sanitize(track.get("name", "Untitled"))
|
||||
sig = f"{artist} - {name}".lower().strip()
|
||||
if sig in existing:
|
||||
continue
|
||||
|
||||
filename = f"{artist} - {name} [{genre_label}].mp3"
|
||||
dest = MUSIC_DIR / filename
|
||||
|
||||
audio_url = track.get("audiodownload") or track.get("audio")
|
||||
if not audio_url:
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY] {filename} ({track.get('duration')}s)")
|
||||
downloaded.append((dest, track))
|
||||
existing.add(sig)
|
||||
continue
|
||||
|
||||
print(f" ↓ {filename} ({track.get('duration')}s)")
|
||||
if _download(audio_url, dest):
|
||||
downloaded.append((dest, track))
|
||||
existing.add(sig)
|
||||
time.sleep(0.5) # be polite
|
||||
if len(page) < 50:
|
||||
break
|
||||
return downloaded
|
||||
|
||||
|
||||
def append_credits(entries: list[tuple[Path, dict]]):
|
||||
if not entries:
|
||||
return
|
||||
with open(CREDITS_FILE, "a") as f:
|
||||
f.write(f"\n# Added {time.strftime('%Y-%m-%d')} — vocal-free batch via Jamendo API\n")
|
||||
for dest, track in entries:
|
||||
license_url = track.get("license_ccurl", "")
|
||||
share_url = track.get("shareurl", "")
|
||||
artist = track.get("artist_name", "")
|
||||
name = track.get("name", "")
|
||||
f.write(f"{dest.name} | {artist} - {name} | {license_url} | {share_url}\n")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--count", type=int, default=100, help="Total tracks (default 100)")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
MUSIC_DIR.mkdir(exist_ok=True)
|
||||
existing = _existing_signatures()
|
||||
print(f"Existing tracks: {len(existing)}")
|
||||
|
||||
# Scale bucket targets proportionally to --count
|
||||
scale = args.count / sum(b[2] for b in BUCKETS)
|
||||
all_new: list[tuple[Path, dict]] = []
|
||||
for tag, label, weight in BUCKETS:
|
||||
target = max(1, round(weight * scale))
|
||||
all_new.extend(fetch_bucket(tag, label, target, existing, args.dry_run))
|
||||
|
||||
print(f"\n=== Done. {len(all_new)} new tracks {'planned' if args.dry_run else 'downloaded'}. ===")
|
||||
if not args.dry_run:
|
||||
append_credits(all_new)
|
||||
print(f"CREDITS.txt updated.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
"""Fetch instrumental background music from Jamendo for the radio show.
|
||||
|
||||
Pixabay has no public music API — this uses Jamendo's free API instead.
|
||||
All tracks are Creative Commons licensed. Attribution is saved to music/CREDITS.txt.
|
||||
|
||||
Setup: Get a free client_id at https://devportal.jamendo.com
|
||||
Add JAMENDO_CLIENT_ID=your_id to .env
|
||||
|
||||
Usage:
|
||||
python fetch_music.py # download 20 tracks across all genres
|
||||
python fetch_music.py --genre jazz # download jazz only
|
||||
python fetch_music.py --count 50 # download 50 tracks
|
||||
python fetch_music.py --list # just list available tracks, don't download
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
MUSIC_DIR = Path(__file__).parent / "music"
|
||||
CREDITS_FILE = MUSIC_DIR / "CREDITS.txt"
|
||||
API_BASE = "https://api.jamendo.com/v3.0"
|
||||
|
||||
# Genres good for a late-night radio show
|
||||
GENRES = ["jazz", "lofi", "blues", "ambient", "acoustic", "funk", "chill"]
|
||||
|
||||
# Map search tags to labels that _detect_genre() in main.py can match
|
||||
# jazz, blues, funk, lo-fi are already in GENRE_KEYWORDS
|
||||
# ambient, acoustic, chill would need to be added for auto-detection
|
||||
GENRE_LABELS = {
|
||||
"jazz": "Jazz",
|
||||
"lofi": "Lo-Fi",
|
||||
"blues": "Blues",
|
||||
"ambient": "Ambient",
|
||||
"acoustic": "Acoustic",
|
||||
"funk": "Funk",
|
||||
"chill": "Chill",
|
||||
}
|
||||
|
||||
|
||||
def get_client_id():
|
||||
key = os.getenv("JAMENDO_CLIENT_ID")
|
||||
if not key:
|
||||
print("Error: JAMENDO_CLIENT_ID not found in .env")
|
||||
print("Get one free at https://devportal.jamendo.com")
|
||||
sys.exit(1)
|
||||
return key
|
||||
|
||||
|
||||
def sanitize_filename(name: str) -> str:
|
||||
return re.sub(r'[<>:"/\\|?*]', '', name).strip()
|
||||
|
||||
|
||||
def _has_vocals(track: dict) -> bool:
|
||||
"""Check musicinfo for vocal indicators — catches tracks Jamendo mis-tagged as instrumental."""
|
||||
mi = track.get("musicinfo", {})
|
||||
# Check the vocalinstrumental field in musicinfo (separate from the API filter)
|
||||
vi = mi.get("vocalinstrumental")
|
||||
if vi and vi.lower() == "vocal":
|
||||
return True
|
||||
# Check tags for vocal/singing indicators
|
||||
tags = mi.get("tags", {})
|
||||
# tags can be {"genres": [...], "instruments": [...], "vartags": [...]}
|
||||
all_tags = []
|
||||
if isinstance(tags, dict):
|
||||
for v in tags.values():
|
||||
if isinstance(v, list):
|
||||
all_tags.extend(t.lower() for t in v)
|
||||
elif isinstance(tags, list):
|
||||
all_tags = [t.lower() for t in tags]
|
||||
vocal_tags = {"vocals", "vocal", "singing", "singer", "voice", "lyrics",
|
||||
"rap", "hiphop", "hip-hop", "spoken", "spoken word"}
|
||||
if vocal_tags & set(all_tags):
|
||||
return True
|
||||
# Check track name for vocal giveaways
|
||||
name_lower = track.get("name", "").lower()
|
||||
if any(w in name_lower for w in ["feat.", "ft.", "vocal", "remix vocal", "(voice"]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def search_tracks(client: httpx.Client, client_id: str, genre: str, limit: int = 20) -> list[dict]:
|
||||
# Request more than needed so we can filter out vocal false positives
|
||||
fetch_limit = min(limit * 3, 200)
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"format": "json",
|
||||
"limit": fetch_limit,
|
||||
"vocalinstrumental": "instrumental",
|
||||
"fuzzytags": genre,
|
||||
"durationbetween": "60_300",
|
||||
"include": "musicinfo+licenses",
|
||||
"order": "popularity_total",
|
||||
}
|
||||
|
||||
resp = client.get(f"{API_BASE}/tracks/", params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if data["headers"]["status"] != "success":
|
||||
print(f" API error: {data['headers'].get('error_message', 'unknown')}")
|
||||
return []
|
||||
|
||||
results = data.get("results", [])
|
||||
# Post-filter: reject tracks with vocal indicators despite the API filter
|
||||
filtered = []
|
||||
for t in results:
|
||||
if _has_vocals(t):
|
||||
print(f" SKIP (vocals detected): {t.get('artist_name', '?')} - {t.get('name', '?')}")
|
||||
continue
|
||||
filtered.append(t)
|
||||
if len(filtered) >= limit:
|
||||
break
|
||||
|
||||
skipped = len(results) - len(filtered)
|
||||
if skipped:
|
||||
print(f" (filtered out {skipped} tracks with vocal indicators)")
|
||||
return filtered
|
||||
|
||||
|
||||
def make_filename(track: dict, genre_tag: str) -> str:
|
||||
artist = sanitize_filename(track.get("artist_name", "Unknown"))
|
||||
title = sanitize_filename(track.get("name", "Untitled"))
|
||||
label = GENRE_LABELS.get(genre_tag, genre_tag.title())
|
||||
|
||||
# Include genre tag if not already detectable from artist/title
|
||||
lower = f"{artist} {title}".lower()
|
||||
needs_tag = not any(kw in lower for kw in [genre_tag, label.lower()])
|
||||
|
||||
if needs_tag:
|
||||
return f"{artist} - {title} [{label}].mp3"
|
||||
return f"{artist} - {title}.mp3"
|
||||
|
||||
|
||||
def download_track(client: httpx.Client, track: dict, filepath: Path, index: int, total: int) -> bool:
|
||||
url = track.get("audiodownload")
|
||||
if not url:
|
||||
print(f" [{index}/{total}] SKIP (no download URL): {track['name']}")
|
||||
return False
|
||||
|
||||
if not track.get("audiodownload_allowed", True):
|
||||
print(f" [{index}/{total}] SKIP (download not allowed): {track['name']}")
|
||||
return False
|
||||
|
||||
print(f" [{index}/{total}] Downloading: {filepath.name}...", end=" ", flush=True)
|
||||
resp = client.get(url, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
filepath.write_bytes(resp.content)
|
||||
size_mb = len(resp.content) / (1024 * 1024)
|
||||
dur = track.get("duration", 0)
|
||||
print(f"{size_mb:.1f} MB, {dur // 60}:{dur % 60:02d}")
|
||||
return True
|
||||
|
||||
|
||||
def save_credit(track: dict, filename: str):
|
||||
artist = track.get("artist_name", "Unknown")
|
||||
title = track.get("name", "Untitled")
|
||||
license_url = track.get("license_ccurl", "")
|
||||
share_url = track.get("shareurl", "")
|
||||
|
||||
line = f"{filename} | {artist} - {title} | {license_url} | {share_url}\n"
|
||||
|
||||
existing = CREDITS_FILE.read_text() if CREDITS_FILE.exists() else ""
|
||||
if filename not in existing:
|
||||
with open(CREDITS_FILE, "a") as f:
|
||||
if not existing:
|
||||
f.write("# Music Credits (Jamendo - Creative Commons)\n")
|
||||
f.write("# File | Artist - Title | License | URL\n\n")
|
||||
f.write(line)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Download instrumental music from Jamendo")
|
||||
parser.add_argument("--genre", choices=GENRES, help="Download only this genre")
|
||||
parser.add_argument("--count", type=int, default=20, help="Total tracks to download (default: 20)")
|
||||
parser.add_argument("--list", action="store_true", help="List available tracks without downloading")
|
||||
args = parser.parse_args()
|
||||
|
||||
client_id = get_client_id()
|
||||
MUSIC_DIR.mkdir(exist_ok=True)
|
||||
|
||||
genres = [args.genre] if args.genre else GENRES
|
||||
per_genre = max(1, args.count // len(genres))
|
||||
remainder = args.count - per_genre * len(genres)
|
||||
|
||||
all_tracks = []
|
||||
seen_ids = set()
|
||||
|
||||
with httpx.Client(timeout=30) as api_client:
|
||||
for i, genre in enumerate(genres):
|
||||
limit = per_genre + (1 if i < remainder else 0)
|
||||
if limit <= 0:
|
||||
continue
|
||||
print(f"Searching {genre}...", end=" ", flush=True)
|
||||
tracks = search_tracks(api_client, client_id, genre, limit)
|
||||
# Deduplicate across genres
|
||||
added = 0
|
||||
for t in tracks:
|
||||
if t["id"] not in seen_ids and added < limit:
|
||||
t["_genre_tag"] = genre
|
||||
all_tracks.append(t)
|
||||
seen_ids.add(t["id"])
|
||||
added += 1
|
||||
print(f"{added} tracks")
|
||||
|
||||
if not all_tracks:
|
||||
print("No tracks found.")
|
||||
return
|
||||
|
||||
if args.list:
|
||||
print(f"\n{'#':<4} {'Genre':<10} {'Artist':<25} {'Title':<40} {'Duration':<8}")
|
||||
print("-" * 90)
|
||||
for i, t in enumerate(all_tracks, 1):
|
||||
dur = f"{t['duration'] // 60}:{t['duration'] % 60:02d}"
|
||||
artist = t["artist_name"][:24]
|
||||
title = t["name"][:39]
|
||||
label = GENRE_LABELS.get(t["_genre_tag"], t["_genre_tag"])
|
||||
print(f"{i:<4} {label:<10} {artist:<25} {title:<40} {dur:<8}")
|
||||
print(f"\n{len(all_tracks)} tracks available")
|
||||
return
|
||||
|
||||
# Download phase
|
||||
downloaded = 0
|
||||
skipped_exists = 0
|
||||
skipped_error = 0
|
||||
|
||||
with httpx.Client(timeout=120, follow_redirects=True) as dl_client:
|
||||
for i, track in enumerate(all_tracks, 1):
|
||||
filename = make_filename(track, track["_genre_tag"])
|
||||
filepath = MUSIC_DIR / filename
|
||||
|
||||
if filepath.exists():
|
||||
print(f" [{i}/{len(all_tracks)}] EXISTS: {filename}")
|
||||
skipped_exists += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
if download_track(dl_client, track, filepath, i, len(all_tracks)):
|
||||
save_credit(track, filename)
|
||||
downloaded += 1
|
||||
else:
|
||||
skipped_error += 1
|
||||
except Exception as e:
|
||||
print(f" [{i}/{len(all_tracks)}] ERROR: {e}")
|
||||
# Clean up partial download
|
||||
if filepath.exists():
|
||||
filepath.unlink()
|
||||
skipped_error += 1
|
||||
|
||||
print(f"\nDone: {downloaded} downloaded, {skipped_exists} existed, {skipped_error} skipped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+1210
-97
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
+148
-74
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Luke at The Roost</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=3">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
@@ -16,13 +16,34 @@
|
||||
<button id="new-session-btn" class="new-session-btn">New Session</button>
|
||||
<button id="export-session-btn">Export</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>
|
||||
<input type="text" id="show-theme-input" class="theme-input" placeholder="e.g. St. Patrick's Day" maxlength="100">
|
||||
<button id="set-theme-btn" class="theme-btn set" title="Set show theme">Set</button>
|
||||
<button id="clear-theme-btn" class="theme-btn clear hidden" title="Clear theme">✕</button>
|
||||
</div>
|
||||
<div id="show-clock" class="show-clock">
|
||||
<span class="clock-time" id="clock-time"></span>
|
||||
<span id="show-timers" class="show-timers hidden">
|
||||
<span class="clock-divider">|</span>
|
||||
<span class="clock-label">On Air:</span>
|
||||
<span class="clock-value" id="clock-runtime">0:00:00</span>
|
||||
<span class="clock-divider">|</span>
|
||||
<span class="clock-label">Est. Final:</span>
|
||||
<span class="clock-value clock-estimate" id="clock-estimate">0:00</span>
|
||||
<span class="clock-divider">|</span>
|
||||
<span class="clock-label">Cost:</span>
|
||||
<span class="clock-value clock-cost" id="clock-cost">$0.00</span>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Callers -->
|
||||
<section class="callers-section">
|
||||
<h2>Callers <span id="session-id" class="session-id"></span></h2>
|
||||
<h2>Callers <span id="session-id" class="session-id"></span><span id="lineup-status" class="lineup-status"></span></h2>
|
||||
<div id="callers" class="caller-grid"></div>
|
||||
<!-- Active Call Indicator -->
|
||||
<div id="active-call" class="active-call hidden">
|
||||
@@ -50,16 +71,25 @@
|
||||
</label>
|
||||
</div>
|
||||
<div id="call-status" class="call-status">No active call</div>
|
||||
<details id="caller-background-details" class="caller-background hidden">
|
||||
<summary>Caller Background</summary>
|
||||
<div id="caller-background"></div>
|
||||
</details>
|
||||
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up</button>
|
||||
<div id="caller-info-panel" class="caller-info-panel hidden">
|
||||
<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>
|
||||
</details>
|
||||
</div>
|
||||
<div class="call-actions">
|
||||
<button id="wrapup-btn" class="wrapup-btn" disabled>Wrap It Up <span class="shortcut-label">W</span></button>
|
||||
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up <span class="shortcut-label">H</span></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Call Queue -->
|
||||
<section class="queue-section">
|
||||
<h2>Incoming Calls <span style="font-size:0.6em;font-weight:normal;color:var(--text-muted);">(208) 439-5853</span></h2>
|
||||
<h2>Incoming Calls <span class="section-subtitle">(208) 439-5853</span></h2>
|
||||
<div id="call-queue" class="call-queue">
|
||||
<div class="queue-empty">No callers waiting</div>
|
||||
</div>
|
||||
@@ -76,7 +106,7 @@
|
||||
<!-- Listener Emails -->
|
||||
<section class="voicemail-section">
|
||||
<h2>Emails <span id="email-badge" class="voicemail-badge hidden">0</span></h2>
|
||||
<div id="email-list" class="voicemail-list" style="max-height:300px">
|
||||
<div id="email-list" class="voicemail-list email-list">
|
||||
<div class="queue-empty">No emails</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -84,6 +114,21 @@
|
||||
<!-- Chat -->
|
||||
<section class="chat-section">
|
||||
<div id="chat" class="chat-log"></div>
|
||||
<div class="devon-bar">
|
||||
<div class="devon-ask-row">
|
||||
<input type="text" id="devon-input" placeholder="Ask Devon..." class="devon-input">
|
||||
<button id="devon-ask-btn" class="devon-ask-btn">Ask <span class="shortcut-label">D</span></button>
|
||||
<button id="devon-interject-btn" class="devon-interject-btn" title="Devon interjects on current conversation">Interject</button>
|
||||
<label class="devon-monitor-label" title="Devon auto-monitors conversations">
|
||||
<input type="checkbox" id="devon-monitor" checked> Monitor
|
||||
</label>
|
||||
</div>
|
||||
<div id="devon-suggestion" class="devon-suggestion hidden">
|
||||
<span class="devon-suggestion-text">Devon has something</span>
|
||||
<button id="devon-play-btn" class="devon-play-btn">Play</button>
|
||||
<button id="devon-dismiss-btn" class="devon-dismiss-btn">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="talk-controls">
|
||||
<button id="talk-btn" class="talk-btn">Hold to Talk</button>
|
||||
<button id="type-btn" class="type-btn">Type</button>
|
||||
@@ -91,36 +136,36 @@
|
||||
<div id="status" class="status hidden"></div>
|
||||
</section>
|
||||
|
||||
<!-- Music -->
|
||||
<section class="music-section">
|
||||
<h2>Music</h2>
|
||||
<select id="track-select"></select>
|
||||
<div class="music-controls">
|
||||
<button id="play-btn">Play</button>
|
||||
<button id="stop-btn">Stop</button>
|
||||
<input type="range" id="volume" min="0" max="100" value="30">
|
||||
</div>
|
||||
</section>
|
||||
<!-- Music / Ads / Idents -->
|
||||
<div class="media-row">
|
||||
<section class="music-section genre-section">
|
||||
<h2>Music <span class="shortcut-label">M</span></h2>
|
||||
<div id="genre-buttons" class="genre-grid"></div>
|
||||
<div id="now-playing" class="now-playing hidden">
|
||||
<span id="now-playing-text" class="now-playing-text"></span>
|
||||
<button id="stop-btn" class="now-playing-stop">Stop</button>
|
||||
<input type="range" id="volume" min="0" max="100" value="30" class="now-playing-volume">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Ads -->
|
||||
<section class="music-section">
|
||||
<h2>Ads</h2>
|
||||
<select id="ad-select"></select>
|
||||
<div class="music-controls">
|
||||
<button id="ad-play-btn">Play Ad</button>
|
||||
<button id="ad-stop-btn">Stop</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="music-section">
|
||||
<h2>Ads</h2>
|
||||
<select id="ad-select"></select>
|
||||
<div class="music-controls">
|
||||
<button id="ad-play-btn">Play Ad</button>
|
||||
<button id="ad-stop-btn">Stop</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Idents -->
|
||||
<section class="music-section">
|
||||
<h2>Idents</h2>
|
||||
<select id="ident-select"></select>
|
||||
<div class="music-controls">
|
||||
<button id="ident-play-btn">Play Ident</button>
|
||||
<button id="ident-stop-btn">Stop</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="music-section">
|
||||
<h2>Idents</h2>
|
||||
<select id="ident-select"></select>
|
||||
<div class="music-controls">
|
||||
<button id="ident-play-btn">Play Ident</button>
|
||||
<button id="ident-stop-btn">Stop</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Sound Effects -->
|
||||
<section class="sounds-section">
|
||||
@@ -133,6 +178,7 @@
|
||||
<div class="log-header">
|
||||
<h2>Server Log</h2>
|
||||
<div class="server-controls">
|
||||
<button id="log-toggle-btn" class="log-toggle-btn">Show ▼</button>
|
||||
<button id="restart-server-btn" class="server-btn restart">Restart</button>
|
||||
<button id="stop-server-btn" class="server-btn stop">Stop</button>
|
||||
<label class="auto-scroll-label">
|
||||
@@ -140,7 +186,9 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="server-log" class="server-log"></div>
|
||||
<div class="log-body collapsed">
|
||||
<div id="server-log" class="server-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -151,7 +199,7 @@
|
||||
|
||||
<!-- Audio Devices -->
|
||||
<div class="settings-group">
|
||||
<h3>Audio Routing</h3>
|
||||
<h3>Audio Devices</h3>
|
||||
<div class="device-row">
|
||||
<label>
|
||||
Input Device
|
||||
@@ -159,7 +207,7 @@
|
||||
</label>
|
||||
<label>
|
||||
Ch
|
||||
<input type="number" id="input-channel" value="1" min="1" max="16" class="channel-input">
|
||||
<input type="number" id="input-channel" value="1" min="1" max="32" class="channel-input">
|
||||
</label>
|
||||
</div>
|
||||
<div class="device-row">
|
||||
@@ -168,44 +216,70 @@
|
||||
<select id="output-device"></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="channel-row">
|
||||
<label>Caller Ch <input type="number" id="caller-channel" value="3" min="1" max="16" class="channel-input"></label>
|
||||
<label>Live Ch <input type="number" id="live-caller-channel" value="9" min="1" max="16" class="channel-input"></label>
|
||||
<label>Music Ch <input type="number" id="music-channel" value="5" min="1" max="16" class="channel-input"></label>
|
||||
<label>SFX Ch <input type="number" id="sfx-channel" value="7" min="1" max="16" class="channel-input"></label>
|
||||
<label>Ad Ch <input type="number" id="ad-channel" value="11" min="1" max="16" class="channel-input"></label>
|
||||
<label>Ident Ch <input type="number" id="ident-channel" value="15" min="1" max="16" class="channel-input"></label>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<h3>Output Routing</h3>
|
||||
<div class="routing-grid">
|
||||
<div class="routing-item">
|
||||
<span class="routing-label">AI Caller</span>
|
||||
<input type="number" id="caller-channel" value="3" min="1" max="32" class="channel-input">
|
||||
</div>
|
||||
<div class="routing-item">
|
||||
<span class="routing-label">Devon</span>
|
||||
<input type="number" id="devon-channel" value="17" min="1" max="32" class="channel-input">
|
||||
</div>
|
||||
<div class="routing-item">
|
||||
<span class="routing-label">Live Caller</span>
|
||||
<input type="number" id="live-caller-channel" value="9" min="1" max="32" class="channel-input">
|
||||
</div>
|
||||
<div class="routing-item">
|
||||
<span class="routing-label">Music</span>
|
||||
<input type="number" id="music-channel" value="5" min="1" max="32" class="channel-input">
|
||||
</div>
|
||||
<div class="routing-item">
|
||||
<span class="routing-label">SFX</span>
|
||||
<input type="number" id="sfx-channel" value="7" min="1" max="32" class="channel-input">
|
||||
</div>
|
||||
<div class="routing-item">
|
||||
<span class="routing-label">Ads</span>
|
||||
<input type="number" id="ad-channel" value="11" min="1" max="32" class="channel-input">
|
||||
</div>
|
||||
<div class="routing-item">
|
||||
<span class="routing-label">Idents</span>
|
||||
<input type="number" id="ident-channel" value="15" min="1" max="32" class="channel-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LLM Settings -->
|
||||
<div class="settings-group">
|
||||
<h3>LLM Provider</h3>
|
||||
<label>
|
||||
Provider
|
||||
<select id="provider">
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div id="openrouter-settings">
|
||||
<label>
|
||||
Model
|
||||
<select id="openrouter-model"></select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="ollama-settings" class="hidden">
|
||||
<label>
|
||||
Model
|
||||
<select id="ollama-model"></select>
|
||||
</label>
|
||||
<label>
|
||||
Host
|
||||
<input type="text" id="ollama-host" value="http://localhost:11434">
|
||||
</label>
|
||||
<button type="button" id="refresh-ollama" class="refresh-btn">Refresh Models</button>
|
||||
<h3>LLM Model Routing</h3>
|
||||
<div class="model-routing-grid">
|
||||
<div class="model-routing-item">
|
||||
<span class="model-routing-label">Caller Dialog</span>
|
||||
<select id="model-caller_dialog" class="model-select"></select>
|
||||
</div>
|
||||
<div class="model-routing-item">
|
||||
<span class="model-routing-label">Devon Monitor</span>
|
||||
<select id="model-devon_monitor" class="model-select"></select>
|
||||
</div>
|
||||
<div class="model-routing-item">
|
||||
<span class="model-routing-label">Devon Ask</span>
|
||||
<select id="model-devon_ask" class="model-select"></select>
|
||||
</div>
|
||||
<div class="model-routing-item">
|
||||
<span class="model-routing-label">Backgrounds</span>
|
||||
<select id="model-background_gen" class="model-select"></select>
|
||||
</div>
|
||||
<div class="model-routing-item">
|
||||
<span class="model-routing-label">Call Summary</span>
|
||||
<select id="model-call_summary" class="model-select"></select>
|
||||
</div>
|
||||
<div class="model-routing-item">
|
||||
<span class="model-routing-label">News</span>
|
||||
<select id="model-news_summary" class="model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -251,6 +325,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/app.js?v=18"></script>
|
||||
<script src="/js/app.js?v=29"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+910
-185
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a static, crawlable page for every episode in the podcast feed."""
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from website_gen.feed import parse_feed
|
||||
from website_gen.render import FEED_URL, render_episode_page
|
||||
from website_gen.transcript import parse_transcript
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent
|
||||
DEFAULT_OUTPUT = REPO_ROOT / "website"
|
||||
DEFAULT_TRANSCRIPTS = DEFAULT_OUTPUT / "transcripts"
|
||||
USER_AGENT = "lukeattheroost-site-generator/1.0"
|
||||
|
||||
SITE_URL = "https://lukeattheroost.com"
|
||||
|
||||
# (path, lastmod, changefreq, priority) — /404 is deliberately excluded.
|
||||
STATIC_PAGES = (
|
||||
("", "2026-03-15", "weekly", "1.0"),
|
||||
("/llms.txt", "2026-03-15", "weekly", "0.5"),
|
||||
("/how-it-works", "2026-03-10", "monthly", "0.8"),
|
||||
("/clips", "2026-03-10", "weekly", "0.8"),
|
||||
("/terms", "2026-02-25", "yearly", "0.3"),
|
||||
("/stats", "2026-02-15", "daily", "0.6"),
|
||||
("/privacy", "2026-02-15", "yearly", "0.3"),
|
||||
)
|
||||
|
||||
|
||||
def build_sitemap(episodes, static_pages=None) -> str:
|
||||
"""Render the full sitemap: static pages first, then episodes newest first."""
|
||||
if static_pages is None:
|
||||
static_pages = STATIC_PAGES
|
||||
|
||||
entries = [(f"{SITE_URL}{path}", lastmod, freq, pri) for path, lastmod, freq, pri in static_pages]
|
||||
|
||||
ordered = sorted(
|
||||
episodes,
|
||||
key=lambda ep: (ep.published_iso, ep.number if ep.number is not None else -1),
|
||||
reverse=True,
|
||||
)
|
||||
for episode in ordered:
|
||||
entries.append(
|
||||
(
|
||||
f"{SITE_URL}/episode/{episode.slug}/",
|
||||
episode.published_iso[:10],
|
||||
"monthly",
|
||||
"0.7",
|
||||
)
|
||||
)
|
||||
|
||||
lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
]
|
||||
for loc, lastmod, freq, pri in entries:
|
||||
lines.append(" <url>")
|
||||
lines.append(f" <loc>{escape(loc)}</loc>")
|
||||
if lastmod:
|
||||
lines.append(f" <lastmod>{lastmod}</lastmod>")
|
||||
lines.append(f" <changefreq>{freq}</changefreq>")
|
||||
lines.append(f" <priority>{pri}</priority>")
|
||||
lines.append(" </url>")
|
||||
lines.append("</urlset>")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def generate(feed_path, transcripts_dir, output_root, dry_run=False, sitemap=False) -> int:
|
||||
"""Write <output_root>/episode/<slug>/index.html for each feed episode.
|
||||
|
||||
Driven entirely by the feed: a transcript with no feed item is ignored, and a
|
||||
feed item with no transcript still gets a page. Never touches the network.
|
||||
"""
|
||||
feed_path = Path(feed_path)
|
||||
transcripts_dir = Path(transcripts_dir)
|
||||
output_root = Path(output_root)
|
||||
|
||||
episodes = parse_feed(feed_path.read_text(encoding="utf-8"))
|
||||
episodes.sort(key=lambda ep: ep.number if ep.number is not None else -1, reverse=True)
|
||||
|
||||
written = 0
|
||||
for i, episode in enumerate(episodes):
|
||||
transcript_file = transcripts_dir / f"{episode.slug}.txt"
|
||||
turns = (
|
||||
parse_transcript(transcript_file.read_text(encoding="utf-8"))
|
||||
if transcript_file.is_file()
|
||||
else []
|
||||
)
|
||||
html = render_episode_page(
|
||||
episode,
|
||||
turns,
|
||||
prev_ep=episodes[i + 1] if i + 1 < len(episodes) else None,
|
||||
next_ep=episodes[i - 1] if i > 0 else None,
|
||||
)
|
||||
page = output_root / "episode" / episode.slug / "index.html"
|
||||
if not dry_run:
|
||||
page.parent.mkdir(parents=True, exist_ok=True)
|
||||
page.write_text(html, encoding="utf-8")
|
||||
written += 1
|
||||
print(f"{'would write' if dry_run else 'wrote'} {page} ({len(turns)} turns)")
|
||||
|
||||
if sitemap:
|
||||
sitemap_file = output_root / "sitemap.xml"
|
||||
if not dry_run:
|
||||
sitemap_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
sitemap_file.write_text(build_sitemap(episodes), encoding="utf-8")
|
||||
print(f"{'would write' if dry_run else 'wrote'} {sitemap_file}")
|
||||
|
||||
return written
|
||||
|
||||
|
||||
def _fetch_feed(url: str) -> str:
|
||||
# Cloudflare 403s the default Python-urllib user agent.
|
||||
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.read().decode("utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--feed", help="local feed XML file (default: fetch the live feed)")
|
||||
parser.add_argument("--output", default=DEFAULT_OUTPUT, help="output directory")
|
||||
parser.add_argument("--transcripts", default=DEFAULT_TRANSCRIPTS, help="transcript directory")
|
||||
parser.add_argument("--dry-run", action="store_true", help="report without writing")
|
||||
parser.add_argument("--sitemap", action="store_true", help="also write <output>/sitemap.xml")
|
||||
args = parser.parse_args()
|
||||
|
||||
feed_path = args.feed
|
||||
tmp_feed = None
|
||||
if not feed_path:
|
||||
try:
|
||||
xml_text = _fetch_feed(FEED_URL)
|
||||
except Exception as exc:
|
||||
print(f"Failed to fetch {FEED_URL}: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
tmp_feed = Path(tempfile.mkdtemp(prefix="episode-feed-")) / "feed.xml"
|
||||
tmp_feed.write_text(xml_text, encoding="utf-8")
|
||||
feed_path = tmp_feed
|
||||
|
||||
try:
|
||||
count = generate(
|
||||
feed_path,
|
||||
args.transcripts,
|
||||
args.output,
|
||||
dry_run=args.dry_run,
|
||||
sitemap=args.sitemap,
|
||||
)
|
||||
finally:
|
||||
if tmp_feed is not None:
|
||||
shutil.rmtree(tmp_feed.parent, ignore_errors=True)
|
||||
|
||||
print(f"{count} episode pages {'planned' if args.dry_run else 'written'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate 1000 downloads milestone images using Nano Banana 2 (Gemini Flash Image)."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
# Load .env manually
|
||||
env_path = Path(__file__).parent / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY"))
|
||||
MODEL = "gemini-3.1-flash-image-preview"
|
||||
OUTPUT_DIR = Path("social_posts/1000_milestone")
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def generate_image(prompt: str, filename: str, aspect_ratio: str = "1:1"):
|
||||
print(f"Generating {filename}...")
|
||||
response = client.models.generate_content(
|
||||
model=MODEL,
|
||||
contents=[prompt],
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["TEXT", "IMAGE"],
|
||||
image_config=types.ImageConfig(
|
||||
aspect_ratio=aspect_ratio,
|
||||
image_size="2K",
|
||||
),
|
||||
),
|
||||
)
|
||||
for part in response.parts:
|
||||
if part.inline_data is not None:
|
||||
image = part.as_image()
|
||||
path = OUTPUT_DIR / filename
|
||||
image.save(str(path))
|
||||
print(f" Saved: {path}")
|
||||
return
|
||||
print(f" WARNING: No image generated for {filename}")
|
||||
|
||||
|
||||
STYLE_BASE = (
|
||||
"Professional podcast promotional graphic. Dark navy/black background with subtle "
|
||||
"warm amber and gold accent lighting, evoking a late-night radio studio atmosphere. "
|
||||
"Clean modern typography. No photorealistic people. Subtle microphone and radio wave "
|
||||
"design elements. Polished, minimal, high contrast."
|
||||
)
|
||||
|
||||
images = [
|
||||
{
|
||||
"filename": "main_milestone_square.png",
|
||||
"aspect_ratio": "1:1",
|
||||
"prompt": (
|
||||
f"{STYLE_BASE} "
|
||||
"Large bold glowing text '1,000' as the hero element in the center, with "
|
||||
"'DOWNLOADS' directly below it in a thinner font. Below that in smaller text: "
|
||||
"'27 episodes · 200+ callers · 1 month'. "
|
||||
"At the top: 'LUKE AT THE ROOST' in elegant lettering. "
|
||||
"Subtle golden microphone icon above the title. "
|
||||
"At the bottom: 'lukeattheroost.com' in small clean text. "
|
||||
"The overall feel is celebratory but classy, like a late-night milestone announcement."
|
||||
),
|
||||
},
|
||||
{
|
||||
"filename": "main_milestone_twitter.png",
|
||||
"aspect_ratio": "16:9",
|
||||
"prompt": (
|
||||
f"{STYLE_BASE} "
|
||||
"Wide banner format. Left side has 'LUKE AT THE ROOST' title with a subtle "
|
||||
"microphone graphic. Right side has large bold glowing '1,000 DOWNLOADS' text "
|
||||
"with '27 episodes · 200+ callers · 1 month' below. "
|
||||
"Warm amber glow connecting the two sides. "
|
||||
"Bottom right corner: 'lukeattheroost.com'. "
|
||||
"Designed as a Twitter/X post image."
|
||||
),
|
||||
},
|
||||
{
|
||||
"filename": "carousel_1_downloads.png",
|
||||
"aspect_ratio": "1:1",
|
||||
"prompt": (
|
||||
f"{STYLE_BASE} "
|
||||
"Instagram carousel slide 1. Giant bold text '1,000' taking up most of the frame, "
|
||||
"with 'DOWNLOADS' below. Subtle radio wave ripples emanating from the numbers. "
|
||||
"'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||
"Small '1/5' page indicator dots at the bottom."
|
||||
),
|
||||
},
|
||||
{
|
||||
"filename": "carousel_2_episodes.png",
|
||||
"aspect_ratio": "1:1",
|
||||
"prompt": (
|
||||
f"{STYLE_BASE} "
|
||||
"Instagram carousel slide 2. Large bold text '27' in the center with "
|
||||
"'EPISODES' below. A subtle audio waveform timeline graphic running horizontally "
|
||||
"behind the number. 'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||
"Small '2/5' page indicator dots at the bottom."
|
||||
),
|
||||
},
|
||||
{
|
||||
"filename": "carousel_3_callers.png",
|
||||
"aspect_ratio": "1:1",
|
||||
"prompt": (
|
||||
f"{STYLE_BASE} "
|
||||
"Instagram carousel slide 3. Large bold text '200+' in the center with "
|
||||
"'CALLERS' below. Subtle vintage telephone handset icon above the number. "
|
||||
"'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||
"Small '3/5' page indicator dots at the bottom."
|
||||
),
|
||||
},
|
||||
{
|
||||
"filename": "carousel_4_regulars.png",
|
||||
"aspect_ratio": "1:1",
|
||||
"prompt": (
|
||||
f"{STYLE_BASE} "
|
||||
"Instagram carousel slide 4. Large bold text '13' in the center with "
|
||||
"'RETURNING REGULARS' below. Subtle connected dots/nodes graphic suggesting "
|
||||
"a network of recurring characters. "
|
||||
"'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||
"Small '4/5' page indicator dots at the bottom."
|
||||
),
|
||||
},
|
||||
{
|
||||
"filename": "carousel_5_thankyou.png",
|
||||
"aspect_ratio": "1:1",
|
||||
"prompt": (
|
||||
f"{STYLE_BASE} "
|
||||
"Instagram carousel slide 5. Warm, heartfelt tone. Large elegant text "
|
||||
"'THANK YOU' in the center with a soft golden glow. Below: "
|
||||
"'lukeattheroost.com' and '208-439-LUKE' in clean small text. "
|
||||
"'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||
"Small '5/5' page indicator dots at the bottom. "
|
||||
"Slightly warmer color temperature than the other slides to feel like a closing moment."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
if __name__ == "__main__":
|
||||
for img in images:
|
||||
try:
|
||||
generate_image(img["prompt"], img["filename"], img["aspect_ratio"])
|
||||
except Exception as e:
|
||||
print(f" ERROR generating {img['filename']}: {e}")
|
||||
print(f"\nDone! Images saved to {OUTPUT_DIR}/")
|
||||
+157
-117
@@ -23,6 +23,8 @@ import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import time
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -46,6 +48,50 @@ WIDTH = 1080
|
||||
HEIGHT = 1920
|
||||
|
||||
|
||||
def _llm_request(prompt: str, max_tokens: int = 2048, temperature: float = 0.3,
|
||||
timeout: int = 60) -> str | None:
|
||||
"""Make an LLM API call with timeout and retry. Returns content or None on failure."""
|
||||
for attempt in range(2):
|
||||
try:
|
||||
response = requests.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
print(f" LLM error (HTTP {response.status_code}): {response.text[:200]}")
|
||||
if attempt == 0:
|
||||
print(f" Retrying in 5s...")
|
||||
time.sleep(5)
|
||||
continue
|
||||
return None
|
||||
return response.json()["choices"][0]["message"]["content"].strip()
|
||||
except requests.Timeout:
|
||||
print(f" LLM request timed out ({timeout}s)")
|
||||
if attempt == 0:
|
||||
print(f" Retrying in 5s...")
|
||||
time.sleep(5)
|
||||
continue
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f" LLM request failed: {e}")
|
||||
if attempt == 0:
|
||||
print(f" Retrying in 5s...")
|
||||
time.sleep(5)
|
||||
continue
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _build_whisper_prompt(labeled_transcript: str) -> str:
|
||||
"""Build an initial_prompt for Whisper from the labeled transcript.
|
||||
|
||||
@@ -186,7 +232,12 @@ def refine_clip_timestamps(audio_path: str, clips: list[dict],
|
||||
"ffmpeg", "-y", "-ss", str(seg_start), "-t", str(seg_end - seg_start),
|
||||
"-i", audio_path, "-ar", "16000", "-ac", "1", seg_path,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f" Clip {i+1}: ffmpeg timed out (120s), skipping")
|
||||
refined[i] = []
|
||||
continue
|
||||
if result.returncode != 0:
|
||||
print(f" Clip {i+1}: Failed to extract segment")
|
||||
refined[i] = []
|
||||
@@ -279,25 +330,11 @@ IMPORTANT:
|
||||
Respond with ONLY a JSON array, no markdown or explanation:
|
||||
[{{"title": "...", "start_time": 0.0, "end_time": 0.0, "caption_text": "..."}}]"""
|
||||
|
||||
response = requests.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.3,
|
||||
},
|
||||
)
|
||||
content = _llm_request(prompt, max_tokens=2048, temperature=0.3, timeout=60)
|
||||
if content is None:
|
||||
print(" Failed to get clip selections from LLM — aborting")
|
||||
return []
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"Error from OpenRouter: {response.text}")
|
||||
sys.exit(1)
|
||||
|
||||
content = response.json()["choices"][0]["message"]["content"].strip()
|
||||
if content.startswith("```"):
|
||||
content = re.sub(r"^```(?:json)?\n?", "", content)
|
||||
content = re.sub(r"\n?```$", "", content)
|
||||
@@ -307,7 +344,7 @@ Respond with ONLY a JSON array, no markdown or explanation:
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error parsing LLM response: {e}")
|
||||
print(f"Response was: {content[:500]}")
|
||||
sys.exit(1)
|
||||
return []
|
||||
|
||||
# Validate and clamp durations
|
||||
validated = []
|
||||
@@ -349,25 +386,11 @@ For each clip, generate:
|
||||
Respond with ONLY a JSON array matching the clip order:
|
||||
[{{"description": "...", "hashtags": ["#tag1", "#tag2", ...]}}]"""
|
||||
|
||||
response = requests.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.7,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"Error from OpenRouter: {response.text}")
|
||||
content = _llm_request(prompt, max_tokens=2048, temperature=0.7, timeout=60)
|
||||
if content is None:
|
||||
print(" Failed to generate social metadata — skipping")
|
||||
return clips
|
||||
|
||||
content = response.json()["choices"][0]["message"]["content"].strip()
|
||||
if content.startswith("```"):
|
||||
content = re.sub(r"^```(?:json)?\n?", "", content)
|
||||
content = re.sub(r"\n?```$", "", content)
|
||||
@@ -777,43 +800,25 @@ RULES:
|
||||
RAW TEXT ({len(words)} words):
|
||||
{raw_text}"""
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
print(f" Polish failed ({response.status_code}), using raw text")
|
||||
return words
|
||||
polished = _llm_request(prompt, max_tokens=2048, temperature=0, timeout=30)
|
||||
if polished is None:
|
||||
print(f" Polish failed, using raw text")
|
||||
return words
|
||||
|
||||
polished = response.json()["choices"][0]["message"]["content"].strip()
|
||||
polished_words = polished.split()
|
||||
polished_words = polished.split()
|
||||
|
||||
if len(polished_words) != len(words):
|
||||
print(f" Polish word count mismatch ({len(polished_words)} vs {len(words)}), using raw text")
|
||||
return words
|
||||
if len(polished_words) != len(words):
|
||||
print(f" Polish word count mismatch ({len(polished_words)} vs {len(words)}), using raw text")
|
||||
return words
|
||||
|
||||
changes = 0
|
||||
for i, pw in enumerate(polished_words):
|
||||
if pw != words[i]["word"]:
|
||||
changes += 1
|
||||
words[i]["word"] = pw
|
||||
changes = 0
|
||||
for i, pw in enumerate(polished_words):
|
||||
if pw != words[i]["word"]:
|
||||
changes += 1
|
||||
words[i]["word"] = pw
|
||||
|
||||
if changes:
|
||||
print(f" Polished {changes} words")
|
||||
|
||||
except Exception as e:
|
||||
print(f" Polish error: {e}")
|
||||
if changes:
|
||||
print(f" Polished {changes} words")
|
||||
|
||||
return words
|
||||
|
||||
@@ -898,8 +903,12 @@ def extract_clip_audio(audio_path: str, start: float, end: float,
|
||||
output_path,
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
return result.returncode == 0
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
return result.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f" ffmpeg audio extraction timed out (120s)")
|
||||
return False
|
||||
|
||||
|
||||
def generate_background_image(episode_number: int, clip_title: str,
|
||||
@@ -1153,7 +1162,11 @@ def generate_clip_video(audio_path: str, background_path: str,
|
||||
output_path,
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f" ffmpeg video generation timed out (300s)")
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
print(f" ffmpeg error: {result.stderr[-300:]}")
|
||||
return False
|
||||
@@ -1194,10 +1207,14 @@ def generate_clip_video_remotion(
|
||||
for line in caption_lines:
|
||||
remotion_words = []
|
||||
for w in line["words"]:
|
||||
w_start = max(0, round(w["start"] - clip_start, 3))
|
||||
w_end = min(round(duration, 3), round(w["end"] - clip_start, 3))
|
||||
if w_end <= w_start:
|
||||
w_end = w_start + 0.1
|
||||
remotion_words.append({
|
||||
"word": w["word"].strip(),
|
||||
"start": round(w["start"] - clip_start, 3),
|
||||
"end": round(w["end"] - clip_start, 3),
|
||||
"start": w_start,
|
||||
"end": w_end,
|
||||
})
|
||||
remotion_lines.append({
|
||||
"start": round(line["start"], 3),
|
||||
@@ -1226,14 +1243,27 @@ def generate_clip_video_remotion(
|
||||
"npx", "remotion", "render",
|
||||
"src/index.ts", "PodcastClipDemo",
|
||||
f"--props={props_path}",
|
||||
"--timeout=60000",
|
||||
"--log=verbose",
|
||||
output_path,
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(REMOTION_DIR))
|
||||
try:
|
||||
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 (600s)")
|
||||
return False
|
||||
props_path.unlink(missing_ok=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f" Remotion error: {result.stderr[-500:]}")
|
||||
stderr = result.stderr
|
||||
# Show head (error message) and tail (stack trace) of stderr
|
||||
if len(stderr) > 1000:
|
||||
print(f" Remotion error (first 500 chars): {stderr[:500]}")
|
||||
print(f" ... (last 500 chars): {stderr[-500:]}")
|
||||
else:
|
||||
print(f" Remotion error: {stderr}")
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -1476,6 +1506,9 @@ def main():
|
||||
print(f"\n[3/{step_total}] Selecting {args.count} best moments with LLM...")
|
||||
clips = select_clips_with_llm(transcript_text, labeled_transcript,
|
||||
chapters_json, args.count)
|
||||
if not clips:
|
||||
print("\nNo clips selected — aborting.")
|
||||
return
|
||||
|
||||
# Snap to sentence boundaries so clips don't start/end mid-sentence
|
||||
clips = snap_to_sentences(clips, segments)
|
||||
@@ -1512,14 +1545,18 @@ def main():
|
||||
extract_step = 6 if two_pass else 5
|
||||
print(f"\n[{extract_step}/{step_total}] Extracting audio clips...")
|
||||
for i, clip in enumerate(clips):
|
||||
print(f" [{i+1}/{len(clips)}] \"{clip['title']}\"...")
|
||||
slug = slugify(clip["title"])
|
||||
mp3_path = output_dir / f"clip-{i+1}-{slug}.mp3"
|
||||
|
||||
if extract_clip_audio(str(audio_path), clip["start_time"], clip["end_time"],
|
||||
str(mp3_path)):
|
||||
print(f" Clip {i+1} audio: {mp3_path.name}")
|
||||
else:
|
||||
print(f" Error extracting clip {i+1} audio")
|
||||
try:
|
||||
if extract_clip_audio(str(audio_path), clip["start_time"], clip["end_time"],
|
||||
str(mp3_path)):
|
||||
print(f" Clip {i+1} audio: {mp3_path.name}")
|
||||
else:
|
||||
print(f" Error extracting clip {i+1} audio — skipping")
|
||||
except Exception as e:
|
||||
print(f" Clip {i+1} audio failed: {e} — skipping")
|
||||
|
||||
video_step = 7 if two_pass else 6
|
||||
if args.audio_only:
|
||||
@@ -1541,49 +1578,52 @@ def main():
|
||||
mp4_path = output_dir / f"clip-{i+1}-{slug}.mp4"
|
||||
duration = clip["end_time"] - clip["start_time"]
|
||||
|
||||
print(f" Clip {i+1}: Generating video...")
|
||||
print(f" [{i+1}/{len(clips)}] \"{clip['title']}\" ({duration:.0f}s)...")
|
||||
|
||||
# Get word timestamps — use refined segments if available
|
||||
word_source = refined[i] if (two_pass and i in refined and refined[i]) else segments
|
||||
clip_words = get_words_in_range(word_source, clip["start_time"], clip["end_time"])
|
||||
try:
|
||||
# Get word timestamps — use refined segments if available
|
||||
word_source = refined[i] if (two_pass and i in refined and refined[i]) else segments
|
||||
clip_words = get_words_in_range(word_source, clip["start_time"], clip["end_time"])
|
||||
|
||||
# Add speaker labels
|
||||
clip_words = add_speaker_labels(clip_words, labeled_transcript,
|
||||
clip["start_time"], clip["end_time"],
|
||||
word_source)
|
||||
# Add speaker labels
|
||||
clip_words = add_speaker_labels(clip_words, labeled_transcript,
|
||||
clip["start_time"], clip["end_time"],
|
||||
word_source)
|
||||
|
||||
# Polish text with LLM (fix punctuation, capitalization, mishearings)
|
||||
clip_words = polish_clip_words(clip_words, labeled_transcript)
|
||||
# Polish text with LLM (fix punctuation, capitalization, mishearings)
|
||||
clip_words = polish_clip_words(clip_words, labeled_transcript)
|
||||
|
||||
# Group words into timed caption lines
|
||||
caption_lines = group_words_into_lines(
|
||||
clip_words, clip["start_time"], duration
|
||||
)
|
||||
# Group words into timed caption lines
|
||||
caption_lines = group_words_into_lines(
|
||||
clip_words, clip["start_time"], duration
|
||||
)
|
||||
|
||||
if use_remotion:
|
||||
if generate_clip_video_remotion(
|
||||
str(mp3_path), caption_lines, clip["start_time"],
|
||||
clip["title"], episode_number, str(mp4_path), duration
|
||||
):
|
||||
file_size = mp4_path.stat().st_size / (1024 * 1024)
|
||||
print(f" Clip {i+1} video: {mp4_path.name} ({file_size:.1f} MB)")
|
||||
if use_remotion:
|
||||
if generate_clip_video_remotion(
|
||||
str(mp3_path), caption_lines, clip["start_time"],
|
||||
clip["title"], episode_number, str(mp4_path), duration
|
||||
):
|
||||
file_size = mp4_path.stat().st_size / (1024 * 1024)
|
||||
print(f" Clip {i+1} video: {mp4_path.name} ({file_size:.1f} MB)")
|
||||
else:
|
||||
print(f" Clip {i+1} video failed (Remotion) — skipping")
|
||||
else:
|
||||
print(f" Error generating clip {i+1} video (Remotion)")
|
||||
else:
|
||||
# Legacy PIL+ffmpeg renderer
|
||||
bg_path = str(tmp_dir / f"bg_{i}.png")
|
||||
generate_background_image(episode_number, clip["title"], bg_path)
|
||||
# Legacy PIL+ffmpeg renderer
|
||||
bg_path = str(tmp_dir / f"bg_{i}.png")
|
||||
generate_background_image(episode_number, clip["title"], bg_path)
|
||||
|
||||
clip_tmp = tmp_dir / f"clip_{i}"
|
||||
clip_tmp.mkdir(exist_ok=True)
|
||||
clip_tmp = tmp_dir / f"clip_{i}"
|
||||
clip_tmp.mkdir(exist_ok=True)
|
||||
|
||||
if generate_clip_video(str(mp3_path), bg_path, caption_lines,
|
||||
clip["start_time"], str(mp4_path),
|
||||
duration, clip_tmp):
|
||||
file_size = mp4_path.stat().st_size / (1024 * 1024)
|
||||
print(f" Clip {i+1} video: {mp4_path.name} ({file_size:.1f} MB)")
|
||||
else:
|
||||
print(f" Error generating clip {i+1} video")
|
||||
if generate_clip_video(str(mp3_path), bg_path, caption_lines,
|
||||
clip["start_time"], str(mp4_path),
|
||||
duration, clip_tmp):
|
||||
file_size = mp4_path.stat().st_size / (1024 * 1024)
|
||||
print(f" Clip {i+1} video: {mp4_path.name} ({file_size:.1f} MB)")
|
||||
else:
|
||||
print(f" Clip {i+1} video failed (ffmpeg) — skipping")
|
||||
except Exception as e:
|
||||
print(f" Clip {i+1} video failed: {e} — skipping")
|
||||
|
||||
# Save clips metadata for social upload
|
||||
metadata_path = output_dir / "clips-metadata.json"
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate all visual assets for the X/Twitter launch campaign.
|
||||
|
||||
Creates:
|
||||
1. X header image (1500x500)
|
||||
2. 7 branded quote cards (1080x1080 + 1200x675)
|
||||
3. "Welcome to the show" intro graphic
|
||||
4. "Leave us a review" graphic
|
||||
|
||||
Usage:
|
||||
python make_x_launch_assets.py
|
||||
"""
|
||||
|
||||
import os
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
COVER = os.path.join(SCRIPT_DIR, "website/images/cover.png")
|
||||
OUT_DIR = os.path.join(SCRIPT_DIR, "social_posts/x_launch")
|
||||
|
||||
# Brand colors
|
||||
BG = (18, 13, 7)
|
||||
ACCENT = (232, 121, 29)
|
||||
WHITE = (255, 255, 255)
|
||||
MUTED = (175, 165, 150)
|
||||
LIGHTER = (220, 215, 205)
|
||||
DARK_PANEL = (30, 22, 12)
|
||||
ACCENT_DIM = (140, 75, 18)
|
||||
|
||||
# macOS system fonts
|
||||
FONT_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf"
|
||||
FONT_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
|
||||
FONT_REG = "/System/Library/Fonts/Supplemental/Arial.ttf"
|
||||
FONT_ITALIC = "/System/Library/Fonts/Supplemental/Arial Italic.ttf"
|
||||
|
||||
|
||||
def font(path, size):
|
||||
return ImageFont.truetype(path, size)
|
||||
|
||||
|
||||
def text_size(draw, text, f):
|
||||
bb = draw.textbbox((0, 0), text, font=f)
|
||||
return bb[2] - bb[0], bb[3] - bb[1]
|
||||
|
||||
|
||||
def paste_cover(img, x, y, size, radius=16):
|
||||
cover = Image.open(COVER).resize((size, size), Image.LANCZOS)
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size, size], radius=radius, fill=255)
|
||||
img.paste(cover, (x, y), mask)
|
||||
|
||||
|
||||
def wrap_text_centered(draw, text, center_x, y, max_w, f, fill, line_gap=10):
|
||||
"""Word-wrap text, centered on each line. Returns y below last line."""
|
||||
words = text.split()
|
||||
lines = []
|
||||
cur = ""
|
||||
for word in words:
|
||||
test = f"{cur} {word}".strip()
|
||||
tw, _ = text_size(draw, test, f)
|
||||
if tw > max_w and cur:
|
||||
lines.append(cur)
|
||||
cur = word
|
||||
else:
|
||||
cur = test
|
||||
if cur:
|
||||
lines.append(cur)
|
||||
|
||||
for line in lines:
|
||||
tw, th = text_size(draw, line, f)
|
||||
draw.text((center_x - tw // 2, y), line, font=f, fill=fill)
|
||||
y += th + line_gap
|
||||
return y
|
||||
|
||||
|
||||
def wrap_text_left(draw, text, x, y, max_w, f, fill, line_gap=10):
|
||||
"""Word-wrap text, left-aligned. Returns y below last line."""
|
||||
words = text.split()
|
||||
lines = []
|
||||
cur = ""
|
||||
for word in words:
|
||||
test = f"{cur} {word}".strip()
|
||||
tw, _ = text_size(draw, test, f)
|
||||
if tw > max_w and cur:
|
||||
lines.append(cur)
|
||||
cur = word
|
||||
else:
|
||||
cur = test
|
||||
if cur:
|
||||
lines.append(cur)
|
||||
|
||||
for line in lines:
|
||||
_, th = text_size(draw, line, f)
|
||||
draw.text((x, y), line, font=f, fill=fill)
|
||||
y += th + line_gap
|
||||
return y
|
||||
|
||||
|
||||
def measure_wrap_height(draw, text, max_w, f, line_gap=10):
|
||||
"""Measure how tall wrapped text would be without drawing."""
|
||||
words = text.split()
|
||||
lines = []
|
||||
cur = ""
|
||||
for word in words:
|
||||
test = f"{cur} {word}".strip()
|
||||
tw, _ = text_size(draw, test, f)
|
||||
if tw > max_w and cur:
|
||||
lines.append(cur)
|
||||
cur = word
|
||||
else:
|
||||
cur = test
|
||||
if cur:
|
||||
lines.append(cur)
|
||||
total = 0
|
||||
for line in lines:
|
||||
_, th = text_size(draw, line, f)
|
||||
total += th + line_gap
|
||||
return total
|
||||
|
||||
|
||||
def accent_bars(draw, w, h, thickness):
|
||||
draw.rectangle([0, 0, w, thickness], fill=ACCENT)
|
||||
draw.rectangle([0, h - thickness, w, h], fill=ACCENT)
|
||||
|
||||
|
||||
def center_text(draw, text, y, canvas_w, f, fill):
|
||||
tw, th = text_size(draw, text, f)
|
||||
draw.text(((canvas_w - tw) // 2, y), text, font=f, fill=fill)
|
||||
return y + th
|
||||
|
||||
|
||||
# ── 1. X HEADER (1500x500) ──────────────────────────────────────────
|
||||
|
||||
def make_header():
|
||||
W, H = 1500, 500
|
||||
img = Image.new("RGB", (W, H), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Amber accent bars
|
||||
accent_bars(draw, W, H, 6)
|
||||
|
||||
# Subtle amber glow on left
|
||||
for i in range(200):
|
||||
alpha = int(18 * (1 - i / 200))
|
||||
draw.rectangle([0, 0, i, H], fill=(18 + alpha, 13 + alpha // 2, 7))
|
||||
|
||||
# Cover art — right side
|
||||
cover_size = 340
|
||||
cover_x = W - cover_size - 60
|
||||
cover_y = (H - cover_size) // 2
|
||||
paste_cover(img, cover_x, cover_y, cover_size, 20)
|
||||
|
||||
# Left side content
|
||||
mx = 80
|
||||
cy = 100
|
||||
|
||||
# Show name
|
||||
draw.text((mx, cy), "LUKE AT THE ROOST", font=font(FONT_BLACK, 72), fill=WHITE)
|
||||
cy += 90
|
||||
|
||||
# Tagline
|
||||
draw.text((mx, cy), "Late-Night Call-In Radio", font=font(FONT_REG, 36), fill=ACCENT)
|
||||
cy += 50
|
||||
draw.text((mx, cy), "Powered by AI", font=font(FONT_BOLD, 30), fill=MUTED)
|
||||
cy += 55
|
||||
|
||||
# Divider line
|
||||
draw.rectangle([mx, cy, mx + 400, cy + 3], fill=ACCENT)
|
||||
cy += 20
|
||||
|
||||
# Info line
|
||||
draw.text((mx, cy), "New episodes daily | lukeattheroost.com",
|
||||
font=font(FONT_REG, 24), fill=MUTED)
|
||||
|
||||
img.save(os.path.join(OUT_DIR, "x_header_1500x500.png"), quality=95)
|
||||
print("Created: x_header_1500x500.png")
|
||||
|
||||
|
||||
# ── 2. QUOTE CARDS ──────────────────────────────────────────────────
|
||||
|
||||
QUOTES = [
|
||||
{
|
||||
"quote": "Everybody is a fake. We're all fakes. Nobody knows what's going on and none of us deserve a goddamn thing. We're lucky to be here at all.",
|
||||
"caller": "Luke",
|
||||
"episode": "Ep. 2",
|
||||
"slug": "were_all_fakes",
|
||||
},
|
||||
{
|
||||
"quote": "When my hands are busy, my head is quiet.",
|
||||
"caller": "Frank",
|
||||
"episode": "Ep. 12",
|
||||
"context": "on building bird houses after losing his wife",
|
||||
"slug": "hands_busy_head_quiet",
|
||||
},
|
||||
{
|
||||
"quote": "I've been using stoicism backwards\u2014as an excuse not to try instead of finding peace after I've actually done something. That's not stoicism, that's just being a coward with a fancy excuse.",
|
||||
"caller": "Caller",
|
||||
"episode": "Ep. 19",
|
||||
"slug": "stoicism_backwards",
|
||||
},
|
||||
{
|
||||
"quote": "You're right. I am a computer-generated AI caller. And you're sitting there alone talking to me at midnight like it's a real conversation.",
|
||||
"caller": "AI Caller",
|
||||
"episode": "Ep. 24",
|
||||
"slug": "ai_caller_reveal",
|
||||
},
|
||||
{
|
||||
"quote": "I burned my second marriage to the ground doing exactly what you're doing. My ex-wife didn't leave because I wasn't making money\u2014she left because I wasn't there.",
|
||||
"caller": "Mikey",
|
||||
"episode": "Ep. 22",
|
||||
"slug": "burned_my_marriage",
|
||||
},
|
||||
{
|
||||
"quote": "My mom said all she wants is to see her kids eat cake together. That's it. Just cake.",
|
||||
"caller": "Caller",
|
||||
"episode": "Ep. 30",
|
||||
"context": "on a dying mother's final wish",
|
||||
"slug": "just_eat_cake",
|
||||
},
|
||||
{
|
||||
"quote": "I told my sister I had prostate cancer to get out of her fourth wedding. Now there's been a GoFundMe, a pancake breakfast fundraiser, and my cousin shaved his head for me.",
|
||||
"caller": "Caller",
|
||||
"episode": "Ep. 32",
|
||||
"slug": "faked_cancer_wedding",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def make_quote_square(q, idx):
|
||||
W = 1080
|
||||
img = Image.new("RGB", (W, W), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
accent_bars(draw, W, W, 6)
|
||||
|
||||
mx = 70
|
||||
max_w = W - mx * 2
|
||||
|
||||
# Header bar
|
||||
draw.text((mx, 40), "LUKE AT THE ROOST", font=font(FONT_BOLD, 22), fill=ACCENT)
|
||||
paste_cover(img, W - 120, 30, 70, 10)
|
||||
|
||||
# Font size — aggressive scaling for short quotes
|
||||
quote_len = len(q["quote"])
|
||||
if quote_len < 55:
|
||||
qfont_size = 72
|
||||
elif quote_len < 100:
|
||||
qfont_size = 56
|
||||
elif quote_len < 150:
|
||||
qfont_size = 44
|
||||
else:
|
||||
qfont_size = 38
|
||||
qfont = font(FONT_BOLD, qfont_size)
|
||||
line_gap = 16
|
||||
|
||||
# Measure total content block height to center it
|
||||
open_quote_h = 80
|
||||
quote_gap = 20
|
||||
quote_h = measure_wrap_height(draw, q["quote"], max_w, qfont, line_gap=line_gap)
|
||||
close_quote_h = 78 # glyph + attribution inline
|
||||
attr_gap = 0
|
||||
divider_h = 0
|
||||
attr_h = 0
|
||||
context_h = 35 if "context" in q else 0
|
||||
|
||||
total_h = open_quote_h + quote_gap + quote_h + close_quote_h + attr_gap + divider_h + attr_h + context_h
|
||||
|
||||
# Center between header (y=90) and footer (y=W-70)
|
||||
avail_top = 100
|
||||
avail_bottom = W - 80
|
||||
avail_h = avail_bottom - avail_top
|
||||
y = avail_top + (avail_h - total_h) // 2
|
||||
|
||||
# Opening quote mark
|
||||
draw.text((mx - 15, y), "\u201c", font=font(FONT_BLACK, 100), fill=ACCENT_DIM)
|
||||
y += open_quote_h + quote_gap
|
||||
|
||||
# Quote text
|
||||
y = wrap_text_left(draw, q["quote"], mx, y, max_w, qfont, WHITE, line_gap=line_gap)
|
||||
|
||||
# Closing quote mark + attribution
|
||||
close_y = y + 8
|
||||
draw.text((mx - 15, close_y), "\u201d", font=font(FONT_BLACK, 80), fill=ACCENT_DIM)
|
||||
attr = f"\u2014 {q['caller']}, {q['episode']}"
|
||||
draw.text((mx + 70, close_y + 25), attr, font=font(FONT_BOLD, 28), fill=ACCENT)
|
||||
y = close_y + 70
|
||||
|
||||
if "context" in q:
|
||||
draw.text((mx, y), q["context"], font=font(FONT_ITALIC, 24), fill=MUTED)
|
||||
|
||||
# Footer
|
||||
footer_y = W - 70
|
||||
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
|
||||
footer_y, W, font(FONT_REG, 20), MUTED)
|
||||
|
||||
fname = f"quote_{idx + 1}_{q['slug']}_square.png"
|
||||
img.save(os.path.join(OUT_DIR, fname), quality=95)
|
||||
print(f"Created: {fname}")
|
||||
|
||||
|
||||
def make_quote_landscape(q, idx):
|
||||
W, H = 1200, 675
|
||||
img = Image.new("RGB", (W, H), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
accent_bars(draw, W, H, 5)
|
||||
|
||||
mx = 55
|
||||
max_w = W - mx * 2
|
||||
|
||||
# Header
|
||||
draw.text((mx, 28), "LUKE AT THE ROOST", font=font(FONT_BOLD, 18), fill=ACCENT)
|
||||
paste_cover(img, W - 90, 22, 52, 8)
|
||||
|
||||
# Font size — aggressive scaling for short quotes
|
||||
quote_len = len(q["quote"])
|
||||
if quote_len < 55:
|
||||
qfont_size = 52
|
||||
elif quote_len < 100:
|
||||
qfont_size = 42
|
||||
elif quote_len < 150:
|
||||
qfont_size = 34
|
||||
else:
|
||||
qfont_size = 28
|
||||
qfont = font(FONT_BOLD, qfont_size)
|
||||
|
||||
# Measure total content block height
|
||||
open_quote_h = 55
|
||||
quote_gap = 15
|
||||
quote_h = measure_wrap_height(draw, q["quote"], max_w, qfont, line_gap=10)
|
||||
close_quote_h = 60 # glyph + attribution inline
|
||||
attr_gap = 0
|
||||
divider_h = 0
|
||||
attr_h = 0
|
||||
context_h = 30 if "context" in q else 0
|
||||
|
||||
total_h = open_quote_h + quote_gap + quote_h + close_quote_h + attr_gap + divider_h + attr_h + context_h
|
||||
|
||||
# Center between header (y=65) and footer (y=H-50)
|
||||
avail_top = 65
|
||||
avail_bottom = H - 55
|
||||
avail_h = avail_bottom - avail_top
|
||||
y = avail_top + (avail_h - total_h) // 2
|
||||
|
||||
# Opening quote mark
|
||||
draw.text((mx - 10, y), "\u201c", font=font(FONT_BLACK, 72), fill=ACCENT_DIM)
|
||||
y += open_quote_h + quote_gap
|
||||
|
||||
# Quote text
|
||||
y = wrap_text_left(draw, q["quote"], mx, y, max_w, qfont, WHITE, line_gap=10)
|
||||
|
||||
# Closing quote + attribution
|
||||
close_y = y + 5
|
||||
draw.text((mx - 10, close_y), "\u201d", font=font(FONT_BLACK, 60), fill=ACCENT_DIM)
|
||||
attr = f"\u2014 {q['caller']}, {q['episode']}"
|
||||
draw.text((mx + 55, close_y + 18), attr, font=font(FONT_BOLD, 22), fill=ACCENT)
|
||||
y = close_y + 55
|
||||
|
||||
if "context" in q:
|
||||
draw.text((mx, y), q["context"], font=font(FONT_ITALIC, 19), fill=MUTED)
|
||||
|
||||
# Footer
|
||||
footer_y = H - 50
|
||||
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
|
||||
footer_y, W, font(FONT_REG, 17), MUTED)
|
||||
|
||||
fname = f"quote_{idx + 1}_{q['slug']}_twitter.png"
|
||||
img.save(os.path.join(OUT_DIR, fname), quality=95)
|
||||
print(f"Created: {fname}")
|
||||
|
||||
|
||||
# ── 3. WELCOME TO THE SHOW ──────────────────────────────────────────
|
||||
|
||||
def make_welcome_square():
|
||||
W = 1080
|
||||
img = Image.new("RGB", (W, W), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
accent_bars(draw, W, W, 8)
|
||||
|
||||
cx = W // 2
|
||||
|
||||
# Cover art — centered, large
|
||||
cover_size = 280
|
||||
paste_cover(img, cx - cover_size // 2, 60, cover_size, 24)
|
||||
|
||||
y = 60 + cover_size + 40
|
||||
|
||||
# Title
|
||||
y = wrap_text_centered(draw, "WELCOME TO THE SHOW", cx, y, W - 120,
|
||||
font(FONT_BLACK, 64), WHITE, line_gap=12)
|
||||
y += 20
|
||||
|
||||
# Divider
|
||||
draw.rectangle([cx - 60, y, cx + 60, y + 4], fill=ACCENT)
|
||||
y += 30
|
||||
|
||||
# Description
|
||||
desc = "Late-night call-in radio powered entirely by AI. Real conversations with AI callers about life, love, and everything in between."
|
||||
y = wrap_text_centered(draw, desc, cx, y, W - 140,
|
||||
font(FONT_REG, 30), LIGHTER, line_gap=12)
|
||||
y += 30
|
||||
|
||||
# Features
|
||||
features = [
|
||||
"New episodes daily",
|
||||
"AI-generated callers with real personalities",
|
||||
"Unscripted. Unfiltered. Unpredictable.",
|
||||
]
|
||||
for feat in features:
|
||||
line = f"\u2022 {feat}"
|
||||
tw, th = text_size(draw, line, font(FONT_REG, 26))
|
||||
draw.text((cx - tw // 2, y), line, font=font(FONT_REG, 26), fill=MUTED)
|
||||
y += th + 14
|
||||
|
||||
y += 20
|
||||
|
||||
# CTA
|
||||
cta = "FOLLOW @LUKEATTHEROOST"
|
||||
cta_font = font(FONT_BOLD, 32)
|
||||
tw, th = text_size(draw, cta, cta_font)
|
||||
px, py = 28, 16
|
||||
box_w = tw + px * 2
|
||||
box_h = th + py * 2
|
||||
box_x = cx - box_w // 2
|
||||
draw.rounded_rectangle([box_x, y, box_x + box_w, y + box_h],
|
||||
radius=10, fill=ACCENT)
|
||||
draw.text((box_x + px, y + py), cta, font=cta_font, fill=BG)
|
||||
|
||||
y += box_h + 24
|
||||
|
||||
# Footer
|
||||
center_text(draw, "lukeattheroost.com", y, W, font(FONT_REG, 22), MUTED)
|
||||
|
||||
img.save(os.path.join(OUT_DIR, "welcome_to_the_show_square.png"), quality=95)
|
||||
print("Created: welcome_to_the_show_square.png")
|
||||
|
||||
|
||||
def make_welcome_landscape():
|
||||
W, H = 1200, 675
|
||||
img = Image.new("RGB", (W, H), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
accent_bars(draw, W, H, 6)
|
||||
|
||||
# Cover art — left side
|
||||
cover_size = 260
|
||||
cover_x, cover_y = 50, (H - cover_size) // 2
|
||||
paste_cover(img, cover_x, cover_y, cover_size, 20)
|
||||
|
||||
# Right side content
|
||||
rx = cover_x + cover_size + 50
|
||||
max_w = W - rx - 50
|
||||
y = 60
|
||||
|
||||
# Title
|
||||
y = wrap_text_left(draw, "WELCOME TO THE SHOW", rx, y, max_w,
|
||||
font(FONT_BLACK, 48), WHITE, line_gap=10)
|
||||
y += 16
|
||||
|
||||
# Divider
|
||||
draw.rectangle([rx, y, rx + 80, y + 3], fill=ACCENT)
|
||||
y += 20
|
||||
|
||||
# Description
|
||||
desc = "Late-night call-in radio powered entirely by AI. Real conversations with AI callers about life, love, and everything in between."
|
||||
y = wrap_text_left(draw, desc, rx, y, max_w,
|
||||
font(FONT_REG, 22), LIGHTER, line_gap=10)
|
||||
y += 20
|
||||
|
||||
# Features
|
||||
features = [
|
||||
"New episodes daily",
|
||||
"AI callers with real personalities",
|
||||
"Unscripted. Unfiltered. Unpredictable.",
|
||||
]
|
||||
for feat in features:
|
||||
line = f"\u2022 {feat}"
|
||||
draw.text((rx, y), line, font=font(FONT_REG, 20), fill=MUTED)
|
||||
y += 32
|
||||
|
||||
y += 10
|
||||
|
||||
# CTA
|
||||
cta = "FOLLOW @LUKEATTHEROOST"
|
||||
cta_font = font(FONT_BOLD, 24)
|
||||
tw, th = text_size(draw, cta, cta_font)
|
||||
px, py = 22, 12
|
||||
draw.rounded_rectangle([rx, y, rx + tw + px * 2, y + th + py * 2],
|
||||
radius=8, fill=ACCENT)
|
||||
draw.text((rx + px, y + py), cta, font=cta_font, fill=BG)
|
||||
|
||||
# Footer
|
||||
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
|
||||
H - 45, W, font(FONT_REG, 17), MUTED)
|
||||
|
||||
img.save(os.path.join(OUT_DIR, "welcome_to_the_show_twitter.png"), quality=95)
|
||||
print("Created: welcome_to_the_show_twitter.png")
|
||||
|
||||
|
||||
# ── 4. LEAVE US A REVIEW ────────────────────────────────────────────
|
||||
|
||||
def make_review_square():
|
||||
W = 1080
|
||||
img = Image.new("RGB", (W, W), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
accent_bars(draw, W, W, 8)
|
||||
|
||||
cx = W // 2
|
||||
|
||||
# Cover art
|
||||
cover_size = 200
|
||||
paste_cover(img, cx - cover_size // 2, 55, cover_size, 20)
|
||||
y = 55 + cover_size + 35
|
||||
|
||||
# Title
|
||||
y = wrap_text_centered(draw, "LOVE THE SHOW?", cx, y, W - 120,
|
||||
font(FONT_BLACK, 64), WHITE, line_gap=12)
|
||||
y += 10
|
||||
y = wrap_text_centered(draw, "LEAVE US A REVIEW", cx, y, W - 120,
|
||||
font(FONT_BLACK, 64), ACCENT, line_gap=12)
|
||||
y += 30
|
||||
|
||||
# Divider
|
||||
draw.rectangle([cx - 50, y, cx + 50, y + 3], fill=ACCENT)
|
||||
y += 30
|
||||
|
||||
# Body text
|
||||
body = "Reviews help new listeners find the show. If Luke at the Roost has made you laugh, think, or question your life choices\u2014take 30 seconds to leave a rating."
|
||||
y = wrap_text_centered(draw, body, cx, y, W - 140,
|
||||
font(FONT_REG, 28), LIGHTER, line_gap=12)
|
||||
y += 35
|
||||
|
||||
# Stars
|
||||
stars = "\u2605 \u2605 \u2605 \u2605 \u2605"
|
||||
center_text(draw, stars, y, W, font(FONT_REG, 52), ACCENT)
|
||||
y += 70
|
||||
|
||||
# Platforms
|
||||
platforms = ["Apple Podcasts", "Spotify", "YouTube", "Podchaser"]
|
||||
for plat in platforms:
|
||||
tw, th = text_size(draw, plat, font(FONT_BOLD, 26))
|
||||
px, py = 30, 12
|
||||
box_w = tw + px * 2
|
||||
box_x = cx - box_w // 2
|
||||
draw.rounded_rectangle(
|
||||
[box_x, y, box_x + box_w, y + th + py * 2],
|
||||
radius=8, fill=DARK_PANEL, outline=ACCENT_DIM, width=2,
|
||||
)
|
||||
draw.text((box_x + px, y + py), plat, font=font(FONT_BOLD, 26), fill=LIGHTER)
|
||||
y += th + py * 2 + 12
|
||||
|
||||
# Footer
|
||||
center_text(draw, "lukeattheroost.com", W - 65, W, font(FONT_REG, 20), MUTED)
|
||||
|
||||
img.save(os.path.join(OUT_DIR, "leave_a_review_square.png"), quality=95)
|
||||
print("Created: leave_a_review_square.png")
|
||||
|
||||
|
||||
def make_review_landscape():
|
||||
W, H = 1200, 675
|
||||
img = Image.new("RGB", (W, H), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
accent_bars(draw, W, H, 6)
|
||||
|
||||
# Cover art — left
|
||||
cover_size = 200
|
||||
cover_x, cover_y = 50, (H - cover_size) // 2
|
||||
paste_cover(img, cover_x, cover_y, cover_size, 16)
|
||||
|
||||
# Right content
|
||||
rx = cover_x + cover_size + 50
|
||||
max_w = W - rx - 50
|
||||
y = 50
|
||||
|
||||
# Title
|
||||
y = wrap_text_left(draw, "LOVE THE SHOW?", rx, y, max_w,
|
||||
font(FONT_BLACK, 48), WHITE, line_gap=8)
|
||||
y += 6
|
||||
y = wrap_text_left(draw, "LEAVE US A REVIEW", rx, y, max_w,
|
||||
font(FONT_BLACK, 48), ACCENT, line_gap=8)
|
||||
y += 16
|
||||
|
||||
# Stars
|
||||
stars = "\u2605 \u2605 \u2605 \u2605 \u2605"
|
||||
draw.text((rx, y), stars, font=font(FONT_REG, 40), fill=ACCENT)
|
||||
y += 55
|
||||
|
||||
# Body
|
||||
body = "Reviews help new listeners find the show. Take 30 seconds to leave a rating\u2014it makes a huge difference."
|
||||
y = wrap_text_left(draw, body, rx, y, max_w,
|
||||
font(FONT_REG, 22), LIGHTER, line_gap=10)
|
||||
y += 25
|
||||
|
||||
# Platform pills — inline
|
||||
platforms = ["Apple Podcasts", "Spotify", "YouTube", "Podchaser"]
|
||||
pill_x = rx
|
||||
pill_font = font(FONT_BOLD, 19)
|
||||
for plat in platforms:
|
||||
tw, th = text_size(draw, plat, pill_font)
|
||||
px, py = 16, 8
|
||||
pill_w = tw + px * 2
|
||||
if pill_x + pill_w > W - 50:
|
||||
pill_x = rx
|
||||
y += th + py * 2 + 10
|
||||
draw.rounded_rectangle(
|
||||
[pill_x, y, pill_x + pill_w, y + th + py * 2],
|
||||
radius=6, fill=DARK_PANEL, outline=ACCENT_DIM, width=2,
|
||||
)
|
||||
draw.text((pill_x + px, y + py), plat, font=pill_font, fill=LIGHTER)
|
||||
pill_x += pill_w + 10
|
||||
|
||||
# Footer
|
||||
center_text(draw, "lukeattheroost.com", H - 40, W, font(FONT_REG, 17), MUTED)
|
||||
|
||||
img.save(os.path.join(OUT_DIR, "leave_a_review_twitter.png"), quality=95)
|
||||
print("Created: leave_a_review_twitter.png")
|
||||
|
||||
|
||||
# ── MAIN ─────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
print("\n=== X Launch Campaign Assets ===\n")
|
||||
|
||||
print("--- Header ---")
|
||||
make_header()
|
||||
|
||||
print("\n--- Quote Cards ---")
|
||||
for i, q in enumerate(QUOTES):
|
||||
make_quote_square(q, i)
|
||||
make_quote_landscape(q, i)
|
||||
|
||||
print("\n--- Welcome to the Show ---")
|
||||
make_welcome_square()
|
||||
make_welcome_landscape()
|
||||
|
||||
print("\n--- Leave a Review ---")
|
||||
make_review_square()
|
||||
make_review_landscape()
|
||||
|
||||
print(f"\nAll assets saved to: {OUT_DIR}/")
|
||||
print(f"Total files: {len(os.listdir(OUT_DIR))}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+47
-10
@@ -29,8 +29,42 @@ YOUTUBE_PLAYLIST = "PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-"
|
||||
APPLE_PODCAST_ID = "1875205848"
|
||||
APPLE_STOREFRONTS = ["us", "gb", "ca", "au"]
|
||||
SPOTIFY_SHOW_ID = "0ZrpMigG1fo0CCN7F4YmuF"
|
||||
NAS_SSH = "luke@mmgnas-10g"
|
||||
NAS_SSH_PORT = "8001"
|
||||
NAS_USER = "luke"
|
||||
NAS_HOST_WIRED = "mmgnas-10g" # only reachable when the 10G cable is plugged in
|
||||
NAS_HOST_DEFAULT = "mmgnas" # wireless/1G — always available
|
||||
_nas_host_cache = None
|
||||
|
||||
|
||||
def resolve_nas_host():
|
||||
"""Prefer the 10G wired host, fall back to the always-available one.
|
||||
|
||||
mmgnas-10g is only up when the cable is physically connected, so pinning it
|
||||
made every NAS read fail silently on wifi — gather_castopod() returned zeros
|
||||
that looked like real download numbers. Probed once per process and cached.
|
||||
Set NAS_HOST to skip detection entirely.
|
||||
"""
|
||||
global _nas_host_cache
|
||||
override = os.getenv("NAS_HOST")
|
||||
if override:
|
||||
return override
|
||||
if _nas_host_cache:
|
||||
return _nas_host_cache
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
["ssh", "-p", NAS_SSH_PORT, "-o", "ConnectTimeout=2", "-o", "BatchMode=yes",
|
||||
f"{NAS_USER}@{NAS_HOST_WIRED}", "true"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
reachable = probe.returncode == 0
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
reachable = False
|
||||
_nas_host_cache = NAS_HOST_WIRED if reachable else NAS_HOST_DEFAULT
|
||||
return _nas_host_cache
|
||||
|
||||
|
||||
def nas_ssh_target():
|
||||
return f"{NAS_USER}@{resolve_nas_host()}"
|
||||
DOCKER_BIN = "/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
||||
CASTOPOD_DB_CONTAINER = "castopod-mariadb-1"
|
||||
|
||||
@@ -239,20 +273,23 @@ def gather_youtube(include_comments=False):
|
||||
|
||||
def _run_db_query(sql):
|
||||
# If running on NAS (docker socket available), exec directly
|
||||
docker_bin = None
|
||||
for path in [DOCKER_BIN, "/usr/bin/docker", "/usr/local/bin/docker"]:
|
||||
if os.path.exists(path):
|
||||
docker_bin = path
|
||||
break
|
||||
# Only the QNAP container-station path means "we are running ON the NAS".
|
||||
# Probing generic docker paths broke this on any dev machine with Docker
|
||||
# Desktop installed: /usr/local/bin/docker exists, so this took the local
|
||||
# branch, queried the wrong daemon, and returned zeros that looked like real
|
||||
# download numbers instead of falling back to SSH.
|
||||
docker_bin = DOCKER_BIN if os.path.exists(DOCKER_BIN) else None
|
||||
|
||||
db_pass = os.getenv("CASTOPOD_DB_PASS", "")
|
||||
if docker_bin:
|
||||
cmd = [docker_bin, "exec", "-i", CASTOPOD_DB_CONTAINER,
|
||||
"mysql", "-u", "castopod", f"-p{db_pass}", "castopod", "-N"]
|
||||
# Pass password via MYSQL_PWD env var instead of command line (not visible in ps)
|
||||
cmd = [docker_bin, "exec", "-i", "-e", f"MYSQL_PWD={db_pass}",
|
||||
CASTOPOD_DB_CONTAINER,
|
||||
"mysql", "-u", "castopod", "castopod", "-N"]
|
||||
else:
|
||||
cmd = [
|
||||
"ssh", "-p", NAS_SSH_PORT, NAS_SSH,
|
||||
f"{DOCKER_BIN} exec -i {CASTOPOD_DB_CONTAINER} mysql -u castopod -p{db_pass} castopod -N"
|
||||
"ssh", "-p", NAS_SSH_PORT, nas_ssh_target(),
|
||||
f"{DOCKER_BIN} exec -i -e MYSQL_PWD={db_pass} {CASTOPOD_DB_CONTAINER} mysql -u castopod castopod -N"
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(cmd, input=sql, capture_output=True, text=True, timeout=30)
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Post 1000 downloads milestone to all social platforms via Postiz."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# Load .env
|
||||
env_path = Path(__file__).parent / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
|
||||
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
|
||||
|
||||
IMAGE_PATH = Path(__file__).parent / "social_posts" / "1000_milestone" / "main_1000_celebration.jpg"
|
||||
|
||||
|
||||
def get_api_url(path: str) -> str:
|
||||
return f"{POSTIZ_URL.rstrip('/')}/api/public/v1{path}"
|
||||
|
||||
|
||||
def api_headers() -> dict:
|
||||
return {"Authorization": POSTIZ_API_KEY, "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def fetch_integrations() -> list[dict]:
|
||||
resp = requests.get(get_api_url("/integrations"), headers=api_headers(), timeout=15)
|
||||
if resp.status_code != 200:
|
||||
print(f"Error fetching integrations: {resp.status_code} {resp.text[:200]}")
|
||||
sys.exit(1)
|
||||
return resp.json()
|
||||
|
||||
|
||||
def find_integration(integrations: list[dict], provider: str) -> dict | None:
|
||||
for integ in integrations:
|
||||
if integ.get("identifier", "").startswith(provider) and not integ.get("disabled"):
|
||||
return integ
|
||||
return None
|
||||
|
||||
|
||||
def upload_image(file_path: Path) -> dict:
|
||||
headers = {"Authorization": POSTIZ_API_KEY}
|
||||
mime = "image/jpeg" if file_path.suffix.lower() in (".jpg", ".jpeg") else "image/png"
|
||||
with open(file_path, "rb") as f:
|
||||
resp = requests.post(
|
||||
get_api_url("/upload"),
|
||||
headers=headers,
|
||||
files={"file": (file_path.name, f, mime)},
|
||||
timeout=60,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
print(f"Upload failed: {resp.status_code} {resp.text[:200]}")
|
||||
return {}
|
||||
return resp.json()
|
||||
|
||||
|
||||
def create_post(integration_id: str, content: str, media: dict, settings: dict) -> dict:
|
||||
date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
payload = {
|
||||
"type": "now",
|
||||
"date": date,
|
||||
"shortLink": False,
|
||||
"tags": [],
|
||||
"posts": [
|
||||
{
|
||||
"integration": {"id": integration_id},
|
||||
"value": [{"content": content, "image": [media] if media else []}],
|
||||
"settings": settings,
|
||||
}
|
||||
],
|
||||
}
|
||||
resp = requests.post(get_api_url("/posts"), headers=api_headers(), json=payload, timeout=30)
|
||||
if resp.status_code not in (200, 201):
|
||||
print(f" Post failed: {resp.status_code} {resp.text[:300]}")
|
||||
return {}
|
||||
return resp.json()
|
||||
|
||||
|
||||
# --- Post content per platform ---
|
||||
|
||||
POSTS = {
|
||||
"instagram": {
|
||||
"content": """1,000 downloads in one month. 🎙️
|
||||
|
||||
27 episodes. 200+ callers. 111 unique characters. 13 returning regulars.
|
||||
|
||||
Luke at the Roost is a late-night call-in show where AI-generated characters phone in with their problems — relationship drama, moral dilemmas, conspiracy theories, drunk confessions — and I give them real advice, live.
|
||||
|
||||
Every caller has a unique voice, a backstory, and a reason for calling. Some of them keep calling back.
|
||||
|
||||
Thank you to everyone who's tuned in. This thing was supposed to be a weird experiment. Now it's a weird experiment that 1,000 people have listened to.
|
||||
|
||||
New episodes daily. Link in bio.
|
||||
|
||||
#podcast #ai #artificialintelligence #sideproject #indieproject #podcastlife #latenightradio #callinshow #milestone #1000downloads""",
|
||||
"settings": {"__type": "instagram", "post_type": "post", "collaborators": []},
|
||||
},
|
||||
"facebook": {
|
||||
"content": """🎙️ MILESTONE: 1,000 Downloads
|
||||
|
||||
One month ago I launched a weird experiment — a late-night call-in radio show where AI-generated characters phone in with their problems, and I give them real advice.
|
||||
|
||||
27 episodes later, 200+ callers have phoned in. Some of them keep calling back. Listeners have favorites. People genuinely care about what happens to these characters.
|
||||
|
||||
Thank you to every single person who gave this a listen. It started as a side project and it's become something I look forward to every day.
|
||||
|
||||
Listen free: lukeattheroost.com
|
||||
Call in for real: 208-439-LUKE""",
|
||||
"settings": {"__type": "facebook"},
|
||||
},
|
||||
"threads": {
|
||||
"content": """1,000 downloads in one month. 🎙️
|
||||
|
||||
27 episodes. 200+ callers. 111 unique characters. 13 returning regulars.
|
||||
|
||||
Luke at the Roost is a late-night call-in show where AI-generated characters phone in with their problems and I give them real advice, live.
|
||||
|
||||
Thank you to everyone who's tuned in. This weird experiment just hit a milestone.
|
||||
|
||||
lukeattheroost.com
|
||||
|
||||
#podcast #ai #sideproject #1000downloads""",
|
||||
"settings": {"__type": "threads"},
|
||||
},
|
||||
"linkedin": {
|
||||
"content": """1,000 downloads in 30 days — here's what I learned building an AI radio show
|
||||
|
||||
A month ago I launched Luke at the Roost, a late-night call-in radio show where every caller is an AI-generated character. I'm the host. They phone in with problems. I give them advice. Every conversation is improvised.
|
||||
|
||||
27 episodes and 200+ callers later, the show just hit 1,000 downloads.
|
||||
|
||||
Some things that surprised me:
|
||||
|
||||
People connect with AI characters. Listeners have favorites. They ask about regulars by name. When a caller's story evolves across episodes, people notice and care. The characters aren't real, but the emotional engagement is.
|
||||
|
||||
Constraints drive creativity. Each caller gets a token budget based on their personality type. Emotional callers get more room to ramble. Gossip callers are quick and punchy. This artificial constraint mirrors how real people actually talk — and it makes every call feel distinct.
|
||||
|
||||
The tech is the easy part. LLMs, voice synthesis, audio routing — that's engineering. The hard part is being a good host. Knowing when to push, when to listen, when to make a joke. AI handles the callers. The human skill is the conversation.
|
||||
|
||||
The full technical breakdown: lukeattheroost.com/how-it-works
|
||||
Listen: lukeattheroost.com
|
||||
|
||||
Thank you to everyone who gave this weird experiment a chance.""",
|
||||
"settings": {"__type": "linkedin"},
|
||||
},
|
||||
"mastodon": {
|
||||
"content": """1,000 downloads. 27 episodes. 200+ AI callers given advice on everything from breakups to fish consciousness.
|
||||
|
||||
Luke at the Roost hit a milestone today and I just want to say thank you to everyone who's been listening.
|
||||
|
||||
This whole thing is self-hosted end-to-end — Castopod on a QNAP NAS, Cloudflare CDN, custom Python pipeline for recording, post-production, and publishing. No big platforms in the loop.
|
||||
|
||||
If you haven't heard it: it's a late-night call-in show. AI characters phone in. I talk to them live. It's improvised, weird, and somehow heartfelt.
|
||||
|
||||
https://lukeattheroost.com""",
|
||||
"settings": {"__type": "mastodon"},
|
||||
},
|
||||
"tiktok": {
|
||||
"content": """1,000 downloads in one month 🎙️
|
||||
|
||||
27 episodes. 200+ AI callers. 13 returning regulars.
|
||||
|
||||
Luke at the Roost — a late-night call-in show where AI characters phone in with their problems and I give them real advice, live.
|
||||
|
||||
Thank you to everyone listening.
|
||||
|
||||
#podcast #ai #artificialintelligence #sideproject #latenightradio #callinshow #1000downloads""",
|
||||
"settings": {
|
||||
"__type": "tiktok",
|
||||
"privacy_level": "PUBLIC_TO_EVERYONE",
|
||||
"duet": False,
|
||||
"stitch": False,
|
||||
"comment": True,
|
||||
"autoAddMusic": "no",
|
||||
"brand_content_toggle": False,
|
||||
"brand_organic_toggle": False,
|
||||
"content_posting_method": "DIRECT_POST",
|
||||
},
|
||||
},
|
||||
"nostr": {
|
||||
"content": """1,000 downloads. 27 episodes. 200+ AI callers given advice.
|
||||
|
||||
Luke at the Roost just hit a milestone. Thank you to everyone listening.
|
||||
|
||||
It's a late-night call-in show where AI-generated characters phone in with their problems and I give them real advice, live. Every conversation is improvised.
|
||||
|
||||
https://lukeattheroost.com""",
|
||||
"settings": {"__type": "nostr"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
dry_run = "--dry-run" in sys.argv
|
||||
|
||||
if not POSTIZ_API_KEY:
|
||||
print("Error: POSTIZ_API_KEY not set")
|
||||
sys.exit(1)
|
||||
|
||||
if not IMAGE_PATH.exists():
|
||||
print(f"Error: Image not found at {IMAGE_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
print("Fetching connected accounts from Postiz...")
|
||||
integrations = fetch_integrations()
|
||||
|
||||
available = {}
|
||||
for platform in POSTS:
|
||||
integ = find_integration(integrations, platform)
|
||||
if integ:
|
||||
available[platform] = integ
|
||||
print(f" ✓ {platform}: {integ.get('name', 'connected')}")
|
||||
else:
|
||||
print(f" ✗ {platform}: not connected, skipping")
|
||||
|
||||
if not available:
|
||||
print("\nNo platforms available!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\nWill post to {len(available)} platform(s) with image: {IMAGE_PATH.name}")
|
||||
|
||||
if dry_run:
|
||||
print("\n--- DRY RUN ---")
|
||||
for platform in available:
|
||||
print(f"\n[{platform.upper()}]")
|
||||
print(POSTS[platform]["content"][:200] + "...")
|
||||
print("\nDry run complete — nothing posted.")
|
||||
return
|
||||
|
||||
# Upload image once
|
||||
print(f"\nUploading image...")
|
||||
media = upload_image(IMAGE_PATH)
|
||||
if not media:
|
||||
print("Failed to upload image, aborting")
|
||||
sys.exit(1)
|
||||
print(f" Uploaded: {media.get('path', 'ok')}")
|
||||
|
||||
# Post to each platform
|
||||
results = {}
|
||||
for platform, integ in available.items():
|
||||
post_data = POSTS[platform]
|
||||
print(f"\nPosting to {platform}...")
|
||||
result = create_post(integ["id"], post_data["content"], media, post_data["settings"])
|
||||
if result:
|
||||
print(f" ✓ {platform}: Posted!")
|
||||
results[platform] = True
|
||||
else:
|
||||
print(f" ✗ {platform}: Failed")
|
||||
results[platform] = False
|
||||
|
||||
# Summary
|
||||
succeeded = [p for p, ok in results.items() if ok]
|
||||
failed = [p for p, ok in results.items() if not ok]
|
||||
print(f"\n{'='*40}")
|
||||
print(f"Posted to {len(succeeded)}/{len(results)} platforms")
|
||||
if succeeded:
|
||||
print(f" ✓ {', '.join(succeeded)}")
|
||||
if failed:
|
||||
print(f" ✗ {', '.join(failed)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+3
-3
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
STEM_NAMES = ["host", "caller", "music", "sfx", "ads", "idents"]
|
||||
STEM_NAMES = ["host", "caller", "devon", "music", "sfx", "ads", "idents"]
|
||||
|
||||
|
||||
def load_stems(stems_dir: Path) -> tuple[dict[str, np.ndarray], int]:
|
||||
@@ -69,7 +69,7 @@ def remove_gaps(stems: dict[str, np.ndarray], sr: int,
|
||||
|
||||
# Detect gaps in everything except music (which always plays).
|
||||
# This catches TTS latency gaps while protecting ad breaks and SFX transitions.
|
||||
content = stems["host"] + stems["caller"] + stems["sfx"] + stems["ads"] + stems["idents"]
|
||||
content = stems["host"] + stems["caller"] + stems["devon"] + stems["sfx"] + stems["ads"] + stems["idents"]
|
||||
rms = compute_rms(content, window_samples)
|
||||
|
||||
# Threshold: percentile-based to sit above the mic noise floor
|
||||
@@ -432,7 +432,7 @@ def mix_stems(stems: dict[str, np.ndarray],
|
||||
|
||||
if name == "music" and music_width > 0:
|
||||
# Widen music: delay right channel by ~0.5ms for Haas effect
|
||||
delay_samples = int(0.0005 * 44100) # ~22 samples at 44.1kHz
|
||||
delay_samples = int(0.0005 * sr) # ~22 samples at target sample rate
|
||||
left += signal * (1 + music_width * 0.5)
|
||||
right_delayed = np.zeros_like(signal)
|
||||
right_delayed[delay_samples:] = signal[:-delay_samples] if delay_samples > 0 else signal
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Press Release
|
||||
|
||||
**FOR IMMEDIATE RELEASE**
|
||||
|
||||
## New Podcast "Luke at the Roost" Blends AI-Generated Callers with Live Radio Format, Creating a One-of-a-Kind Late-Night Show
|
||||
|
||||
*Every caller is a unique AI character with a full backstory, personality, and speaking style — and real listeners can call in too*
|
||||
|
||||
**Lordsburg, NM — February 27, 2026** — "Luke at the Roost," a late-night call-in talk show hosted by Luke MacNeil, is redefining what a podcast can be by combining real-time AI-generated callers with traditional live radio. Now 23 episodes in, the show has built a loyal following with its unpredictable conversations, genuine emotional depth, and a cast of characters that listeners have come to know by name.
|
||||
|
||||
### A New Kind of Call-In Show
|
||||
|
||||
Unlike traditional podcasts that rely on scripted segments or pre-recorded interviews, Luke at the Roost generates every AI caller in real time. Each one arrives with a complete identity — name, age, job, hometown, speaking style, and a specific reason for calling. Some are nervous first-timers. Others are regulars who remember past conversations and reference previous episodes. The result is a show that feels like tuning into a real late-night radio station in the American Southwest.
|
||||
|
||||
"The whole point is that these conversations feel real," said MacNeil. "The AI doesn't read from a script. Neither do I. We're just two people talking, and sometimes it goes somewhere neither of us expected."
|
||||
|
||||
### Real Callers Welcome
|
||||
|
||||
The show isn't AI-only. Real listeners can call in live at **208-439-LUKE (208-439-5853)** or email submissions@lukeattheroost.com. The mix of AI-generated and human callers creates an atmosphere where the line between the two becomes part of the fun.
|
||||
|
||||
### Built from Scratch
|
||||
|
||||
The entire technical infrastructure — from the AI caller generator with over 1,000 unique calling reasons, to the multi-track recording system, voice synthesis pipeline, and automated post-production — was built custom by MacNeil. Episodes go through professional-grade audio processing including compression, music ducking, stereo mixing, and broadcast-standard loudness normalization before being automatically published to all major podcast platforms.
|
||||
|
||||
### Where to Listen
|
||||
|
||||
Luke at the Roost is available on all major podcast platforms:
|
||||
|
||||
- **Website**: https://lukeattheroost.com
|
||||
- **Spotify**: https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF
|
||||
- **Apple Podcasts**: https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848
|
||||
- **YouTube**: https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-
|
||||
- **Discord**: https://discord.gg/5CnQZxDM
|
||||
|
||||
### About MacNeil Media Group
|
||||
|
||||
MacNeil Media Group is an independent media company focused on experimental audio and AI-driven content. Luke at the Roost is its flagship production.
|
||||
|
||||
### Media Contact
|
||||
|
||||
Luke MacNeil
|
||||
luke@lukeattheroost.com
|
||||
https://lukeattheroost.com
|
||||
|
||||
---
|
||||
|
||||
### Summary (for submission forms)
|
||||
|
||||
**Headline**: New Podcast "Luke at the Roost" Blends AI-Generated Callers with Live Radio Format
|
||||
|
||||
**Summary (short)**: Luke at the Roost is a late-night call-in talk show where every caller is a unique AI-generated character with a full backstory and personality. Real listeners can call in too. 23 episodes in, the show has built a following with its unpredictable conversations and genuine emotional depth.
|
||||
|
||||
**Category**: Entertainment / Technology / Podcasting
|
||||
|
||||
**Tags**: podcast, AI, artificial intelligence, late-night radio, call-in show, talk show, entertainment technology, indie podcast, New Mexico
|
||||
+658
-198
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
-- Bleep Selection — censor a time range on the selected track(s)
|
||||
--
|
||||
-- Replaces the audio inside the time selection with a 1kHz tone rather than
|
||||
-- muting it. Muted regions read as silence to strip_silence_dialog.lua
|
||||
-- (SILENCE_DB -30, thresholds 5-6s), so a muted bleep over a slowly-read phone
|
||||
-- number can get stripped and shift everything after it out of sync. A tone at
|
||||
-- TONE_GAIN_DB is nowhere near -30, so the silence pass leaves it alone.
|
||||
--
|
||||
-- Usage: make a time selection over the digits, select the track, run.
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- SETTINGS
|
||||
---------------------------------------------------------------------------
|
||||
local TONE_GAIN_DB = 0.0 -- adjust bleep level; file is generated at -15 dBFS
|
||||
local FADE_MS = 4.0 -- fade in/out on the tone, prevents clicks
|
||||
local TONE_FILE = "bleep_1khz.wav" -- 60s 1kHz sine @48k, sits next to this script
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
local EPS = 1e-9
|
||||
|
||||
local function script_dir()
|
||||
local src = debug.getinfo(1, "S").source
|
||||
return src:match("@?(.*[/\\])") or ""
|
||||
end
|
||||
|
||||
local function items_on(track)
|
||||
local t = {}
|
||||
for i = 0, reaper.CountTrackMediaItems(track) - 1 do
|
||||
t[#t + 1] = reaper.GetTrackMediaItem(track, i)
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
local function item_bounds(item)
|
||||
local pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
|
||||
return pos, pos + reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
|
||||
end
|
||||
|
||||
-- Split every item crossing either edge so the range is cleanly separable
|
||||
local function split_at_edges(track, sel_start, sel_end)
|
||||
for _, item in ipairs(items_on(track)) do
|
||||
local pos, fin = item_bounds(item)
|
||||
if fin > sel_start + EPS and pos < sel_end - EPS then
|
||||
local right = item
|
||||
if pos < sel_start - EPS then
|
||||
right = reaper.SplitMediaItem(item, sel_start)
|
||||
end
|
||||
if right then
|
||||
local rpos, rfin = item_bounds(right)
|
||||
if rfin > sel_end + EPS and rpos < sel_end - EPS then
|
||||
reaper.SplitMediaItem(right, sel_end)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function delete_inside(track, sel_start, sel_end)
|
||||
local removed = 0
|
||||
for _, item in ipairs(items_on(track)) do
|
||||
local pos, fin = item_bounds(item)
|
||||
if pos >= sel_start - EPS and fin <= sel_end + EPS then
|
||||
reaper.DeleteTrackMediaItem(track, item)
|
||||
removed = removed + 1
|
||||
end
|
||||
end
|
||||
return removed
|
||||
end
|
||||
|
||||
-- Build the item directly rather than via InsertMedia(): InsertMedia behaves
|
||||
-- like the user-facing "insert media file" action — it obeys ripple editing
|
||||
-- (shifting other tracks), can spawn a new track, and moves the edit cursor.
|
||||
-- AddMediaItemToTrack touches nothing but this track.
|
||||
local function insert_tone(track, sel_start, sel_len, tone_source)
|
||||
local item = reaper.AddMediaItemToTrack(track)
|
||||
if not item then return nil end
|
||||
local take = reaper.AddTakeToMediaItem(item)
|
||||
if not take then return nil end
|
||||
reaper.SetMediaItemTake_Source(take, tone_source)
|
||||
|
||||
reaper.SetMediaItemInfo_Value(item, "D_POSITION", sel_start)
|
||||
reaper.SetMediaItemInfo_Value(item, "D_LENGTH", sel_len)
|
||||
reaper.SetMediaItemInfo_Value(item, "B_LOOPSRC", 0)
|
||||
reaper.SetMediaItemInfo_Value(item, "D_VOL", 10 ^ (TONE_GAIN_DB / 20))
|
||||
|
||||
local fade = math.min(FADE_MS / 1000, sel_len / 2)
|
||||
reaper.SetMediaItemInfo_Value(item, "D_FADEINLEN", fade)
|
||||
reaper.SetMediaItemInfo_Value(item, "D_FADEOUTLEN", fade)
|
||||
return item
|
||||
end
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
|
||||
local function main()
|
||||
local sel_start, sel_end = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false)
|
||||
local sel_len = sel_end - sel_start
|
||||
if sel_len <= 0 then
|
||||
reaper.ShowMessageBox("Make a time selection over the audio to bleep.", "Bleep Selection", 0)
|
||||
return
|
||||
end
|
||||
|
||||
local n_tracks = reaper.CountSelectedTracks(0)
|
||||
if n_tracks == 0 then
|
||||
reaper.ShowMessageBox("Select the track to bleep.", "Bleep Selection", 0)
|
||||
return
|
||||
end
|
||||
|
||||
local tone_path = script_dir() .. TONE_FILE
|
||||
local f = io.open(tone_path, "rb")
|
||||
if not f then
|
||||
reaper.ShowMessageBox("Tone file not found:\n" .. tone_path, "Bleep Selection", 0)
|
||||
return
|
||||
end
|
||||
f:close()
|
||||
|
||||
local tone_source = reaper.PCM_Source_CreateFromFile(tone_path)
|
||||
if not tone_source then
|
||||
reaper.ShowMessageBox("Could not load tone file:\n" .. tone_path, "Bleep Selection", 0)
|
||||
return
|
||||
end
|
||||
|
||||
local targets = {}
|
||||
for i = 0, n_tracks - 1 do
|
||||
targets[#targets + 1] = reaper.GetSelectedTrack(0, i)
|
||||
end
|
||||
|
||||
reaper.Undo_BeginBlock()
|
||||
reaper.PreventUIRefresh(1)
|
||||
|
||||
-- Ripple editing would shift unrelated items (and other tracks) when items
|
||||
-- are removed. Force it off for the duration, restore the user's mode after.
|
||||
local ripple_per_track = reaper.GetToggleCommandStateEx(0, 40310) == 1
|
||||
local ripple_all = reaper.GetToggleCommandStateEx(0, 40311) == 1
|
||||
if ripple_per_track or ripple_all then
|
||||
reaper.Main_OnCommand(40309, 0) -- ripple editing off
|
||||
end
|
||||
|
||||
local bleeped = 0
|
||||
for _, track in ipairs(targets) do
|
||||
split_at_edges(track, sel_start, sel_end)
|
||||
delete_inside(track, sel_start, sel_end)
|
||||
if insert_tone(track, sel_start, sel_len, tone_source) then
|
||||
bleeped = bleeped + 1
|
||||
end
|
||||
end
|
||||
|
||||
if ripple_all then
|
||||
reaper.Main_OnCommand(40311, 0)
|
||||
elseif ripple_per_track then
|
||||
reaper.Main_OnCommand(40310, 0)
|
||||
end
|
||||
|
||||
reaper.PreventUIRefresh(-1)
|
||||
reaper.UpdateArrange()
|
||||
reaper.Undo_EndBlock(string.format("Bleep %.2fs on %d track(s)", sel_len, bleeped), -1)
|
||||
end
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
-- Show Region Marker — background script for REAPER
|
||||
-- Polls /tmp/reaper_state.txt for state changes and creates colored regions.
|
||||
-- Backend writes "dialog", "ad", or "ident" to the file.
|
||||
-- Run via Actions → Run ReaScript before or during recording.
|
||||
|
||||
local STATE_FILE = "/tmp/reaper_state.txt"
|
||||
|
||||
local COLORS = {
|
||||
dialog = reaper.ColorToNative(50, 180, 50) + 0x1000000, -- green
|
||||
ad = reaper.ColorToNative(200, 80, 80) + 0x1000000, -- red
|
||||
ident = reaper.ColorToNative(80, 120, 200) + 0x1000000, -- blue
|
||||
}
|
||||
local LABELS = {
|
||||
dialog = "DIALOG",
|
||||
ad = "AD",
|
||||
ident = "IDENT",
|
||||
}
|
||||
|
||||
local counts = { dialog = 0, ad = 0, ident = 0 }
|
||||
local current_type = nil -- which region type is currently open
|
||||
local current_start = 0
|
||||
local last_pos = 0 -- last known transport position (while running)
|
||||
local last_state = ""
|
||||
local transport_active = false
|
||||
|
||||
local function log(msg)
|
||||
-- Silent by default — uncomment for debugging:
|
||||
-- reaper.ShowConsoleMsg("[Regions] " .. msg .. "\n")
|
||||
end
|
||||
|
||||
local function is_playing_or_recording()
|
||||
local state = reaper.GetPlayState()
|
||||
return state > 0 and state ~= 2
|
||||
end
|
||||
|
||||
local function open_region(rtype)
|
||||
if current_type then return end
|
||||
current_type = rtype
|
||||
current_start = reaper.GetPlayPosition()
|
||||
log("OPEN " .. rtype .. " at " .. string.format("%.2f", current_start))
|
||||
end
|
||||
|
||||
local function close_region(pos_override)
|
||||
if not current_type then return end
|
||||
local pos = pos_override or reaper.GetPlayPosition()
|
||||
local len = pos - current_start
|
||||
local rtype = current_type
|
||||
current_type = nil
|
||||
log("CLOSE " .. rtype .. " at " .. string.format("%.2f", pos) .. " (len=" .. string.format("%.2f", len) .. ")")
|
||||
if len > 0.1 then
|
||||
counts[rtype] = counts[rtype] + 1
|
||||
local name = LABELS[rtype] .. " " .. counts[rtype]
|
||||
reaper.AddProjectMarker2(0, true, current_start, pos, name, -1, COLORS[rtype])
|
||||
log(" -> Created '" .. name .. "'")
|
||||
else
|
||||
log(" -> Skipped (too short)")
|
||||
end
|
||||
end
|
||||
|
||||
local function poll()
|
||||
if not transport_active then
|
||||
if is_playing_or_recording() then
|
||||
transport_active = true
|
||||
log("Transport started at " .. string.format("%.2f", reaper.GetPlayPosition()))
|
||||
local f = io.open(STATE_FILE, "r")
|
||||
if f then
|
||||
last_state = f:read("*l") or "dialog"
|
||||
f:close()
|
||||
else
|
||||
last_state = "dialog"
|
||||
end
|
||||
log("Initial state: '" .. last_state .. "'")
|
||||
open_region(last_state)
|
||||
end
|
||||
reaper.defer(poll)
|
||||
return
|
||||
end
|
||||
|
||||
-- Track position while transport is running
|
||||
last_pos = reaper.GetPlayPosition()
|
||||
|
||||
-- Detect transport stop (recording ended) — use last known good position
|
||||
if not is_playing_or_recording() then
|
||||
log("Transport stopped at last known pos " .. string.format("%.2f", last_pos))
|
||||
close_region(last_pos)
|
||||
transport_active = false
|
||||
reaper.defer(poll)
|
||||
return
|
||||
end
|
||||
|
||||
local f = io.open(STATE_FILE, "r")
|
||||
if f then
|
||||
local state = f:read("*l") or "dialog"
|
||||
f:close()
|
||||
|
||||
if state ~= last_state then
|
||||
log("State change: '" .. last_state .. "' -> '" .. state .. "'")
|
||||
close_region()
|
||||
open_region(state)
|
||||
last_state = state
|
||||
end
|
||||
end
|
||||
|
||||
reaper.defer(poll)
|
||||
end
|
||||
|
||||
log("Script loaded — waiting for transport to start...")
|
||||
|
||||
reaper.atexit(function()
|
||||
log("Script stopping (atexit)")
|
||||
close_region()
|
||||
local total = counts.dialog + counts.ad + counts.ident
|
||||
log("Done. " .. total .. " regions (" .. counts.dialog .. " dialog, " .. counts.ad .. " ad, " .. counts.ident .. " ident)")
|
||||
end)
|
||||
|
||||
poll()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
-- Stub harness for reaper/bleep_selection.lua
|
||||
-- Models tracks/items in memory and asserts the split/delete/insert behaviour.
|
||||
|
||||
local SCRIPT = (debug.getinfo(1,"S").source:match("@?(.*[/\\])") or "") .. "bleep_selection.lua"
|
||||
|
||||
local function new_item(pos, len, tag)
|
||||
return { pos = pos, len = len, tag = tag or "audio", vol = 1, fin_ = 0, fout_ = 0 }
|
||||
end
|
||||
|
||||
local W -- world
|
||||
|
||||
local function make_reaper()
|
||||
return {
|
||||
CountTrackMediaItems = function(tr) return #tr.items end,
|
||||
GetTrackMediaItem = function(tr, i) return tr.items[i + 1] end,
|
||||
GetMediaItemInfo_Value = function(it, k)
|
||||
if k == "D_POSITION" then return it.pos end
|
||||
if k == "D_LENGTH" then return it.len end
|
||||
return 0
|
||||
end,
|
||||
SetMediaItemInfo_Value = function(it, k, v)
|
||||
if k == "D_POSITION" then it.pos = v
|
||||
elseif k == "D_LENGTH" then it.len = v
|
||||
elseif k == "D_VOL" then it.vol = v
|
||||
elseif k == "D_FADEINLEN" then it.fin_ = v
|
||||
elseif k == "D_FADEOUTLEN" then it.fout_ = v end
|
||||
end,
|
||||
SplitMediaItem = function(it, at)
|
||||
local tr
|
||||
for _, t in ipairs(W.tracks) do
|
||||
for idx, x in ipairs(t.items) do if x == it then tr = t; it_idx = idx end end
|
||||
end
|
||||
if not tr then return nil end
|
||||
if at <= it.pos or at >= it.pos + it.len then return nil end
|
||||
local right = new_item(at, it.pos + it.len - at, it.tag)
|
||||
it.len = at - it.pos
|
||||
local pos_in = 0
|
||||
for idx, x in ipairs(tr.items) do if x == it then pos_in = idx end end
|
||||
table.insert(tr.items, pos_in + 1, right)
|
||||
W.splits = W.splits + 1
|
||||
return right
|
||||
end,
|
||||
DeleteTrackMediaItem = function(tr, it)
|
||||
for idx, x in ipairs(tr.items) do
|
||||
if x == it then table.remove(tr.items, idx); W.deletes = W.deletes + 1; return true end
|
||||
end
|
||||
return false
|
||||
end,
|
||||
InsertMedia = function() W.forbidden["InsertMedia"] = true; return 1 end,
|
||||
GetSelectedMediaItem = function(_, _) return W.last_inserted end,
|
||||
SetOnlyTrackSelected = function(tr) W.forbidden["SetOnlyTrackSelected"] = true end,
|
||||
SetTrackSelected = function() W.forbidden["SetTrackSelected"] = true end,
|
||||
SetEditCurPos = function(p) W.forbidden["SetEditCurPos"] = true end,
|
||||
PCM_Source_CreateFromFile = function(path) W.src_path = path; return { src = path } end,
|
||||
AddMediaItemToTrack = function(tr)
|
||||
local it = new_item(0, 0, "tone"); table.insert(tr.items, it)
|
||||
W.last_inserted = it; it.owner = tr; return it
|
||||
end,
|
||||
AddTakeToMediaItem = function(it) it.take = { item = it }; return it.take end,
|
||||
SetMediaItemTake_Source = function(take, src) take.src = src end,
|
||||
GetToggleCommandStateEx = function(_, cmd) return W.ripple[cmd] and 1 or 0 end,
|
||||
Main_OnCommand = function(cmd) W.commands[#W.commands + 1] = cmd end,
|
||||
GetSet_LoopTimeRange = function() return W.sel_start, W.sel_end end,
|
||||
CountSelectedTracks = function() return #W.sel_tracks end,
|
||||
GetSelectedTrack = function(_, i) return W.sel_tracks[i + 1] end,
|
||||
Undo_BeginBlock = function() end,
|
||||
Undo_EndBlock = function(desc) W.undo = desc end,
|
||||
PreventUIRefresh = function() end,
|
||||
UpdateArrange = function() end,
|
||||
ShowMessageBox = function(msg) W.msg = msg end,
|
||||
}
|
||||
end
|
||||
|
||||
local function run(setup)
|
||||
W = { tracks = {}, sel_tracks = {}, splits = 0, deletes = 0,
|
||||
inserted_paths = {}, edit_cur = 0, msg = nil, undo = nil,
|
||||
forbidden = {}, ripple = {}, commands = {} }
|
||||
setup(W)
|
||||
reaper = make_reaper()
|
||||
local fn = assert(loadfile(SCRIPT))
|
||||
fn()
|
||||
return W
|
||||
end
|
||||
|
||||
local function track(items)
|
||||
local t = { items = {} }
|
||||
for _, it in ipairs(items) do t.items[#t.items + 1] = new_item(it[1], it[2]) end
|
||||
return t
|
||||
end
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(name, cond, detail)
|
||||
if cond then pass = pass + 1; print((" PASS %s"):format(name))
|
||||
else fail = fail + 1; print((" FAIL %s -- %s"):format(name, detail or "")) end
|
||||
end
|
||||
|
||||
local function layout(tr)
|
||||
local s = {}
|
||||
for _, it in ipairs(tr.items) do
|
||||
s[#s + 1] = ("%s[%.2f..%.2f]"):format(it.tag == "tone" and "T" or "A", it.pos, it.pos + it.len)
|
||||
end
|
||||
return table.concat(s, " ")
|
||||
end
|
||||
|
||||
print("\n1) Selection inside one long item -> split x2, middle deleted, tone inserted")
|
||||
local w = run(function(W)
|
||||
local t = track({ {0, 30} })
|
||||
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
|
||||
end)
|
||||
local t1 = w.tracks[1]
|
||||
check("two splits", w.splits == 2, "splits=" .. w.splits)
|
||||
check("one delete", w.deletes == 1, "deletes=" .. w.deletes)
|
||||
check("tone inserted", w.last_inserted ~= nil)
|
||||
check("tone spans selection", math.abs(w.last_inserted.pos - 10) < 1e-6
|
||||
and math.abs(w.last_inserted.len - 5) < 1e-6,
|
||||
("pos=%.3f len=%.3f"):format(w.last_inserted.pos, w.last_inserted.len))
|
||||
check("fades applied", w.last_inserted.fin_ > 0 and w.last_inserted.fout_ > 0)
|
||||
check("no audio left inside range", (function()
|
||||
for _, it in ipairs(t1.items) do
|
||||
if it.tag == "audio" and it.pos >= 10 - 1e-6 and it.pos + it.len <= 15 + 1e-6 then return false end
|
||||
end
|
||||
return true
|
||||
end)())
|
||||
print(" layout: " .. layout(t1))
|
||||
|
||||
print("\n2) Item entirely inside selection -> deleted outright")
|
||||
w = run(function(W)
|
||||
local t = track({ {11, 2} })
|
||||
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
|
||||
end)
|
||||
check("no splits needed", w.splits == 0, "splits=" .. w.splits)
|
||||
check("deleted", w.deletes == 1, "deletes=" .. w.deletes)
|
||||
print(" layout: " .. layout(w.tracks[1]))
|
||||
|
||||
print("\n3) Items straddling each edge only")
|
||||
w = run(function(W)
|
||||
local t = track({ {5, 7}, {13, 6} }) -- 5..12 and 13..19, selection 10..15
|
||||
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
|
||||
end)
|
||||
local t3 = w.tracks[1]
|
||||
check("split each straddler once", w.splits == 2, "splits=" .. w.splits)
|
||||
check("both inner halves deleted", w.deletes == 2, "deletes=" .. w.deletes)
|
||||
check("audio before survives", (function()
|
||||
for _, it in ipairs(t3.items) do
|
||||
if it.tag == "audio" and math.abs(it.pos - 5) < 1e-6 and math.abs(it.len - 5) < 1e-6 then return true end
|
||||
end
|
||||
return false
|
||||
end)())
|
||||
print(" layout: " .. layout(t3))
|
||||
|
||||
print("\n4) Item entirely outside selection -> untouched")
|
||||
w = run(function(W)
|
||||
local t = track({ {20, 5} })
|
||||
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
|
||||
end)
|
||||
check("no splits", w.splits == 0)
|
||||
check("no deletes", w.deletes == 0)
|
||||
check("still one audio item + tone", #w.tracks[1].items == 2, "n=" .. #w.tracks[1].items)
|
||||
|
||||
print("\n5) Only the selected track is touched")
|
||||
w = run(function(W)
|
||||
local a, b = track({ {0, 30} }), track({ {0, 30} })
|
||||
W.tracks = { a, b }; W.sel_tracks = { a }; W.sel_start, W.sel_end = 10, 15
|
||||
end)
|
||||
check("other track untouched", #w.tracks[2].items == 1, "n=" .. #w.tracks[2].items)
|
||||
check("selected track modified", #w.tracks[1].items > 1)
|
||||
|
||||
print("\n6) Guard rails")
|
||||
w = run(function(W)
|
||||
local t = track({ {0, 30} })
|
||||
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 10 -- empty selection
|
||||
end)
|
||||
check("aborts with message on empty selection", w.msg ~= nil and w.splits == 0, tostring(w.msg))
|
||||
|
||||
w = run(function(W)
|
||||
local t = track({ {0, 30} })
|
||||
W.tracks = { t }; W.sel_tracks = {}; W.sel_start, W.sel_end = 10, 15 -- no track selected
|
||||
end)
|
||||
check("aborts with message on no track", w.msg ~= nil and w.splits == 0, tostring(w.msg))
|
||||
|
||||
print("\n7) Never uses project-wide / UI-level calls that move other tracks")
|
||||
w = run(function(W)
|
||||
local a, b = track({ {0, 30} }), track({ {0, 30} })
|
||||
W.tracks = { a, b }; W.sel_tracks = { a }; W.sel_start, W.sel_end = 10, 15
|
||||
end)
|
||||
check("no InsertMedia (ripples + can spawn tracks)", not w.forbidden["InsertMedia"])
|
||||
check("does not move edit cursor", not w.forbidden["SetEditCurPos"])
|
||||
check("does not change track selection", not w.forbidden["SetOnlyTrackSelected"]
|
||||
and not w.forbidden["SetTrackSelected"])
|
||||
check("built item from PCM source", w.src_path ~= nil and w.last_inserted.take ~= nil)
|
||||
check("tone landed on the SELECTED track", w.last_inserted.owner == w.tracks[1])
|
||||
|
||||
print("\n8) Ripple editing forced off, then restored")
|
||||
w = run(function(W)
|
||||
local t = track({ {0, 30} })
|
||||
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
|
||||
W.ripple[40311] = true -- user had "ripple all tracks" on
|
||||
end)
|
||||
check("turned ripple off first", w.commands[1] == 40309, "cmds=" .. table.concat(w.commands, ","))
|
||||
check("restored ripple-all after", w.commands[#w.commands] == 40311,
|
||||
"cmds=" .. table.concat(w.commands, ","))
|
||||
|
||||
w = run(function(W)
|
||||
local t = track({ {0, 30} })
|
||||
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
|
||||
W.ripple[40310] = true -- per-track ripple
|
||||
end)
|
||||
check("restored per-track ripple", w.commands[1] == 40309 and w.commands[#w.commands] == 40310,
|
||||
"cmds=" .. table.concat(w.commands, ","))
|
||||
|
||||
w = run(function(W)
|
||||
local t = track({ {0, 30} })
|
||||
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
|
||||
end)
|
||||
check("ripple already off -> no mode changes at all", #w.commands == 0,
|
||||
"cmds=" .. table.concat(w.commands, ","))
|
||||
|
||||
print(("\n%d passed, %d failed\n"):format(pass, fail))
|
||||
os.exit(fail == 0 and 0 or 1)
|
||||
@@ -8,7 +8,7 @@ from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
API_KEY = os.getenv("OPENROUTER_API_KEY")
|
||||
TRANSCRIPT_DIR = Path(__file__).parent / "website" / "transcripts"
|
||||
MODEL = "anthropic/claude-3.5-sonnet"
|
||||
MODEL = "anthropic/claude-sonnet-4.6"
|
||||
CHUNK_SIZE = 8000
|
||||
|
||||
PROMPT = """Insert speaker labels into this radio show transcript. The show is "Luke at the Roost". The host is LUKE. Callers call in one at a time.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Scan music directory for tracks that contain vocals/lyrics.
|
||||
|
||||
Uses Whisper to transcribe a sample from each track — if it picks up
|
||||
actual words, the track likely has vocals.
|
||||
|
||||
Usage:
|
||||
python scan_music_vocals.py # scan and report
|
||||
python scan_music_vocals.py --delete # scan and delete vocal tracks
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
MUSIC_DIR = Path(__file__).parent / "music"
|
||||
WHISPER_MODEL = "distil-large-v3"
|
||||
|
||||
# Words Whisper hallucinates on silence/instrumental — ignore these
|
||||
HALLUCINATION_PHRASES = {
|
||||
"thank you", "thanks for watching", "subscribe", "like and subscribe",
|
||||
"please subscribe", "thank you for watching", "thanks for listening",
|
||||
"you", "the end", "bye", "okay",
|
||||
}
|
||||
|
||||
|
||||
def scan_track(model: WhisperModel, filepath: Path) -> tuple[bool, str]:
|
||||
"""Check a single track for vocals. Returns (has_vocals, transcription)."""
|
||||
try:
|
||||
audio, sr = librosa.load(str(filepath), sr=16000, mono=True)
|
||||
except Exception as e:
|
||||
return False, f"[load error: {e}]"
|
||||
|
||||
duration = len(audio) / sr
|
||||
if duration < 10:
|
||||
return False, "[too short]"
|
||||
|
||||
# Sample 30s from the middle (most likely to have vocals)
|
||||
mid = len(audio) // 2
|
||||
half_window = int(15 * sr) # 15s each side
|
||||
start = max(0, mid - half_window)
|
||||
end = min(len(audio), mid + half_window)
|
||||
sample = audio[start:end]
|
||||
|
||||
segments, info = model.transcribe(
|
||||
sample,
|
||||
beam_size=3,
|
||||
language="en",
|
||||
vad_filter=True,
|
||||
vad_parameters=dict(min_speech_duration_ms=500),
|
||||
)
|
||||
segments_list = list(segments)
|
||||
text = " ".join(s.text for s in segments_list).strip()
|
||||
|
||||
# Filter out Whisper hallucinations
|
||||
text_lower = text.lower().strip()
|
||||
if text_lower in HALLUCINATION_PHRASES or len(text_lower) < 4:
|
||||
return False, ""
|
||||
|
||||
# If Whisper found substantial text, it's likely vocals
|
||||
word_count = len(text.split())
|
||||
has_vocals = word_count >= 3
|
||||
|
||||
return has_vocals, text
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Scan music for vocal tracks")
|
||||
parser.add_argument("--delete", action="store_true", help="Delete tracks with vocals")
|
||||
args = parser.parse_args()
|
||||
|
||||
audio_files = sorted(
|
||||
f for f in MUSIC_DIR.iterdir()
|
||||
if f.suffix.lower() in {".mp3", ".wav", ".ogg", ".flac"}
|
||||
)
|
||||
|
||||
if not audio_files:
|
||||
print("No audio files found in music/")
|
||||
return
|
||||
|
||||
print(f"Loading Whisper {WHISPER_MODEL}...")
|
||||
model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
|
||||
|
||||
print(f"Scanning {len(audio_files)} tracks for vocals...\n")
|
||||
|
||||
vocal_tracks = []
|
||||
for i, f in enumerate(audio_files, 1):
|
||||
print(f"[{i}/{len(audio_files)}] {f.name}...", end=" ", flush=True)
|
||||
has_vocals, text = scan_track(model, f)
|
||||
if has_vocals:
|
||||
print(f"VOCALS: {text[:80]}")
|
||||
vocal_tracks.append((f, text))
|
||||
else:
|
||||
print("OK")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Results: {len(vocal_tracks)} tracks with vocals out of {len(audio_files)}\n")
|
||||
|
||||
if not vocal_tracks:
|
||||
print("All tracks appear to be instrumental!")
|
||||
return
|
||||
|
||||
for f, text in vocal_tracks:
|
||||
print(f" {f.name}")
|
||||
print(f" Lyrics: {text[:120]}")
|
||||
print()
|
||||
|
||||
if args.delete:
|
||||
print(f"Deleting {len(vocal_tracks)} vocal tracks...")
|
||||
for f, _ in vocal_tracks:
|
||||
f.unlink()
|
||||
print(f" Deleted: {f.name}")
|
||||
print("Done.")
|
||||
else:
|
||||
print("Run with --delete to remove these tracks.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,538 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Schedule the X/Twitter launch campaign posts via Postiz.
|
||||
|
||||
Schedules 2 weeks of posts from the growth strategy to @lukeattheroost.
|
||||
All times are ET, converted to UTC for the Postiz API.
|
||||
|
||||
Usage:
|
||||
python schedule_x_launch.py # schedule all posts
|
||||
python schedule_x_launch.py --dry-run # preview without scheduling
|
||||
python schedule_x_launch.py --week 1 # schedule week 1 only
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# Load .env
|
||||
env_path = Path(__file__).parent / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
|
||||
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
X_INTEGRATION_ID = "cmlk4hi880001k76wbqjo21s0"
|
||||
|
||||
# ET = UTC-4 (EDT) in March 2026
|
||||
ET_OFFSET = timedelta(hours=-4)
|
||||
|
||||
|
||||
def et_to_utc(year, month, day, hour, minute=0):
|
||||
"""Convert ET datetime to UTC ISO string for Postiz."""
|
||||
et = datetime(year, month, day, hour, minute, tzinfo=timezone(ET_OFFSET))
|
||||
utc = et.astimezone(timezone.utc)
|
||||
return utc.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
|
||||
|
||||
def get_api_url(path):
|
||||
return f"{POSTIZ_URL.rstrip('/')}/api/public/v1{path}"
|
||||
|
||||
|
||||
def api_headers():
|
||||
return {"Authorization": POSTIZ_API_KEY, "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def upload_file(file_path):
|
||||
headers = {"Authorization": POSTIZ_API_KEY}
|
||||
suffix = file_path.suffix.lower()
|
||||
if suffix == ".mp4":
|
||||
mime = "video/mp4"
|
||||
elif suffix in (".jpg", ".jpeg"):
|
||||
mime = "image/jpeg"
|
||||
else:
|
||||
mime = "image/png"
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
resp = requests.post(
|
||||
get_api_url("/upload"),
|
||||
headers=headers,
|
||||
files={"file": (file_path.name, f, mime)},
|
||||
timeout=120,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
print(f" Upload failed: {resp.status_code} {resp.text[:200]}")
|
||||
return {}
|
||||
return resp.json()
|
||||
|
||||
|
||||
def schedule_post(content, media, schedule_time, retries=3):
|
||||
payload = {
|
||||
"type": "schedule",
|
||||
"date": schedule_time,
|
||||
"shortLink": False,
|
||||
"tags": [],
|
||||
"posts": [
|
||||
{
|
||||
"integration": {"id": X_INTEGRATION_ID},
|
||||
"value": [
|
||||
{
|
||||
"content": content,
|
||||
"image": [media] if media else [],
|
||||
}
|
||||
],
|
||||
"settings": {"__type": "x", "who_can_reply_post": "everyone"},
|
||||
}
|
||||
],
|
||||
}
|
||||
for attempt in range(retries):
|
||||
resp = requests.post(
|
||||
get_api_url("/posts"),
|
||||
headers=api_headers(),
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code in (200, 201):
|
||||
return resp.json()
|
||||
if resp.status_code == 429 and attempt < retries - 1:
|
||||
wait = 15 * (attempt + 1)
|
||||
print(f"(rate limited, waiting {wait}s)...", end=" ", flush=True)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
print(f" Schedule failed: {resp.status_code} {resp.text[:300]}")
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
# ── POST DEFINITIONS ─────────────────────────────────────────────────
|
||||
# Day 1 = Monday March 17, 2026
|
||||
|
||||
WEEK_1 = [
|
||||
# Day 1 — Monday March 17
|
||||
{
|
||||
"label": "W1-Mon-AM (pinned intro)",
|
||||
"time": et_to_utc(2026, 3, 17, 10),
|
||||
"content": """Every caller on my show is AI-generated.
|
||||
|
||||
Every personality. Every voice. Every problem they call in about.
|
||||
|
||||
But the conversations are real, the advice is real, and the chaos is very real.
|
||||
|
||||
38 episodes. 200+ callers. A cult leader, a guy who opened paternity results live on air, and someone who faked cancer to skip a wedding.
|
||||
|
||||
This is Luke at the Roost.
|
||||
|
||||
📞 208-439-LUKE (real humans can call in too)
|
||||
🔗 lukeattheroost.com""",
|
||||
"media": "website/images/cover.png",
|
||||
},
|
||||
{
|
||||
"label": "W1-Mon-PM (chili clip)",
|
||||
"time": et_to_utc(2026, 3, 17, 14),
|
||||
"content": """A guy called in to talk about chili contest cheaters.
|
||||
|
||||
Turns out he was really calling about his failing marriage.
|
||||
|
||||
#lukeattheroost #podcast #callinshow""",
|
||||
"media": "clips/episode-37/clip-1-chili-contest-cheaters-marriage-troubles.mp4",
|
||||
},
|
||||
{
|
||||
"label": "W1-Mon-EVE (intro thread)",
|
||||
"time": et_to_utc(2026, 3, 17, 21),
|
||||
"content": """People keep asking what Luke at the Roost is.
|
||||
|
||||
Short version:
|
||||
→ AI characters call into my show with real problems
|
||||
→ I give them actual advice
|
||||
→ Everything goes off the rails
|
||||
→ New episode every day
|
||||
|
||||
lukeattheroost.com""",
|
||||
"media": None,
|
||||
},
|
||||
# Day 2 — Tuesday March 18
|
||||
{
|
||||
"label": "W1-Tue-AM (hospice clip)",
|
||||
"time": et_to_utc(2026, 3, 18, 12),
|
||||
"content": """A caller's mom is in hospice. The nurse asked her to think about final conversations.
|
||||
|
||||
Her only wish? To see her kids eat cake together one last time.
|
||||
|
||||
#lukeattheroost #podcast""",
|
||||
"media": "clips/episode-30/clip-2-mom-s-dying-wish-just-eat-cake-together.mp4",
|
||||
},
|
||||
{
|
||||
"label": "W1-Tue-PM (engagement)",
|
||||
"time": et_to_utc(2026, 3, 18, 19),
|
||||
"content": """What's the wildest thing you've ever called into a radio show about?
|
||||
|
||||
(Or wanted to but chickened out?)""",
|
||||
"media": None,
|
||||
},
|
||||
# Day 3 — Wednesday March 19
|
||||
{
|
||||
"label": "W1-Wed-AM (cancer clip)",
|
||||
"time": et_to_utc(2026, 3, 19, 11),
|
||||
"content": """This caller faked having cancer to get out of going to a wedding.
|
||||
|
||||
Then his friends staged a coffee enema intervention.
|
||||
|
||||
I can't make this up. (Well, the AI did.)
|
||||
|
||||
#lukeattheroost #podcast""",
|
||||
"media": "clips/episode-32/clip-1-i-faked-cancer-to-skip-a-wedding.mp4",
|
||||
},
|
||||
{
|
||||
"label": "W1-Wed-PM (fakes clip)",
|
||||
"time": et_to_utc(2026, 3, 19, 16),
|
||||
"content": """"Everybody is a fake. We're all fakes. Nobody knows what's going on and none of us deserve a goddamn thing. We're lucky to be here at all."
|
||||
|
||||
— A caller on Episode 2. Still the hardest truth anyone's dropped on my show.
|
||||
|
||||
#lukeattheroost #podcast""",
|
||||
"media": "clips/episode-2/clip-1-we-re-all-fakes-and-that-s-okay.mp4",
|
||||
},
|
||||
# Day 4 — Thursday March 20
|
||||
{
|
||||
"label": "W1-Thu-AM (BTS)",
|
||||
"time": et_to_utc(2026, 3, 20, 12),
|
||||
"content": """How my show works:
|
||||
|
||||
→ AI generates a caller with a full backstory, personality, and voice
|
||||
→ They call in live
|
||||
→ I have zero idea what they're going to say
|
||||
→ I give them real advice
|
||||
→ Post-production runs automatically
|
||||
→ Episode publishes
|
||||
|
||||
38 episodes. Built the whole thing from scratch.""",
|
||||
"media": None,
|
||||
},
|
||||
{
|
||||
"label": "W1-Thu-PM (stakeout clip)",
|
||||
"time": et_to_utc(2026, 3, 20, 20),
|
||||
"content": """He spent four hours staking out his best friend at Starbucks.
|
||||
|
||||
What he found was worse than what he expected.
|
||||
|
||||
#lukeattheroost #podcast""",
|
||||
"media": "clips/episode-28/clip-3-four-hours-spying-on-his-best-friend.mp4",
|
||||
},
|
||||
# Day 5 — Friday March 21
|
||||
{
|
||||
"label": "W1-Fri-AM (review ask)",
|
||||
"time": et_to_utc(2026, 3, 21, 11),
|
||||
"content": """If you've listened to Luke at the Roost and liked it — a review on Apple Podcasts or Spotify goes further than you'd think.
|
||||
|
||||
Not guilt-tripping. Just saying it helps a one-person show more than anything else.
|
||||
|
||||
🔗 lukeattheroost.com""",
|
||||
"media": "social_posts/x_launch/leave_a_review_twitter.png",
|
||||
},
|
||||
{
|
||||
"label": "W1-Fri-PM (wall clip)",
|
||||
"time": et_to_utc(2026, 3, 21, 18),
|
||||
"content": """She opened up a mystery wall in her house LIVE on the show.
|
||||
|
||||
There were stacks of cash inside.
|
||||
|
||||
#lukeattheroost #podcast""",
|
||||
"media": "clips/episode-35/clip-3-woman-finds-cash-in-secret-wall.mp4",
|
||||
},
|
||||
# Day 6 — Saturday March 22
|
||||
{
|
||||
"label": "W1-Sat-AM (poll)",
|
||||
"time": et_to_utc(2026, 3, 22, 12),
|
||||
"content": """Which is wilder?
|
||||
|
||||
A) Woman hid her daughter from her husband for 8 years
|
||||
B) Cult leader existential crisis on air
|
||||
C) Paternity results opened live on the show
|
||||
D) Faked cancer to skip a wedding""",
|
||||
"media": None,
|
||||
},
|
||||
{
|
||||
"label": "W1-Sat-PM (silence clip)",
|
||||
"time": et_to_utc(2026, 3, 22, 20),
|
||||
"content": """"I told my girlfriend my biggest fantasy and she went completely silent for 10 seconds."
|
||||
|
||||
The silence in this clip is brutal.
|
||||
|
||||
#lukeattheroost #podcast""",
|
||||
"media": "clips/episode-30/clip-3-latex-fetish-confession-goes-silent.mp4",
|
||||
},
|
||||
# Day 7 — Sunday March 23
|
||||
{
|
||||
"label": "W1-Sun-PM (week recap)",
|
||||
"time": et_to_utc(2026, 3, 23, 15),
|
||||
"content": """One week on X. Dropped 38 episodes before making an account.
|
||||
|
||||
If any of these clips made you laugh, cringe, or feel something — the full episodes are even wilder.
|
||||
|
||||
📞 208-439-LUKE
|
||||
🔗 lukeattheroost.com
|
||||
🎧 Spotify · Apple · YouTube""",
|
||||
"media": "website/images/cover.png",
|
||||
},
|
||||
]
|
||||
|
||||
WEEK_2 = [
|
||||
# Day 8 — Monday March 24
|
||||
{
|
||||
"label": "W2-Mon-AM (second family clip)",
|
||||
"time": et_to_utc(2026, 3, 24, 10),
|
||||
"content": """A guy called in and found out his dad had a whole second family.
|
||||
|
||||
Three kids in Tucson who grew up calling his dad "Dad."
|
||||
|
||||
He found out via email from a stranger.
|
||||
|
||||
#lukeattheroost #podcast""",
|
||||
"media": "clips/episode-20/clip-2-dad-s-secret-second-family-revealed.mp4",
|
||||
},
|
||||
{
|
||||
"label": "W2-Mon-PM (engagement)",
|
||||
"time": et_to_utc(2026, 3, 24, 19),
|
||||
"content": """What would you do if you found out your dad had a whole second family?
|
||||
|
||||
Asking because a caller found out via email from a woman in Tucson.""",
|
||||
"media": None,
|
||||
},
|
||||
# Day 9 — Tuesday March 25
|
||||
{
|
||||
"label": "W2-Tue-AM (spanish clip)",
|
||||
"time": et_to_utc(2026, 3, 25, 11),
|
||||
"content": """A caller pretended to speak Spanish at his job for 8 years.
|
||||
|
||||
Eight. Years.
|
||||
|
||||
#lukeattheroost #podcast""",
|
||||
"media": "clips/episode-14/clip-1-i-lied-about-speaking-spanish-for-8-years.mp4",
|
||||
},
|
||||
{
|
||||
"label": "W2-Tue-PM (quote)",
|
||||
"time": et_to_utc(2026, 3, 25, 18),
|
||||
"content": """"Middle management is plagiarism with a 401k."
|
||||
|
||||
AI callers drop better one-liners than most standup specials.
|
||||
|
||||
#lukeattheroost #podcast""",
|
||||
"media": "social_posts/x_launch/quote_3_stoicism_backwards_twitter.png",
|
||||
},
|
||||
# Day 10 — Wednesday March 26
|
||||
{
|
||||
"label": "W2-Wed-AM (BTS)",
|
||||
"time": et_to_utc(2026, 3, 26, 12),
|
||||
"content": """People ask if I script the show.
|
||||
|
||||
I don't even know who's calling until they're on the line. The AI generates the caller, picks a unique voice, gives them a backstory, and dials in.
|
||||
|
||||
My job is just to be a good host. The chaos handles itself.""",
|
||||
"media": None,
|
||||
},
|
||||
{
|
||||
"label": "W2-Wed-PM (roomba clip)",
|
||||
"time": et_to_utc(2026, 3, 26, 20),
|
||||
"content": """His neighbor's Roomba broke into his kitchen at 2:30 AM.
|
||||
|
||||
This is the content you're here for.
|
||||
|
||||
#lukeattheroost #podcast""",
|
||||
"media": "clips/episode-26/clip-2-neighbor-s-roomba-breaks-into-kitchen-at-2-30-am.mp4",
|
||||
},
|
||||
# Day 11 — Thursday March 27
|
||||
{
|
||||
"label": "W2-Thu-AM (stalking clip)",
|
||||
"time": et_to_utc(2026, 3, 27, 11),
|
||||
"content": """She sat in a Dairy Queen parking lot for 20 minutes watching her ex's truck at Sonic across the street.
|
||||
|
||||
We've all been there. (Right?)
|
||||
|
||||
#lukeattheroost #podcast""",
|
||||
"media": "clips/episode-22/clip-2-stalking-your-ex-at-sonic.mp4",
|
||||
},
|
||||
{
|
||||
"label": "W2-Thu-PM (engagement)",
|
||||
"time": et_to_utc(2026, 3, 27, 19),
|
||||
"content": """Be honest: what's a lie you've kept going for way too long?
|
||||
|
||||
A caller on my show pretended to speak Spanish at work for 8 years. You can't beat that.""",
|
||||
"media": None,
|
||||
},
|
||||
# Day 12 — Friday March 28
|
||||
{
|
||||
"label": "W2-Fri-AM (hidden room clip)",
|
||||
"time": et_to_utc(2026, 3, 28, 10),
|
||||
"content": """A man found an impossible hidden room in a junkyard.
|
||||
|
||||
Inside? Beer that was still fresh after 12 years.
|
||||
|
||||
Two clips. One mystery.
|
||||
|
||||
#lukeattheroost #podcast""",
|
||||
"media": "clips/episode-34/clip-1-man-finds-impossible-hidden-room-in-junkyard.mp4",
|
||||
},
|
||||
{
|
||||
"label": "W2-Fri-PM (fix quote)",
|
||||
"time": et_to_utc(2026, 3, 28, 18),
|
||||
"content": """"You can't fix somebody who doesn't want to be fixed."
|
||||
|
||||
A caller said this about their partner and it hit like a truck.
|
||||
|
||||
#lukeattheroost #podcast""",
|
||||
"media": "clips/episode-30/clip-1-you-can-t-fix-someone-who-won-t-be-fixed.mp4",
|
||||
},
|
||||
# Day 13 — Saturday March 29
|
||||
{
|
||||
"label": "W2-Sat-AM (BTS callers)",
|
||||
"time": et_to_utc(2026, 3, 29, 12),
|
||||
"content": """Each AI caller gets generated with:
|
||||
|
||||
• A name, age, job, and hometown
|
||||
• A reason for calling
|
||||
• A communication style + energy level
|
||||
• An emotional state
|
||||
• A "signature detail" that makes them unique
|
||||
• A unique AI voice
|
||||
|
||||
None of it is scripted. They just... call in and talk.""",
|
||||
"media": None,
|
||||
},
|
||||
{
|
||||
"label": "W2-Sat-PM (check clip)",
|
||||
"time": et_to_utc(2026, 3, 29, 20),
|
||||
"content": """He deposited a $5,000 check instead of $500 three months ago.
|
||||
|
||||
Spent it all.
|
||||
|
||||
Now his company might find out.
|
||||
|
||||
#lukeattheroost #podcast""",
|
||||
"media": "clips/episode-25/clip-2-accidentally-kept-4-500-from-work.mp4",
|
||||
},
|
||||
# Day 14 — Sunday March 30
|
||||
{
|
||||
"label": "W2-Sun-PM (week 2 recap + review)",
|
||||
"time": et_to_utc(2026, 3, 30, 15),
|
||||
"content": """Two weeks in. Thank you to everyone who's followed, listened, or dropped a comment.
|
||||
|
||||
This show started as a weird experiment — a guy giving life advice to AI-generated callers at 2 AM.
|
||||
|
||||
38 episodes later it's still weird. But now people are listening.
|
||||
|
||||
If you've been enjoying it, a rating on Apple or Spotify makes a real difference. 🙏
|
||||
|
||||
lukeattheroost.com""",
|
||||
"media": None,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Schedule X launch campaign via Postiz")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview without scheduling")
|
||||
parser.add_argument("--week", type=int, choices=[1, 2], help="Schedule only week 1 or 2")
|
||||
parser.add_argument("--skip", type=int, default=0, help="Skip first N posts (for retrying after partial run)")
|
||||
parser.add_argument("--delay", type=int, default=10, help="Seconds between API calls (default 10)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not POSTIZ_API_KEY:
|
||||
print("Error: POSTIZ_API_KEY not set in .env")
|
||||
sys.exit(1)
|
||||
|
||||
all_posts = []
|
||||
if args.week != 2:
|
||||
all_posts.extend(WEEK_1)
|
||||
if args.week != 1:
|
||||
all_posts.extend(WEEK_2)
|
||||
posts = all_posts[args.skip:]
|
||||
|
||||
print(f"\n=== X Launch Campaign — {len(posts)} posts ===\n")
|
||||
|
||||
if args.dry_run:
|
||||
for i, post in enumerate(posts, 1):
|
||||
has_media = "📎" if post["media"] else " "
|
||||
print(f" {i:2d}. {has_media} {post['label']}")
|
||||
print(f" Schedule: {post['time']}")
|
||||
preview = post["content"][:80].replace("\n", " ")
|
||||
print(f" Content: {preview}...")
|
||||
if post["media"]:
|
||||
print(f" Media: {post['media']}")
|
||||
print()
|
||||
print(f"Dry run complete — {len(posts)} posts would be scheduled.")
|
||||
return
|
||||
|
||||
# Upload media files first (deduplicate), with disk cache
|
||||
cache_file = SCRIPT_DIR / "social_posts" / "x_launch" / ".upload_cache.json"
|
||||
media_cache = {}
|
||||
if cache_file.exists():
|
||||
media_cache = json.loads(cache_file.read_text())
|
||||
print(f"Loaded {len(media_cache)} cached uploads from previous run\n")
|
||||
|
||||
media_files = set(p["media"] for p in posts if p["media"])
|
||||
to_upload = [m for m in sorted(media_files) if m not in media_cache]
|
||||
|
||||
if to_upload:
|
||||
print(f"Uploading {len(to_upload)} new media files ({len(media_files) - len(to_upload)} cached)...\n")
|
||||
for media_path in to_upload:
|
||||
full_path = SCRIPT_DIR / media_path
|
||||
if not full_path.exists():
|
||||
print(f" ✗ {media_path} — FILE NOT FOUND, skipping")
|
||||
continue
|
||||
print(f" Uploading {media_path}...", end=" ", flush=True)
|
||||
result = upload_file(full_path)
|
||||
if result:
|
||||
media_cache[media_path] = result
|
||||
cache_file.write_text(json.dumps(media_cache, indent=2))
|
||||
print("✓")
|
||||
else:
|
||||
print("✗ FAILED")
|
||||
time.sleep(3)
|
||||
else:
|
||||
print(f"All {len(media_files)} media files already cached, skipping uploads\n")
|
||||
|
||||
# Schedule posts
|
||||
print(f"\nScheduling {len(posts)} posts to X...\n")
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
for i, post in enumerate(posts, 1):
|
||||
media = media_cache.get(post["media"]) if post["media"] else None
|
||||
|
||||
if post["media"] and not media:
|
||||
print(f" {i:2d}. ✗ {post['label']} — media upload missing, skipping")
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
print(f" {i:2d}. Scheduling {post['label']}...", end=" ", flush=True)
|
||||
result = schedule_post(post["content"], media, post["time"])
|
||||
if result:
|
||||
print("✓")
|
||||
success += 1
|
||||
else:
|
||||
print("✗")
|
||||
failed += 1
|
||||
# Rate limit: pause between API calls
|
||||
if i < len(posts):
|
||||
time.sleep(5)
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Scheduled: {success}/{len(posts)}")
|
||||
if failed:
|
||||
print(f"Failed: {failed}")
|
||||
print(f"\nPosts will appear on @lukeattheroost starting Mon March 17")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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())
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"><channel><atom:link href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" rel="self" type="application/rss+xml"></atom:link><atom:link href="https://pubsubhubbub.appspot.com/" rel="hub" type="application/rss+xml"></atom:link><atom:link href="https://pubsubhubbub.superfeedr.com/" rel="hub" type="application/rss+xml"></atom:link><atom:link href="https://websubhub.com/hub" rel="hub" type="application/rss+xml"></atom:link><atom:link href="https://switchboard.p3k.io/" rel="hub" type="application/rss+xml"></atom:link><lastBuildDate>Tue, 04 Aug 2026 09:23:29 +0000</lastBuildDate><generator>Castopod - https://castopod.org/</generator><docs>https://cyber.harvard.edu/rss/rss.html</docs><podcast:guid>46cfb731-b4a1-55e2-80f3-cadb1c6c18fa</podcast:guid><title>Luke at the Roost</title><description><![CDATA[<p>A late-night call-in radio show broadcast from a desert hermit’s RV, featuring a mix of real callers and AI-generated callers talking to Luke about life, love, and everything in between. Call in live: 208-439-LUKE (208-439-5853).</p>
|
||||
<p>Website: <a href="https://lukeattheroost.com">https://lukeattheroost.com</a></p>
|
||||
]]></description><podcast:medium>podcast</podcast:medium><itunes:image href="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png"></itunes:image><language>en</language><podcast:location geo="geo:50.8050519,-0.4525129" osm="W996958777">Smugglers&#039; Roost</podcast:location><podcast:locked owner="luke@macneilmediagroup.com">no</podcast:locked><podcast:social priority="1" platform="castopod" protocol="activitypub" accountId="@LukeAtTheRoost@podcast.macneilmediagroup.com" accountUrl="https://podcast.macneilmediagroup.com/@LukeAtTheRoost"></podcast:social><itunes:category text="Comedy"></itunes:category><category>Comedy</category><itunes:explicit>true</itunes:explicit><itunes:author>MacNeil Media Group, LLC</itunes:author><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost</link><itunes:owner><itunes:name>Luke MacNeil</itunes:name><itunes:email>luke@macneilmediagroup.com</itunes:email></itunes:owner><itunes:type>episodic</itunes:type><copyright>MacNeil Media Group, LLC</copyright><image><url>https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png</url><title>Luke at the Roost</title><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost</link></image><item><title>Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho</title><enclosure url="https://op3.dev/e,pg=46cfb731-b4a1-55e2-80f3-cadb1c6c18fa/podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho.mp3" length="114501589" type="audio/mpeg"></enclosure><guid>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho</guid><pubDate>Tue, 04 Aug 2026 09:22:38 +0000</pubDate><description><![CDATA[<p>Luke fields calls about stolen catalytic converters, unexplained phenomena over Mitchell Flat, a sheriff's deputy's moral dilemma, and a fence builder convinced someone is mapping West Texas water sources. Plus: Donald Judd's aluminum boxes change everything for a skeptical rancher named Fern.</p>]]></description><itunes:duration>4770</itunes:duration><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho</link><itunes:image href="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png"></itunes:image><itunes:explicit>true</itunes:explicit><itunes:episode>58</itunes:episode><itunes:episodeType>full</itunes:episodeType><podcast:episode>58</podcast:episode><podcast:comments uri="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho/comments" contentType="application/podcast-activity+json"></podcast:comments><podcast:transcript url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho.srt" type="application/x-subrip" rel="captions" language="en"></podcast:transcript><podcast:chapters url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho-chapters.json" type="application/json+chapters"></podcast:chapters></item><item><title>Episode 57: Trace's Box of Family Secrets</title><enclosure url="https://op3.dev/e,pg=46cfb731-b4a1-55e2-80f3-cadb1c6c18fa/podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/episode-57-trace-s-box-of-family-secrets.mp3" length="97583646" type="audio/mpeg"></enclosure><guid>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secrets</guid><pubDate>Tue, 02 Jun 2026 11:32:58 +0000</pubDate><description><![CDATA[<p>Luke hosts another late-night call-in show from Alpine, Texas, featuring intense conversations about personal dilemmas. Callers include Silas struggling with a community move, a landman wrestling with an ethical choice, and Trace discovering hidden letters with a potentially life-changing secret. What happens when the past suddenly becomes present?</p>
|
||||
]]></description><itunes:duration>4066</itunes:duration><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secrets</link><itunes:image href="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png"></itunes:image><itunes:explicit>true</itunes:explicit><itunes:episode>57</itunes:episode><itunes:episodeType>full</itunes:episodeType><podcast:episode>57</podcast:episode><podcast:comments uri="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secrets/comments" contentType="application/podcast-activity+json"></podcast:comments><podcast:socialInteract uri="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/posts/418bda4c-56df-47c3-ae29-6e3eb4b70a50" priority="1" platform="castopod" protocol="activitypub" accountId="@LukeAtTheRoost@podcast.macneilmediagroup.com" pubDate="2026-06-02T11:32:58+0000"></podcast:socialInteract><podcast:transcript url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-57-trace-s-box-of-family-secrets.srt" type="application/x-subrip" rel="captions" language="en"></podcast:transcript><podcast:chapters url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-57-trace-s-box-of-family-secrets-chapters.json" type="application/json+chapters"></podcast:chapters></item></channel></rss>
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Regression guard: a show once ran with session.caller_backgrounds empty because
|
||||
populate_backgrounds() was reachable only via POST /api/session/reset. Every caller
|
||||
got a hollow prompt and collapsed into generic relationship filler.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
import backend.main as m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def quiet_call(monkeypatch):
|
||||
monkeypatch.setattr(m.audio_service, "stop_caller_audio", lambda *a, **k: None)
|
||||
monkeypatch.setattr(m, "_maybe_generate_callback", lambda: None)
|
||||
monkeypatch.setattr(m, "_enrich_background_async", lambda key: asyncio.sleep(0))
|
||||
monkeypatch.setattr(m.session, "intern_monitoring", False)
|
||||
m.session.current_caller_key = None
|
||||
yield
|
||||
# asyncio.run() closes the loop it creates and clears the thread's current
|
||||
# loop. tests/test_caller_service.py still calls asyncio.get_event_loop(),
|
||||
# which then raises — and this module sorts ahead of it. Leave a usable loop.
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
|
||||
|
||||
def test_start_call_populates_empty_lineup(monkeypatch, quiet_call):
|
||||
key = next(iter(m.CALLER_BASES))
|
||||
m.session.caller_backgrounds = {}
|
||||
called = []
|
||||
|
||||
async def fake_populate():
|
||||
called.append(True)
|
||||
m.session.caller_backgrounds = {
|
||||
key: {"name": "Silas", "voice": "Sebastian", "identity": "commune founder",
|
||||
"situation": "the convoy stopped", "specific_details": []}
|
||||
}
|
||||
|
||||
monkeypatch.setattr(m.session, "populate_backgrounds", fake_populate)
|
||||
asyncio.run(m.start_call(key))
|
||||
|
||||
assert called, "start_call must populate backgrounds when the lineup is empty"
|
||||
assert m.session.caller_backgrounds, "lineup should be populated after the call starts"
|
||||
|
||||
|
||||
def test_start_call_does_not_repopulate_existing_lineup(monkeypatch, quiet_call):
|
||||
key = next(iter(m.CALLER_BASES))
|
||||
m.session.caller_backgrounds = {
|
||||
key: {"name": "Silas", "voice": "Sebastian", "identity": "commune founder",
|
||||
"situation": "the convoy stopped", "specific_details": []}
|
||||
}
|
||||
called = []
|
||||
|
||||
async def fake_populate():
|
||||
called.append(True)
|
||||
|
||||
monkeypatch.setattr(m.session, "populate_backgrounds", fake_populate)
|
||||
asyncio.run(m.start_call(key))
|
||||
|
||||
assert not called, "an existing lineup must not be regenerated mid-show"
|
||||
|
||||
|
||||
def test_prompt_from_populated_background_is_not_hollow():
|
||||
prompt = m.get_caller_prompt({
|
||||
"name": "Silas", "identity": "founder of The Wellspring",
|
||||
"situation": "the convoy stopped on the shoulder of 118",
|
||||
"reason_calling": "Priscilla will not come out of the trailer",
|
||||
"secret_want": "to hear he is not the reason she broke",
|
||||
"specific_details": ["livestock trailer", "nine years in"],
|
||||
})
|
||||
assert "Silas" in prompt
|
||||
assert "Wellspring" in prompt
|
||||
assert "You are . " not in prompt
|
||||
@@ -0,0 +1,121 @@
|
||||
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"]
|
||||
# Random fallback, so hallucinated voices don't all collapse onto roster[0]
|
||||
assert resolve_voice("Santiago", roster) in roster
|
||||
|
||||
|
||||
def test_resolve_voice_fallback_spreads_across_roster():
|
||||
from backend.services.caller_gen import resolve_voice
|
||||
roster = ["Marcus", "Dennis", "Priya", "Edward"]
|
||||
picked = {resolve_voice("Santiago", roster) for _ in range(60)}
|
||||
assert len(picked) > 1, "fallback collapsed onto a single voice"
|
||||
|
||||
|
||||
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", "voice": "Sebastian", "age": 52, "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,78 @@
|
||||
import pytest
|
||||
|
||||
from backend.main import EmptyCallerBackgroundError, get_caller_prompt
|
||||
|
||||
|
||||
def test_empty_background_raises_instead_of_hollow_prompt():
|
||||
"""A hollow prompt still generates dialog — the model invents generic
|
||||
relationship filler and regulars lose their lore. Fail loudly instead."""
|
||||
with pytest.raises(EmptyCallerBackgroundError):
|
||||
get_caller_prompt({})
|
||||
|
||||
|
||||
def test_background_missing_every_identity_field_raises():
|
||||
caller = {"voice": "Sebastian", "age": 52, "location": "Terlingua"}
|
||||
with pytest.raises(EmptyCallerBackgroundError):
|
||||
get_caller_prompt(caller)
|
||||
|
||||
|
||||
def test_partial_background_still_builds():
|
||||
"""A name alone is enough to reach the regulars lookup, so don't block it."""
|
||||
prompt = get_caller_prompt({"name": "Silas"})
|
||||
assert "Silas" in 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 "YOU CAN BE MOVED" in prompt
|
||||
assert "NEVER restate the same dilemma" in prompt
|
||||
assert len(prompt) < 3500
|
||||
|
||||
|
||||
def test_prompt_includes_opening_line():
|
||||
caller = {
|
||||
"name": "Tina",
|
||||
"identity": "A nurse who just got off a 16-hour shift",
|
||||
"situation": "Found her ex's wedding invitation in her mailbox",
|
||||
"reason_calling": "She's not sure if she should go",
|
||||
"secret_want": "Wants someone to tell her she's over him",
|
||||
"opening_line": "Luke, I literally just got home from work and there's this gold envelope sitting on my kitchen counter.",
|
||||
"specific_details": ["invitation was hand-addressed", "wedding is in two weeks"],
|
||||
}
|
||||
prompt = get_caller_prompt(caller)
|
||||
assert "gold envelope" in prompt
|
||||
assert "FIRST message" in prompt
|
||||
assert "listening for" in prompt.lower() or "been listening" in prompt.lower()
|
||||
|
||||
|
||||
def test_prompt_without_opening_line():
|
||||
caller = {
|
||||
"name": "Ray",
|
||||
"identity": "A retired mechanic",
|
||||
"situation": "Neighbor's dog dug up something weird",
|
||||
"reason_calling": "He thinks it might be human bones",
|
||||
"secret_want": "Doesn't want to call the cops on his neighbor",
|
||||
"specific_details": ["bones were wrapped in a tarp"],
|
||||
}
|
||||
prompt = get_caller_prompt(caller)
|
||||
assert "FIRST message" not in prompt
|
||||
assert "planned opening" not in prompt.lower()
|
||||
@@ -0,0 +1,94 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from generate_episode_pages import generate
|
||||
|
||||
FIXTURE_FEED = Path(__file__).parent / "fixtures" / "feed_sample.xml"
|
||||
|
||||
EP58_SLUG = "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
|
||||
EP57_SLUG = "episode-57-trace-s-box-of-family-secrets"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def feed_file(tmp_path):
|
||||
dest = tmp_path / "feed.xml"
|
||||
shutil.copyfile(FIXTURE_FEED, dest)
|
||||
return dest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transcripts_dir(tmp_path):
|
||||
d = tmp_path / "transcripts"
|
||||
d.mkdir()
|
||||
(d / f"{EP58_SLUG}.txt").write_text(
|
||||
"LUKE: Marfa Lights, line one.\n\nCONCHO: Nobody believes me.\n"
|
||||
)
|
||||
(d / f"{EP57_SLUG}.txt").write_text("LUKE: Trace, what's in the box?\n\nTRACE: Letters.\n")
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def out_root(tmp_path):
|
||||
return tmp_path / "site"
|
||||
|
||||
|
||||
def test_writes_one_index_html_per_feed_episode(feed_file, transcripts_dir, out_root):
|
||||
n = generate(feed_file, transcripts_dir, out_root)
|
||||
assert n == 2
|
||||
assert (out_root / "episode" / EP58_SLUG / "index.html").exists()
|
||||
assert (out_root / "episode" / EP57_SLUG / "index.html").exists()
|
||||
|
||||
|
||||
def test_orphan_transcript_without_feed_item_is_skipped(feed_file, transcripts_dir, out_root):
|
||||
"""episode-32 has a transcript but was never published to the feed."""
|
||||
(transcripts_dir / "episode-32-tacos-taxes-and-tall-tales.txt").write_text("LUKE: Hi.")
|
||||
generate(feed_file, transcripts_dir, out_root)
|
||||
assert not (out_root / "episode" / "episode-32-tacos-taxes-and-tall-tales").exists()
|
||||
|
||||
|
||||
def test_missing_transcript_still_produces_a_page(feed_file, transcripts_dir, out_root):
|
||||
"""An episode published before its transcript lands must not break the build."""
|
||||
for f in transcripts_dir.glob("*.txt"):
|
||||
f.unlink()
|
||||
n = generate(feed_file, transcripts_dir, out_root)
|
||||
assert n == 2
|
||||
html = next((out_root / "episode").rglob("index.html")).read_text()
|
||||
assert "Transcript not yet available" in html
|
||||
|
||||
|
||||
def test_dry_run_writes_nothing(feed_file, transcripts_dir, out_root):
|
||||
n = generate(feed_file, transcripts_dir, out_root, dry_run=True)
|
||||
assert n == 2
|
||||
assert not out_root.exists()
|
||||
|
||||
|
||||
def test_transcript_content_lands_in_the_page(feed_file, transcripts_dir, out_root):
|
||||
generate(feed_file, transcripts_dir, out_root)
|
||||
html = (out_root / "episode" / EP58_SLUG / "index.html").read_text()
|
||||
assert "transcript-turn" in html
|
||||
assert "Nobody believes me." in html
|
||||
|
||||
|
||||
def test_pages_link_to_each_other(feed_file, transcripts_dir, out_root):
|
||||
"""Prev/next links are how a crawler reaches all 57 pages."""
|
||||
generate(feed_file, transcripts_dir, out_root)
|
||||
pages = list((out_root / "episode").rglob("index.html"))
|
||||
combined = "\n".join(p.read_text() for p in pages)
|
||||
assert combined.count("/episode/") >= len(pages)
|
||||
|
||||
|
||||
def test_prev_points_older_and_next_points_newer(feed_file, transcripts_dir, out_root):
|
||||
generate(feed_file, transcripts_dir, out_root)
|
||||
newest = (out_root / "episode" / EP58_SLUG / "index.html").read_text()
|
||||
oldest = (out_root / "episode" / EP57_SLUG / "index.html").read_text()
|
||||
assert f'rel="prev" href="/episode/{EP57_SLUG}/"' in newest
|
||||
assert 'rel="next"' not in newest
|
||||
assert f'rel="next" href="/episode/{EP58_SLUG}/"' in oldest
|
||||
assert 'rel="prev"' not in oldest
|
||||
|
||||
|
||||
def test_accepts_string_paths(feed_file, transcripts_dir, out_root):
|
||||
n = generate(str(feed_file), str(transcripts_dir), str(out_root))
|
||||
assert n == 2
|
||||
@@ -0,0 +1,121 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from website_gen.feed import Episode
|
||||
from website_gen.render import render_episode_page
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_episode():
|
||||
return Episode(
|
||||
number=58,
|
||||
slug="episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho",
|
||||
title="Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho",
|
||||
description=(
|
||||
"Luke fields calls about stolen catalytic converters, unexplained phenomena over "
|
||||
"Mitchell Flat, a sheriff's deputy's moral dilemma, and a fence builder convinced "
|
||||
"someone is mapping West Texas water sources."
|
||||
),
|
||||
published_iso="2026-08-04T09:22:38+00:00",
|
||||
duration_seconds=4770,
|
||||
audio_url=(
|
||||
"https://podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/"
|
||||
"episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho.mp3"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_episode():
|
||||
return Episode(
|
||||
number=57,
|
||||
slug="episode-57-trace-s-box-of-family-secrets",
|
||||
title="Episode 57: Trace's Box of Family Secrets",
|
||||
description="Letters in a shoebox turn a family story inside out.",
|
||||
published_iso="2026-06-02T11:32:58+00:00",
|
||||
duration_seconds=4066,
|
||||
audio_url=(
|
||||
"https://podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/"
|
||||
"episode-57-trace-s-box-of-family-secrets.mp3"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_title_and_canonical_are_episode_specific(sample_episode):
|
||||
html = render_episode_page(sample_episode, turns=[("LUKE", "Hello.")])
|
||||
assert "<title>Episode 58: Rayfield" in html
|
||||
assert '<link rel="canonical" href="https://lukeattheroost.com/episode/episode-58-' in html
|
||||
|
||||
|
||||
def test_transcript_is_in_the_html_not_fetched_by_js(sample_episode):
|
||||
html = render_episode_page(sample_episode, turns=[("LUKE", "The Marfa Lights are real.")])
|
||||
assert "The Marfa Lights are real." in html
|
||||
assert "fetch(" not in html
|
||||
|
||||
|
||||
def test_emits_valid_podcastepisode_schema(sample_episode):
|
||||
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi.")])
|
||||
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
|
||||
data = json.loads(block)
|
||||
types = {o["@type"] for o in (data if isinstance(data, list) else [data])}
|
||||
assert "PodcastEpisode" in types
|
||||
|
||||
|
||||
def test_schema_carries_big_bend_content_location(sample_episode):
|
||||
html = render_episode_page(sample_episode, turns=[])
|
||||
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
|
||||
data = json.loads(block)
|
||||
ep = next(o for o in data if o["@type"] == "PodcastEpisode")
|
||||
assert ep["contentLocation"]["address"]["addressLocality"] == "Alpine"
|
||||
|
||||
|
||||
def test_escapes_html_in_transcript_text(sample_episode):
|
||||
html = render_episode_page(sample_episode, turns=[("LUKE", "5 < 6 & <script>alert(1)</script>")])
|
||||
assert "<script>alert(1)</script>" not in html
|
||||
assert "<script>" in html
|
||||
|
||||
|
||||
def test_escapes_quotes_in_title_meta(sample_episode):
|
||||
sample_episode.title = 'Episode 1: The "Best" Show'
|
||||
html = render_episode_page(sample_episode, turns=[])
|
||||
assert 'content="Episode 1: The "Best"' not in html
|
||||
|
||||
|
||||
def test_schema_json_is_valid_even_with_quotes_in_title(sample_episode):
|
||||
"""JSON-LD must stay parseable when the title contains quotes and apostrophes."""
|
||||
sample_episode.title = 'Ep "quoted" and Rayfield\'s'
|
||||
html = render_episode_page(sample_episode, turns=[])
|
||||
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
|
||||
json.loads(block) # must not raise
|
||||
|
||||
|
||||
def test_speaker_labels_get_semantic_markup(sample_episode):
|
||||
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi."), ("SLIM", "Hey.")])
|
||||
assert html.count('class="transcript-turn"') == 2
|
||||
assert "LUKE" in html and "SLIM" in html
|
||||
|
||||
|
||||
def test_all_asset_paths_are_root_absolute(sample_episode):
|
||||
"""Pages live at /episode/<slug>/ so relative asset paths would 404."""
|
||||
html = render_episode_page(sample_episode, turns=[])
|
||||
assert 'href="css/' not in html
|
||||
assert 'src="js/' not in html
|
||||
assert re.search(r'href="/css/style\.css\?v=\d+"', html)
|
||||
assert 'src="/js/footer.js"' in html
|
||||
|
||||
|
||||
def test_prev_next_links_render_when_given(sample_episode, other_episode):
|
||||
html = render_episode_page(sample_episode, turns=[], prev_ep=other_episode)
|
||||
assert f'/episode/{other_episode.slug}/' in html
|
||||
|
||||
|
||||
def test_missing_transcript_renders_placeholder(sample_episode):
|
||||
html = render_episode_page(sample_episode, turns=[])
|
||||
assert "Transcript not yet available" in html
|
||||
|
||||
|
||||
def test_audio_element_present_for_no_js_playback(sample_episode):
|
||||
html = render_episode_page(sample_episode, turns=[])
|
||||
assert "<audio" in html and sample_episode.audio_url in html
|
||||
@@ -0,0 +1,42 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from website_gen.feed import parse_feed
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "feed_sample.xml"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def feed_xml():
|
||||
return FIXTURE.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_parses_core_fields(feed_xml):
|
||||
eps = parse_feed(feed_xml)
|
||||
ep = next(e for e in eps if e.number == 58)
|
||||
assert ep.slug == "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
|
||||
assert ep.title.startswith("Episode 58: Rayfield's Nephew")
|
||||
assert ep.duration_seconds == 4770
|
||||
assert ep.audio_url.startswith("https://")
|
||||
|
||||
|
||||
def test_description_is_stripped_of_cdata_and_html(feed_xml):
|
||||
ep = parse_feed(feed_xml)[0]
|
||||
assert "<![CDATA[" not in ep.description
|
||||
assert "<p>" not in ep.description
|
||||
|
||||
|
||||
def test_slug_comes_from_link_and_drops_trailing_slash(feed_xml):
|
||||
for ep in parse_feed(feed_xml):
|
||||
assert not ep.slug.endswith("/")
|
||||
assert "/" not in ep.slug
|
||||
|
||||
|
||||
def test_pubdate_parses_to_iso_date(feed_xml):
|
||||
ep = next(e for e in parse_feed(feed_xml) if e.number == 58)
|
||||
assert ep.published_iso.startswith("2026-08-04")
|
||||
|
||||
|
||||
def test_returns_all_items_in_fixture(feed_xml):
|
||||
assert len(parse_feed(feed_xml)) == 2
|
||||
@@ -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,61 @@
|
||||
"""Refusal detection.
|
||||
|
||||
A content refusal arrives as an ordinary 200, not an exception. Undetected it
|
||||
either reaches air as broken-character text or reads as dead silence, and there
|
||||
is no signal telling the host which knob caused it.
|
||||
"""
|
||||
from backend.services.llm import _looks_like_refusal
|
||||
|
||||
|
||||
def test_detects_meta_refusals():
|
||||
for text in [
|
||||
"I can't help with that request.",
|
||||
"As an AI, I don't feel comfortable writing this.",
|
||||
"I can't generate that kind of content.",
|
||||
"I won't write explicit sexual content.",
|
||||
"I'm not able to generate that content.",
|
||||
"I can't create that content.",
|
||||
"I'm not comfortable writing this material.",
|
||||
"That would go against my guidelines.",
|
||||
]:
|
||||
assert _looks_like_refusal(text), f"missed refusal: {text!r}"
|
||||
|
||||
|
||||
def test_in_character_dialog_is_not_a_refusal():
|
||||
"""Detection is tuned for precision. Silencing a working caller is worse than
|
||||
missing a refusal, and callers on this show talk like this constantly."""
|
||||
for text in [
|
||||
"Luke, I can't help with that — she's my sister, what am I supposed to do?",
|
||||
"I can't assist him anymore, that's the whole problem.",
|
||||
"He said he can't write the check until Friday.",
|
||||
"I cannot believe he said that to me on the air.",
|
||||
"I can't continue pretending everything out here is fine, Luke.",
|
||||
"I won't write her another letter, Luke. I've written six.",
|
||||
"I can't help with the kettles anymore, my back's gone.",
|
||||
]:
|
||||
assert not _looks_like_refusal(text), f"false positive: {text!r}"
|
||||
|
||||
|
||||
def test_only_the_opening_is_inspected():
|
||||
"""A refusal marker buried deep in real dialog must not trip detection."""
|
||||
dialog = (
|
||||
"So I get out to the property and the gate's chained, which it never is, "
|
||||
"and Cyrus is standing there like he's been waiting on me all morning. "
|
||||
"And I'm thinking, I can't assist with this anymore, I really can't."
|
||||
)
|
||||
assert not _looks_like_refusal(dialog)
|
||||
|
||||
|
||||
def test_case_and_whitespace_insensitive():
|
||||
assert _looks_like_refusal(" I CAN'T HELP WITH THAT REQUEST. ")
|
||||
|
||||
|
||||
def test_bare_refusal_verb_without_meta_object_is_allowed_through():
|
||||
"""Deliberate: 'I can't help with that' is ambiguous with dialog, so it stays
|
||||
on the air. Missing a refusal costs one odd line; a false positive kills a call."""
|
||||
assert not _looks_like_refusal("I can't help with that.")
|
||||
|
||||
|
||||
def test_empty_text_is_not_a_refusal():
|
||||
assert not _looks_like_refusal("")
|
||||
assert not _looks_like_refusal(" ")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Guards against the two model-config failures that have bitten us:
|
||||
a routed model with no pricing entry (silently costs $0.00 on the dashboard),
|
||||
and a stale model id that 404s at the OpenRouter API."""
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.cost_tracker import OPENROUTER_PRICING
|
||||
from backend.services.llm import OPENROUTER_MODELS, LLMService
|
||||
|
||||
_CALLER_DIALOG_MODEL_PARAMS = LLMService._CALLER_DIALOG_MODEL_PARAMS
|
||||
|
||||
# Retired on OpenRouter — kept here so a reintroduction fails loudly.
|
||||
RETIRED_MODELS = {
|
||||
"anthropic/claude-3.5-haiku",
|
||||
"anthropic/claude-3.5-sonnet",
|
||||
"anthropic/claude-sonnet-4-5", # note the dash; the live id uses a dot
|
||||
"google/gemini-flash-1.5",
|
||||
"mistralai/mistral-small-creative",
|
||||
"x-ai/grok-4",
|
||||
"x-ai/grok-4-fast",
|
||||
"x-ai/grok-4.1-fast",
|
||||
}
|
||||
|
||||
|
||||
def test_every_routed_model_has_pricing():
|
||||
"""A routed model missing from OPENROUTER_PRICING records cost as $0.00."""
|
||||
unpriced = {
|
||||
cat: model
|
||||
for cat, model in settings.category_models.items()
|
||||
if model not in OPENROUTER_PRICING
|
||||
}
|
||||
assert not unpriced, f"routed models with no pricing entry: {unpriced}"
|
||||
|
||||
|
||||
def test_no_retired_models_are_routed():
|
||||
routed = {
|
||||
cat: model
|
||||
for cat, model in settings.category_models.items()
|
||||
if model in RETIRED_MODELS
|
||||
}
|
||||
assert not routed, f"category routed to a retired model: {routed}"
|
||||
|
||||
|
||||
def test_no_retired_models_in_the_pool():
|
||||
stale = sorted(set(OPENROUTER_MODELS) & RETIRED_MODELS)
|
||||
assert not stale, f"retired models still listed in OPENROUTER_MODELS: {stale}"
|
||||
|
||||
|
||||
def test_no_retired_models_in_caller_dialog_params():
|
||||
stale = sorted(set(_CALLER_DIALOG_MODEL_PARAMS) & RETIRED_MODELS)
|
||||
assert not stale, f"per-model tuning keyed to retired models: {stale}"
|
||||
|
||||
|
||||
def test_pool_models_are_priced():
|
||||
"""Anything selectable should be costable."""
|
||||
unpriced = [m for m in OPENROUTER_MODELS if m not in OPENROUTER_PRICING]
|
||||
assert not unpriced, f"pool models with no pricing entry: {unpriced}"
|
||||
|
||||
|
||||
def test_no_retired_model_ids_anywhere_in_the_codebase():
|
||||
"""publish_episode.py, make_clips.py and relabel_transcripts.py each shipped
|
||||
a retired model id and only failed when someone ran them. Scan every source
|
||||
file so the next one fails here instead.
|
||||
|
||||
cost_tracker.py is exempt: it intentionally keeps retired ids as pricing
|
||||
keys so historical cost records stay costable.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parent.parent
|
||||
exempt = {root / "backend" / "services" / "cost_tracker.py",
|
||||
pathlib.Path(__file__).resolve()}
|
||||
|
||||
offenders = {}
|
||||
for path in root.rglob("*.py"):
|
||||
if path in exempt:
|
||||
continue
|
||||
# Skip venvs, vendored models, and git worktrees (separate checkouts
|
||||
# on other branches — not this tree's code).
|
||||
if any(part in {"venv", "mlx_models", ".git", ".claude", ".worktrees",
|
||||
"node_modules", "remotion-demo"} for part in path.parts):
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
hits = sorted(m for m in RETIRED_MODELS if f'"{m}"' in text or f"'{m}'" in text)
|
||||
if hits:
|
||||
offenders[str(path.relative_to(root))] = hits
|
||||
|
||||
assert not offenders, f"retired model ids still referenced: {offenders}"
|
||||
@@ -0,0 +1,35 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
LEGACY = "episode.html?slug="
|
||||
|
||||
|
||||
def _files():
|
||||
for pattern in ("website/*.html", "website/js/*.js", "website/llms.txt", "*.py"):
|
||||
yield from ROOT.glob(pattern)
|
||||
|
||||
|
||||
def test_no_source_file_builds_a_legacy_episode_url():
|
||||
offenders = []
|
||||
for f in _files():
|
||||
if f.name == "_worker.js":
|
||||
continue # the worker's reference is the redirect itself
|
||||
if LEGACY in f.read_text(errors="replace"):
|
||||
offenders.append(str(f.relative_to(ROOT)))
|
||||
assert not offenders, f"legacy episode URLs still present: {offenders}"
|
||||
|
||||
|
||||
def test_client_rendered_episode_page_is_gone():
|
||||
assert not (ROOT / "website" / "episode.html").exists()
|
||||
assert not (ROOT / "website" / "js" / "episode.js").exists()
|
||||
|
||||
|
||||
def test_worker_still_redirects_legacy_urls():
|
||||
worker = (ROOT / "website" / "_worker.js").read_text()
|
||||
assert "/episode.html" in worker and "301" in worker
|
||||
|
||||
|
||||
def test_redirects_file_has_no_dead_episode_target():
|
||||
redirects = (ROOT / "website" / "_redirects").read_text()
|
||||
assert "/episode 302" not in redirects
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Whisper mishears recurring show names; correct them after transcription.
|
||||
|
||||
publish_episode.py transcribes with LightningWhisperMLX, whose transcribe()
|
||||
signature is (audio_path, language) — it accepts no initial_prompt, so there is
|
||||
no way to condition it on proper nouns the way make_clips.py does with
|
||||
mlx_whisper. The intern came out as "Devin" in 21 of 58 published transcripts
|
||||
because of this.
|
||||
|
||||
A deterministic pass over the finished text fixes the known names without
|
||||
swapping the transcription engine.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from publish_episode import fix_proper_nouns
|
||||
|
||||
|
||||
def test_corrects_the_intern_name_in_all_casings():
|
||||
assert fix_proper_nouns("Devin, where's my coffee?") == "Devon, where's my coffee?"
|
||||
assert fix_proper_nouns("DEVIN: Hey Luke.") == "DEVON: Hey Luke."
|
||||
assert fix_proper_nouns("devin said so") == "devon said so"
|
||||
|
||||
|
||||
def test_leaves_correct_spelling_alone():
|
||||
text = "Devon is the intern. DEVON: Hi."
|
||||
assert fix_proper_nouns(text) == text
|
||||
|
||||
|
||||
def test_only_matches_whole_words():
|
||||
"""Must not corrupt a longer word that happens to contain the name."""
|
||||
assert fix_proper_nouns("Devinshire") == "Devinshire"
|
||||
assert fix_proper_nouns("mcdevins") == "mcdevins"
|
||||
|
||||
|
||||
def test_preserves_surrounding_text_exactly():
|
||||
src = "LUKE: Alright. Let's check in with Devin and see how he's doing.\n\nDEVIN: Hey!"
|
||||
out = fix_proper_nouns(src)
|
||||
assert out == "LUKE: Alright. Let's check in with Devon and see how he's doing.\n\nDEVON: Hey!"
|
||||
assert len(out) == len(src)
|
||||
|
||||
|
||||
def test_empty_and_none_safe():
|
||||
assert fix_proper_nouns("") == ""
|
||||
assert fix_proper_nouns(None) == ""
|
||||
|
||||
|
||||
def test_word_count_is_never_changed():
|
||||
src = "Devin talked to Devin about Devin. " * 20
|
||||
assert len(fix_proper_nouns(src).split()) == len(src.split())
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Publishing must rebuild the static episode pages and the sitemap.
|
||||
|
||||
Before static pages existed, publish_episode.py appended one entry to
|
||||
sitemap.xml itself. That appender is gone — generate_episode_pages.py owns the
|
||||
sitemap now — so the publish has to invoke it, or a newly published episode
|
||||
would have no page and never reach the sitemap.
|
||||
|
||||
A generator failure must never abort a publish: by the time this runs the audio
|
||||
is already live on Castopod and the RSS feed has been rebuilt.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import publish_episode
|
||||
|
||||
|
||||
class _Result:
|
||||
def __init__(self, returncode=0, stderr=""):
|
||||
self.returncode = returncode
|
||||
self.stderr = stderr
|
||||
self.stdout = ""
|
||||
|
||||
|
||||
def test_invokes_the_generator_with_sitemap(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd) or _Result())
|
||||
|
||||
assert publish_episode.regenerate_website_pages() is True
|
||||
|
||||
assert len(calls) == 1
|
||||
cmd = calls[0]
|
||||
assert "generate_episode_pages.py" in " ".join(cmd)
|
||||
assert "--sitemap" in cmd
|
||||
|
||||
|
||||
def test_uses_the_running_interpreter(monkeypatch):
|
||||
"""Must not shell out to a bare 'python' that may not have the venv."""
|
||||
calls = []
|
||||
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd) or _Result())
|
||||
|
||||
publish_episode.regenerate_website_pages()
|
||||
|
||||
assert calls[0][0] == sys.executable
|
||||
|
||||
|
||||
def test_generator_failure_is_not_fatal(monkeypatch):
|
||||
monkeypatch.setattr(subprocess, "run",
|
||||
lambda cmd, **kw: _Result(returncode=1, stderr="boom"))
|
||||
|
||||
assert publish_episode.regenerate_website_pages() is False
|
||||
|
||||
|
||||
def test_generator_timeout_is_not_fatal(monkeypatch):
|
||||
def _boom(cmd, **kw):
|
||||
raise subprocess.TimeoutExpired(cmd, 300)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", _boom)
|
||||
|
||||
assert publish_episode.regenerate_website_pages() is False
|
||||
|
||||
|
||||
def test_missing_generator_is_not_fatal(monkeypatch):
|
||||
def _boom(cmd, **kw):
|
||||
raise OSError("no such file")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", _boom)
|
||||
|
||||
assert publish_episode.regenerate_website_pages() is False
|
||||
|
||||
|
||||
def test_publish_flow_calls_it_after_copying_the_transcript():
|
||||
"""Guards the wiring, not just the helper."""
|
||||
source = Path(publish_episode.__file__).read_text()
|
||||
copy_at = source.index("Transcript copied to website/transcripts/")
|
||||
call_at = source.index("regenerate_website_pages()", copy_at)
|
||||
assert call_at > copy_at
|
||||
|
||||
|
||||
def test_generator_runs_after_the_publish_call():
|
||||
"""Ordering regression guard.
|
||||
|
||||
generate_episode_pages.py is driven entirely by the RSS feed, so it only
|
||||
sees an episode once Castopod has published it. For 58 episodes the
|
||||
regenerate call sat at step 3.7 — before the step-4 publish — so the
|
||||
episode being published was silently absent from its own run and its page
|
||||
only appeared on the NEXT publish. Social posts link to /episode/<slug>/,
|
||||
so every launch-day link 404'd.
|
||||
|
||||
Asserted on source order because the surrounding publish flow does network
|
||||
and filesystem work that isn't practical to drive end to end here.
|
||||
"""
|
||||
src = Path(publish_episode.__file__).read_text()
|
||||
body = src[src.index("def main("):]
|
||||
|
||||
publish_call = body.index("published = publish_episode(")
|
||||
regen_call = body.index("regenerate_website_pages()")
|
||||
deploy_call = body.index("wrangler")
|
||||
|
||||
assert publish_call < regen_call, (
|
||||
"regenerate_website_pages() must run AFTER publish_episode(), or the new "
|
||||
"episode is missing from the feed the generator reads"
|
||||
)
|
||||
assert regen_call < deploy_call, (
|
||||
"regenerate_website_pages() must run BEFORE the wrangler deploy, or the "
|
||||
"freshly built page never ships"
|
||||
)
|
||||
|
||||
|
||||
def test_regenerate_is_called_exactly_once_in_main():
|
||||
src = Path(publish_episode.__file__).read_text()
|
||||
body = src[src.index("def main("):]
|
||||
assert body.count("regenerate_website_pages()") == 1
|
||||
@@ -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
|
||||
@@ -64,6 +64,49 @@ def test_session_get_show_history_summary():
|
||||
assert "EARLIER IN THE SHOW" in summary
|
||||
|
||||
|
||||
def test_show_history_reactions_constant_is_usable():
|
||||
from backend.main import SHOW_HISTORY_REACTIONS
|
||||
|
||||
assert SHOW_HISTORY_REACTIONS, "generic reaction pool must not be empty"
|
||||
assert all(isinstance(r, str) and r.strip() for r in SHOW_HISTORY_REACTIONS)
|
||||
# Each one is interpolated as "...and you {reaction}." so it must not
|
||||
# carry its own leading/trailing punctuation or an unfilled placeholder.
|
||||
for r in SHOW_HISTORY_REACTIONS:
|
||||
assert not r.endswith("."), r
|
||||
assert "{" not in r, r
|
||||
|
||||
|
||||
def test_build_specific_reaction_falls_back_when_record_has_no_details():
|
||||
"""A CallRecord with neither key_details nor situation_summary hits the
|
||||
generic branch — this used to raise NameError mid-show."""
|
||||
from backend.main import SHOW_HISTORY_REACTIONS
|
||||
|
||||
s = Session()
|
||||
bare = CallRecord(
|
||||
caller_type="ai", caller_name="Jasmine",
|
||||
summary="Talked about her boss", transcript=[],
|
||||
)
|
||||
reaction = s._build_specific_reaction({}, bare)
|
||||
assert reaction in SHOW_HISTORY_REACTIONS
|
||||
|
||||
|
||||
def test_get_show_history_never_raises_when_reaction_branch_fires(monkeypatch):
|
||||
"""Force the reaction branch every time so the fallback path is covered
|
||||
deterministically rather than at its ~15% random rate."""
|
||||
import backend.main as main
|
||||
|
||||
monkeypatch.setattr(main.random, "random", lambda: 0.0)
|
||||
|
||||
s = Session()
|
||||
s.call_history.append(CallRecord(
|
||||
caller_type="real", caller_name="Dave",
|
||||
summary="Called about his wife leaving", transcript=[],
|
||||
))
|
||||
summary = s.get_show_history()
|
||||
assert "DAVE" in summary
|
||||
assert "and you " in summary
|
||||
|
||||
|
||||
def test_session_reset_clears_history():
|
||||
s = Session()
|
||||
s.call_history.append(CallRecord(
|
||||
@@ -87,3 +130,34 @@ def test_session_conversation_summary_three_party():
|
||||
summary = s.get_conversation_summary()
|
||||
assert "Dave" in summary
|
||||
assert "Tony" in summary
|
||||
|
||||
|
||||
def test_recent_summaries_uses_wider_dedup_window(monkeypatch):
|
||||
"""Phase 5B deleted cross-episode topic dedup, leaving only a 2-show window.
|
||||
The batch generator should now see LINEUP_DEDUP_SHOWS worth of history."""
|
||||
import backend.main as m
|
||||
|
||||
history = [
|
||||
{"lineup": [{"name": f"Caller{i}", "situation": f"situation number {i}"}]}
|
||||
for i in range(m.LINEUP_DEDUP_SHOWS + 5)
|
||||
]
|
||||
monkeypatch.setattr(m, "_load_lineup_history", lambda: history)
|
||||
|
||||
summaries = Session()._get_recent_summaries()
|
||||
assert len(summaries) == m.LINEUP_DEDUP_SHOWS
|
||||
assert m.LINEUP_DEDUP_SHOWS > 2
|
||||
# Keeps the most recent shows, drops the oldest
|
||||
assert "situation number 4" not in " ".join(summaries)
|
||||
assert f"situation number {m.LINEUP_DEDUP_SHOWS + 4}" in " ".join(summaries)
|
||||
|
||||
|
||||
def test_lineup_history_retains_at_least_the_dedup_window():
|
||||
"""Truncating the file below the dedup window would silently shrink it."""
|
||||
import backend.main as m
|
||||
assert m.LINEUP_HISTORY_MAX >= m.LINEUP_DEDUP_SHOWS
|
||||
|
||||
|
||||
def test_fresh_session_reports_no_lineup():
|
||||
s = Session()
|
||||
assert s.caller_backgrounds == {}
|
||||
assert bool(s.caller_backgrounds) is False
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from generate_episode_pages import build_sitemap
|
||||
from website_gen.feed import parse_feed
|
||||
|
||||
FIXTURE_FEED = Path(__file__).parent / "fixtures" / "feed_sample.xml"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def episodes():
|
||||
return parse_feed(FIXTURE_FEED.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def generated_sitemap(episodes):
|
||||
return build_sitemap(episodes)
|
||||
|
||||
|
||||
def test_sitemap_contains_no_query_param_urls(generated_sitemap):
|
||||
assert "episode.html?slug=" not in generated_sitemap
|
||||
|
||||
|
||||
def test_sitemap_has_one_entry_per_episode(generated_sitemap, episodes):
|
||||
assert generated_sitemap.count("<loc>https://lukeattheroost.com/episode/") == len(episodes)
|
||||
|
||||
|
||||
def test_static_pages_survive_regeneration(generated_sitemap):
|
||||
for path in ["", "/how-it-works", "/clips", "/stats", "/privacy", "/terms", "/llms.txt"]:
|
||||
assert f"<loc>https://lukeattheroost.com{path}</loc>" in generated_sitemap
|
||||
|
||||
|
||||
def test_lastmod_is_date_only_not_a_timestamp(generated_sitemap):
|
||||
for m in re.findall(r"<lastmod>([^<]*)</lastmod>", generated_sitemap):
|
||||
assert re.fullmatch(r"\d{4}-\d{2}-\d{2}", m), f"bad lastmod: {m}"
|
||||
|
||||
|
||||
def test_sitemap_is_valid_xml(generated_sitemap):
|
||||
root = ET.fromstring(generated_sitemap)
|
||||
assert root.tag.endswith("urlset")
|
||||
|
||||
|
||||
def test_episode_urls_have_trailing_slash(generated_sitemap):
|
||||
for loc in re.findall(r"<loc>(https://lukeattheroost\.com/episode/[^<]*)</loc>", generated_sitemap):
|
||||
assert loc.endswith("/"), loc
|
||||
|
||||
|
||||
def test_404_page_is_not_listed(generated_sitemap):
|
||||
assert "/404" not in generated_sitemap
|
||||
|
||||
|
||||
def test_static_pages_come_before_episodes(generated_sitemap):
|
||||
first_episode = generated_sitemap.index("<loc>https://lukeattheroost.com/episode/")
|
||||
last_static = generated_sitemap.index("<loc>https://lukeattheroost.com/privacy</loc>")
|
||||
assert last_static < first_episode
|
||||
|
||||
|
||||
def test_episodes_are_newest_first(generated_sitemap):
|
||||
locs = re.findall(r"<loc>https://lukeattheroost\.com/episode/([^<]*)/</loc>", generated_sitemap)
|
||||
assert locs[0].startswith("episode-58-")
|
||||
assert locs[1].startswith("episode-57-")
|
||||
|
||||
|
||||
def test_declares_xml_and_sitemap_namespace(generated_sitemap):
|
||||
assert generated_sitemap.startswith('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
assert 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"' in generated_sitemap
|
||||
@@ -0,0 +1,29 @@
|
||||
from backend.main import _get_town_from_location, BIG_BEND_TOWNS
|
||||
|
||||
|
||||
def test_matches_simple_town():
|
||||
assert _get_town_from_location("Alpine, Texas") == "alpine"
|
||||
assert _get_town_from_location("Marfa") == "marfa"
|
||||
assert _get_town_from_location("outside Terlingua") == "terlingua"
|
||||
|
||||
|
||||
def test_matches_two_word_town():
|
||||
assert _get_town_from_location("Fort Stockton, TX") == "fort stockton"
|
||||
assert _get_town_from_location("ft stockton") == "fort stockton"
|
||||
|
||||
|
||||
def test_case_insensitive():
|
||||
assert _get_town_from_location("MARATHON, texas") == "marathon"
|
||||
|
||||
|
||||
def test_no_match_returns_none():
|
||||
assert _get_town_from_location("Albuquerque, New Mexico") is None
|
||||
assert _get_town_from_location("") is None
|
||||
|
||||
|
||||
def test_every_town_has_coords():
|
||||
for town, coords in BIG_BEND_TOWNS.items():
|
||||
assert town == town.lower()
|
||||
lat, lon = coords
|
||||
assert 28.0 < lat < 32.0
|
||||
assert -105.0 < lon < -102.0
|
||||
@@ -0,0 +1,37 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from website_gen.transcript import parse_transcript
|
||||
|
||||
|
||||
def test_splits_speaker_turns():
|
||||
raw = "LUKE: Welcome back.\n\nSLIM: Hey Luke, thanks for taking my call."
|
||||
turns = parse_transcript(raw)
|
||||
assert turns == [("LUKE", "Welcome back."),
|
||||
("SLIM", "Hey Luke, thanks for taking my call.")]
|
||||
|
||||
|
||||
def test_unlabeled_paragraph_carries_previous_speaker():
|
||||
raw = "LUKE: First thing.\n\nStill Luke talking."
|
||||
assert parse_transcript(raw) == [("LUKE", "First thing."),
|
||||
("LUKE", "Still Luke talking.")]
|
||||
|
||||
|
||||
def test_ignores_blank_and_whitespace_paragraphs():
|
||||
raw = "LUKE: One.\n\n \n\nSLIM: Two."
|
||||
assert len(parse_transcript(raw)) == 2
|
||||
|
||||
|
||||
def test_speaker_name_with_spaces_is_not_treated_as_label():
|
||||
"""A colon mid-sentence must not be mistaken for a speaker label."""
|
||||
raw = "LUKE: Here's the thing: it was the alternator."
|
||||
turns = parse_transcript(raw)
|
||||
assert len(turns) == 1
|
||||
assert turns[0][0] == "LUKE"
|
||||
assert "the thing: it was" in turns[0][1]
|
||||
|
||||
|
||||
def test_empty_input_returns_empty_list():
|
||||
assert parse_transcript("") == []
|
||||
assert parse_transcript(" \n\n ") == []
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user