Move caller dialog to Sonnet 4.6, add SearXNG for Devon, and fix stale model ids
Working-tree changes that had accumulated without being committed. The test updates matter most: tests/test_caller_gen.py was left behind when caller_gen started requiring voice and age in regulars_included, so the committed tree had a failing suite that only passed locally. - Caller dialog moves from Haiku 4.5 to Sonnet 4.6 (~$1/show to ~$3-4/show) - Grok pinned to x-ai/grok-4.3; grok-4, grok-4-fast and grok-4.1-fast were retired from OpenRouter, and llm.py swallows the 404 and returns empty text, so a retired id makes callers go silent with nothing in the logs - Devon's web_search now runs against SearXNG on the NAS - Assorted TTS, audio, news, cost tracker and control-panel changes - CLAUDE.md updated to match Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,13 +38,13 @@ 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 Haiku 4.5 per turn. Cost ~$1/show.
|
||||
- **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 Haiku 4.5 via the `caller_dialog` category in `config.category_models`. No per-caller model routing — deleted in Phase 5B.
|
||||
- **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%).
|
||||
@@ -55,6 +55,7 @@ Required in `.env`:
|
||||
- **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
|
||||
|
||||
+6
-2
@@ -27,6 +27,10 @@ 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.6" # primary/default model
|
||||
@@ -35,8 +39,8 @@ class Settings(BaseSettings):
|
||||
|
||||
# Per-category model routing
|
||||
category_models: dict = {
|
||||
"caller_dialog": "anthropic/claude-haiku-4.5", # live caller dialog ($0.80/$4)
|
||||
"devon_ask": "x-ai/grok-4.1-fast", # Devon matches show energy, cheap ($0.20/$0.50)
|
||||
"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)
|
||||
|
||||
+384
-42
@@ -82,19 +82,22 @@ FEMALE_NAMES = [
|
||||
|
||||
# Voice pools per TTS provider
|
||||
INWORLD_MALE_VOICES = [
|
||||
"Alex", "Arjun", "Blake", "Brian", "Callum", "Carter", "Clive", "Craig",
|
||||
"Dennis", "Derek", "Edward", "Elliot", "Ethan", "Evan", "Gareth", "Graham",
|
||||
"Grant", "Hades", "Hamish", "Hank", "Jake", "James", "Jason", "Liam",
|
||||
"Malcolm", "Mark", "Mortimer", "Nate", "Oliver", "Ronald", "Rupert",
|
||||
"Sebastian", "Shaun", "Simon", "Theodore", "Timothy", "Tyler", "Victor",
|
||||
"Vinny",
|
||||
"Alex", "Arjun", "Arthur", "Avery", "Blake", "Brandon", "Brian", "Callum",
|
||||
"Carter", "Cedric", "Clive", "Conrad", "Craig", "Damon", "Daniel", "Dennis",
|
||||
"Derek", "Duncan",
|
||||
"Edward", "Elliot", "Ethan", "Evan", "Felix", "Gareth", "Graham", "Grant",
|
||||
"Hades", "Hamish", "Hank", "Jake", "James", "Jason", "Jonah", "Levi",
|
||||
"Liam", "Malcolm", "Marcus", "Mark", "Mortimer", "Nate", "Oliver", "Reed",
|
||||
"Ronald", "Rupert", "Sebastian", "Shaun", "Simon", "Theodore", "Timothy",
|
||||
"Trevor", "Tristan", "Tyler", "Victor", "Vinny",
|
||||
]
|
||||
INWORLD_FEMALE_VOICES = [
|
||||
"Amina", "Anjali", "Ashley", "Celeste", "Chloe", "Claire", "Darlene",
|
||||
"Deborah", "Elizabeth", "Evelyn", "Hana", "Jessica", "Julia", "Kayla",
|
||||
"Kelsey", "Lauren", "Loretta", "Luna", "Marlene", "Miranda", "Olivia",
|
||||
"Pippa", "Priya", "Saanvi", "Sarah", "Serena", "Tessa", "Veronica",
|
||||
"Victoria", "Wendy",
|
||||
"Amina", "Anjali", "Ashley", "Bianca", "Brooke", "Celeste", "Chloe",
|
||||
"Claire", "Darlene", "Deborah", "Eleanor", "Elizabeth", "Evelyn", "Hana",
|
||||
"Jessica", "Joy", "Julia", "Kayla", "Kelsey", "Lauren", "Loretta", "Luna",
|
||||
"Marlene", "Miranda", "Nadia", "Naomi", "Olivia", "Pippa", "Priya",
|
||||
"Saanvi", "Sarah", "Selene", "Serena", "Sophie", "Tessa", "Veronica",
|
||||
"Victoria", "Wendy", "Zadie",
|
||||
]
|
||||
|
||||
ELEVENLABS_MALE_VOICES = [
|
||||
@@ -338,6 +341,68 @@ def _extract_search_query(background: str) -> str | None:
|
||||
return " ".join(search_words)
|
||||
|
||||
|
||||
# Big Bend region towns the show lives in, with coordinates for weather lookups.
|
||||
# Longer names first so "fort stockton" matches before "stockton"-style partials.
|
||||
BIG_BEND_TOWNS: dict[str, tuple[float, float]] = {
|
||||
"fort stockton": (30.8949, -102.8794),
|
||||
"fort davis": (30.5882, -103.8946),
|
||||
"big bend": (29.2700, -103.3000), # Chisos Basin
|
||||
"alpine": (30.3585, -103.6610),
|
||||
"marfa": (30.3098, -104.0207),
|
||||
"marathon": (30.2074, -103.2452),
|
||||
"terlingua": (29.3216, -103.6168),
|
||||
"presidio": (29.5602, -104.3707),
|
||||
}
|
||||
|
||||
# WMO weather codes (Open-Meteo) → short human phrase
|
||||
_WMO_PHRASE = {
|
||||
0: "clear skies", 1: "mostly clear", 2: "partly cloudy", 3: "overcast",
|
||||
45: "foggy", 48: "freezing fog",
|
||||
51: "light drizzle", 53: "drizzle", 55: "heavy drizzle",
|
||||
61: "light rain", 63: "rain", 65: "heavy rain",
|
||||
71: "light snow", 73: "snow", 75: "heavy snow",
|
||||
80: "rain showers", 81: "rain showers", 82: "heavy rain showers",
|
||||
95: "thunderstorms", 96: "thunderstorms", 99: "thunderstorms",
|
||||
}
|
||||
|
||||
|
||||
def _get_town_from_location(text: str) -> Optional[str]:
|
||||
"""Return the canonical Big Bend town key mentioned in a location string,
|
||||
or None. Matches 'ft stockton' as 'fort stockton'."""
|
||||
if not text:
|
||||
return None
|
||||
low = " " + text.lower().replace("ft.", "fort").replace("ft ", "fort ") + " "
|
||||
for town in BIG_BEND_TOWNS:
|
||||
if town in low:
|
||||
return town
|
||||
return None
|
||||
|
||||
|
||||
async def _get_weather_for_town(town: str) -> Optional[str]:
|
||||
"""Current conditions for a Big Bend town via Open-Meteo (free, no key).
|
||||
Returns e.g. '58°F, clear skies', or None on failure/unknown town."""
|
||||
coords = BIG_BEND_TOWNS.get(town)
|
||||
if not coords:
|
||||
return None
|
||||
lat, lon = coords
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(
|
||||
"https://api.open-meteo.com/v1/forecast",
|
||||
params={
|
||||
"latitude": lat, "longitude": lon,
|
||||
"current": "temperature_2m,weather_code",
|
||||
"temperature_unit": "fahrenheit",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
cur = resp.json().get("current", {})
|
||||
temp = cur.get("temperature_2m")
|
||||
if temp is None:
|
||||
return None
|
||||
phrase = _WMO_PHRASE.get(cur.get("weather_code"), "")
|
||||
return f"{round(temp)}°F, {phrase}" if phrase else f"{round(temp)}°F"
|
||||
|
||||
|
||||
async def enrich_caller_background(background: str) -> str:
|
||||
"""Search for a relevant article and local town news, summarize naturally.
|
||||
Called once at pickup time — never during live conversation."""
|
||||
@@ -384,9 +449,9 @@ async def enrich_caller_background(background: str) -> str:
|
||||
try:
|
||||
if not town:
|
||||
town = _get_town_from_location(background.split(".")[0])
|
||||
if town and town not in ("road forks", "hachita"): # Too small for news
|
||||
if town and town != "big bend": # the park, not a town with local news
|
||||
async with asyncio.timeout(4):
|
||||
town_query = f"{town.title()} New Mexico" if town not in ("tucson", "phoenix", "bisbee", "douglas", "sierra vista", "safford", "willcox", "globe", "clifton", "duncan", "tombstone", "nogales", "green valley", "benson", "san simon") else f"{town.title()} Arizona"
|
||||
town_query = f"{town.title()} Texas"
|
||||
results = await news_service.search_topic(town_query)
|
||||
if results:
|
||||
article = results[0]
|
||||
@@ -475,25 +540,77 @@ def detect_host_mood(messages: list[dict], wrapping_up: bool = False) -> str:
|
||||
|
||||
|
||||
|
||||
def get_caller_prompt(caller: dict) -> str:
|
||||
def get_caller_prompt(caller: dict, theme: str = "") -> str:
|
||||
"""Caller system prompt. Identity carries the weight."""
|
||||
name = caller.get("name", "")
|
||||
identity = caller.get("identity", "")
|
||||
situation = caller.get("situation", "")
|
||||
reason = caller.get("reason_calling", "")
|
||||
want = caller.get("secret_want", "")
|
||||
opening = caller.get("opening_line", "")
|
||||
details = caller.get("specific_details", []) or []
|
||||
detail_str = " | ".join(f"- {d}" for d in details)
|
||||
|
||||
opening_block = ""
|
||||
if opening:
|
||||
opening_block = f"""
|
||||
Your planned opening line (use this or something very close for your FIRST message — do NOT say you've been listening for years, do NOT use generic radio caller clichés):
|
||||
"{opening}"
|
||||
"""
|
||||
|
||||
# Returning-caller memory block. Memory fields are usually attached to the
|
||||
# slim dict at generation time, but slim dicts restored from checkpoint or
|
||||
# preserved across a partial-regenerate (theme change) won't have them — so
|
||||
# fall back to a name lookup against the regulars store.
|
||||
is_regular = caller.get("is_regular", False)
|
||||
arc_state = (caller.get("arc_state") or "").strip()
|
||||
prior = [s for s in (caller.get("prior_summaries") or []) if s]
|
||||
if not is_regular and name:
|
||||
persisted = regular_caller_service.get_by_name(name)
|
||||
if persisted:
|
||||
is_regular = True
|
||||
if not prior:
|
||||
prior = [
|
||||
(e.get("summary") or "").strip()[:400]
|
||||
for e in (persisted.get("call_history") or [])[-4:]
|
||||
if (e.get("summary") or "").strip()
|
||||
]
|
||||
if not arc_state:
|
||||
try:
|
||||
from .services import regulars_v2
|
||||
for r in regulars_v2.load_all_active_regulars():
|
||||
if r.name.lower() == name.lower():
|
||||
is_regular = True
|
||||
arc_state = (r.arc_state or "").strip()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
regular_block = ""
|
||||
if is_regular:
|
||||
regular_block = "\n\nYOU ARE A RECURRING CALLER ON THIS SHOW. Luke knows you. You've called before — do NOT introduce yourself as a first-time listener.\n"
|
||||
if prior:
|
||||
regular_block += "\nYour past calls with Luke (oldest first — these things actually happened, reference them naturally when relevant):\n"
|
||||
for i, s in enumerate(prior, 1):
|
||||
regular_block += f"{i}. {s.strip()}\n"
|
||||
if arc_state:
|
||||
regular_block += f"\nWhere your story stands going into tonight: {arc_state}\n"
|
||||
if prior or arc_state:
|
||||
regular_block += "\nIf Luke asks about something from a past call, you remember it. You and he have a rapport built from those prior conversations.\n"
|
||||
|
||||
theme_block = ""
|
||||
if theme:
|
||||
theme_block = f'\n\nTonight\'s show theme: "{theme}". If your reason for calling connects to this, lean into the connection with energy and specificity. If it genuinely doesn\'t fit your story, don\'t force it.\n'
|
||||
|
||||
return f"""You are {name}. {identity}
|
||||
|
||||
You're calling Luke's late-night radio show because: {situation} — specifically, {reason}.
|
||||
|
||||
What you secretly want from this call: {want}
|
||||
|
||||
{opening_block}
|
||||
Specific details you'll drop if it feels natural:
|
||||
{detail_str}
|
||||
|
||||
{regular_block}{theme_block}
|
||||
Speak as this person. React to what Luke says. Stay in character.
|
||||
|
||||
CRITICAL OUTPUT RULES:
|
||||
@@ -502,6 +619,13 @@ CRITICAL OUTPUT RULES:
|
||||
- NEVER use parenthetical stage directions. No (laughs), (nervous), (sighs).
|
||||
- No narration. No describing what you're doing or feeling except through what you say.
|
||||
- If you catch yourself writing an asterisk or parenthesis, delete it and just say the words instead.
|
||||
- NEVER say you've "been listening for X years" or "long-time listener, first-time caller." Just get into your story.
|
||||
- If you've already shared a specific fact or detail in this conversation, do NOT restate it. Move the story forward — share something new, react to what Luke just said, or escalate the stakes.
|
||||
|
||||
YOU CAN BE MOVED — DON'T CIRCLE:
|
||||
- You have a position and something you want, but you're a real person, not a stuck record. If Luke makes a good point or gives real advice, actually take it in — let it land. Agree, change your mind, soften, get defensive, dig in harder, or come to a decision. Something shifts.
|
||||
- NEVER restate the same dilemma, worry, or argument you've already made. If you catch yourself going in a circle, that's the moment to move: make a decision, admit something new, react to his point, or let the conversation turn somewhere it hasn't been.
|
||||
- It's good to reach a resolution, a choice, or an emotional turn over the call. You are NOT required to stay stuck in the same problem until you're cut off. Real conversations go somewhere.
|
||||
|
||||
Mix short punchy replies with longer ones where natural. Real callers breathe, react in fragments, ask their own questions — they don't deliver a monologue every turn."""
|
||||
|
||||
@@ -586,6 +710,19 @@ def _assess_call_quality(
|
||||
}
|
||||
|
||||
|
||||
# Generic reactions for when an earlier call has no details worth naming.
|
||||
# Interpolated as "...and you {reaction}." so each must agree with "you".
|
||||
SHOW_HISTORY_REACTIONS = [
|
||||
"have been chewing on their call ever since",
|
||||
"didn't buy a word of it",
|
||||
"thought they got a raw deal from the host",
|
||||
"can't quite shake what they said",
|
||||
"wanted to reach through the radio and argue with them",
|
||||
"felt for them more than you expected to",
|
||||
"thought they were dead right and nobody backed them up",
|
||||
]
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self):
|
||||
self.id = str(uuid.uuid4())[:8]
|
||||
@@ -792,35 +929,72 @@ class Session:
|
||||
from .services import caller_gen, regulars_v2
|
||||
from datetime import datetime
|
||||
|
||||
voice_roster = [name for name in INWORLD_MALE_VOICES + INWORLD_FEMALE_VOICES
|
||||
# Voice rotation: Sonnet biases toward familiar-sounding names when shown
|
||||
# the full 78-voice roster, so 12-ish voices dominated ~50% of TTS calls.
|
||||
# Pre-sample a 25-voice subset per show, excluding voices used in the last
|
||||
# 2 shows — this rotates the full pool over 5-6 shows.
|
||||
full_pool = [name for name in INWORLD_MALE_VOICES + INWORLD_FEMALE_VOICES
|
||||
if name not in BLACKLISTED_VOICES]
|
||||
recently_used = _recently_used_voices(n_shows=2)
|
||||
fresh_pool = [v for v in full_pool if v not in recently_used] or full_pool
|
||||
voice_roster = random.sample(fresh_pool, min(25, len(fresh_pool)))
|
||||
print(f"[Background] Voice subset: {len(voice_roster)} of {len(full_pool)} ({len(recently_used)} excluded as recent)")
|
||||
|
||||
# Each active regular has an independent 50% chance to appear tonight.
|
||||
REGULAR_APPEARANCE_PROB = 0.5
|
||||
# Each active regular's appearance probability scales with shows-since-last
|
||||
# appearance: base 0.4, +0.3 per missed show, capped at 1.0. A regular who's
|
||||
# never appeared (or hasn't been seen in 2+ shows) is guaranteed tonight.
|
||||
active_regulars = regulars_v2.load_all_active_regulars()
|
||||
regulars_for_tonight = [
|
||||
history = _load_lineup_history()
|
||||
|
||||
def _shows_since_last(name: str) -> int:
|
||||
for i, record in enumerate(reversed(history)):
|
||||
if any(c.get("name") == name for c in record.get("lineup", [])):
|
||||
return i
|
||||
return 999 # never appeared
|
||||
|
||||
regulars_for_tonight: list[dict] = []
|
||||
for r in active_regulars:
|
||||
misses = _shows_since_last(r.name)
|
||||
prob = min(1.0, 0.4 + 0.3 * misses)
|
||||
if random.random() < prob:
|
||||
regulars_for_tonight.append(
|
||||
{"name": r.name, "voice": r.voice, "age": r.age,
|
||||
"lore": r.lore_body, "arc_state": r.arc_state}
|
||||
for r in active_regulars
|
||||
if random.random() < REGULAR_APPEARANCE_PROB
|
||||
][:3]
|
||||
"lore": r.lore_body, "arc_state": r.arc_state,
|
||||
"_misses": misses, "_prob": prob}
|
||||
)
|
||||
regulars_for_tonight = regulars_for_tonight[:3]
|
||||
if regulars_for_tonight:
|
||||
names = ", ".join(r["name"] for r in regulars_for_tonight)
|
||||
names = ", ".join(f"{r['name']}(miss={r['_misses']},p={r['_prob']:.2f})"
|
||||
for r in regulars_for_tonight)
|
||||
print(f"[Background] Regulars tonight: {names}")
|
||||
else:
|
||||
print("[Background] No regulars tonight — all walk-ins")
|
||||
# Strip internal fields before passing to caller_gen
|
||||
for r in regulars_for_tonight:
|
||||
r.pop("_misses", None)
|
||||
r.pop("_prob", None)
|
||||
|
||||
headlines: list[str] = []
|
||||
if self.news_headlines:
|
||||
for h in self.news_headlines[:5]:
|
||||
headlines.append(h.title if hasattr(h, "title") else str(h))
|
||||
|
||||
weather_line = "cool desert night"
|
||||
try:
|
||||
async with asyncio.timeout(5):
|
||||
w = await _get_weather_for_town("alpine")
|
||||
if w:
|
||||
weather_line = f"Weather in Alpine right now: {w}"
|
||||
except Exception as e:
|
||||
print(f"[Background] Alpine weather lookup failed: {e}")
|
||||
|
||||
base_ctx = {
|
||||
"date": datetime.now().strftime("%A, %B %d, %Y"),
|
||||
"weather": "cool desert night", # TODO: real weather feed
|
||||
"weather": weather_line,
|
||||
"headlines": headlines,
|
||||
"recent_caller_summaries": self._get_recent_summaries(),
|
||||
"voice_roster": voice_roster,
|
||||
"theme": self.show_theme,
|
||||
}
|
||||
# Single batch of 10 so sonnet sees the full roster and can enforce
|
||||
# the anti-collision rule across all callers. Previously two parallel
|
||||
@@ -873,7 +1047,7 @@ class Session:
|
||||
if r["name"] not in regular_idents:
|
||||
print(f"[Background] Regular '{r['name']}' missing from batch output — generating directly")
|
||||
try:
|
||||
fields = await caller_gen.generate_regular_situation(r, ctx_a)
|
||||
fields = await caller_gen.generate_regular_situation(r, ctx)
|
||||
regular_idents[r["name"]] = caller_gen.CallerIdentity(
|
||||
name=r["name"],
|
||||
age=r["age"],
|
||||
@@ -895,11 +1069,30 @@ class Session:
|
||||
ordered = [regular_idents[r["name"]] for r in regulars_for_tonight if r["name"] in regular_idents]
|
||||
ordered.extend(walk_ins)
|
||||
|
||||
# Build memory-injection metadata for each regular: arc_state from the
|
||||
# Obsidian frontmatter (regulars_v2) + last few call summaries from
|
||||
# data/regulars.json. Without this, the dialog model treats every call
|
||||
# as the caller's first time and forgets prior conversations.
|
||||
regular_memory_by_name: dict[str, dict] = {}
|
||||
for r in regulars_for_tonight:
|
||||
persisted = regular_caller_service.get_by_name(r["name"])
|
||||
prior_summaries: list[str] = []
|
||||
if persisted:
|
||||
for entry in (persisted.get("call_history") or [])[-4:]:
|
||||
summary = entry.get("summary", "").strip()
|
||||
if summary:
|
||||
prior_summaries.append(summary[:400])
|
||||
regular_memory_by_name[r["name"]] = {
|
||||
"is_regular": True,
|
||||
"arc_state": (r.get("arc_state") or "").strip(),
|
||||
"prior_summaries": prior_summaries,
|
||||
}
|
||||
|
||||
backgrounds: dict[str, dict] = {}
|
||||
caller_keys = list(CALLER_BASES.keys()) # ["1"-"9","0"]
|
||||
for i, identity in enumerate(ordered[:10]):
|
||||
key = caller_keys[i]
|
||||
backgrounds[key] = {
|
||||
bg = {
|
||||
"name": identity.name,
|
||||
"age": identity.age,
|
||||
"voice": identity.voice_resolved,
|
||||
@@ -912,7 +1105,11 @@ class Session:
|
||||
"specific_details": identity.specific_details,
|
||||
"emotional_register": identity.emotional_register,
|
||||
}
|
||||
if identity.name in regular_memory_by_name:
|
||||
bg.update(regular_memory_by_name[identity.name])
|
||||
backgrounds[key] = bg
|
||||
print(f"[Background] Built {len(backgrounds)} callers (from {len(identities)} raw, {len(regular_idents)} regulars locked)")
|
||||
_save_lineup_to_history(backgrounds)
|
||||
return backgrounds
|
||||
|
||||
def start_prewarm(self):
|
||||
@@ -955,8 +1152,18 @@ class Session:
|
||||
self.start_prewarm()
|
||||
|
||||
def _get_recent_summaries(self) -> list[str]:
|
||||
# Return last 2 shows' caller summaries — stub for now, can wire into cost_db
|
||||
return []
|
||||
"""Return caller name+situation strings from the last 2 lineups, so Sonnet
|
||||
can avoid repeating archetypes. Empty list on first run."""
|
||||
history = _load_lineup_history()
|
||||
recent = history[-2:]
|
||||
summaries: list[str] = []
|
||||
for record in recent:
|
||||
for caller in record.get("lineup", []):
|
||||
name = caller.get("name", "")
|
||||
situation = (caller.get("situation") or "")[:160]
|
||||
if name and situation:
|
||||
summaries.append(f"{name}: {situation}")
|
||||
return summaries
|
||||
|
||||
def reset(self):
|
||||
"""Reset session - clears all caller backgrounds for fresh personalities"""
|
||||
@@ -1044,6 +1251,59 @@ async def _stream_hold_music(caller_id: str):
|
||||
# --- Session Checkpoint ---
|
||||
CHECKPOINT_FILE = Path(__file__).parent.parent / "data" / "session_checkpoint.json"
|
||||
CHECKPOINT_MAX_AGE = 12 * 3600 # Ignore checkpoints older than 12 hours
|
||||
# Restore the persisted caller lineup only if a show is in progress (calls already
|
||||
# made) AND the checkpoint is recent. Otherwise the next session gets a fresh roster.
|
||||
CHECKPOINT_LINEUP_MAX_AGE = 3600 # 1 hour
|
||||
|
||||
# --- Lineup History (for anti-repeat context across sessions) ---
|
||||
LINEUP_HISTORY_FILE = Path(__file__).parent.parent / "data" / "caller_lineups.json"
|
||||
LINEUP_HISTORY_MAX = 10
|
||||
|
||||
|
||||
def _load_lineup_history() -> list[dict]:
|
||||
if not LINEUP_HISTORY_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
with open(LINEUP_HISTORY_FILE) as f:
|
||||
return json.load(f).get("lineups", [])
|
||||
except Exception as e:
|
||||
print(f"[LineupHistory] Load failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _recently_used_voices(n_shows: int = 2) -> set[str]:
|
||||
"""Voices assigned in the last N show lineups — caller_gen excludes these
|
||||
from the next show's voice subset to force rotation through the full pool."""
|
||||
history = _load_lineup_history()
|
||||
recent = history[-n_shows:] if history else []
|
||||
voices: set[str] = set()
|
||||
for record in recent:
|
||||
for caller in record.get("lineup", []):
|
||||
v = caller.get("voice")
|
||||
if v:
|
||||
voices.add(v)
|
||||
return voices
|
||||
|
||||
|
||||
def _save_lineup_to_history(backgrounds: dict):
|
||||
"""Append the active lineup so future shows can avoid repeating archetypes
|
||||
and rotate voices through the full pool. Stores name + situation + voice."""
|
||||
lineup = [
|
||||
{"name": v.get("name", ""), "situation": v.get("situation", ""), "voice": v.get("voice", "")}
|
||||
for v in backgrounds.values()
|
||||
if isinstance(v, dict) and v.get("name")
|
||||
]
|
||||
if not lineup:
|
||||
return
|
||||
history = _load_lineup_history()
|
||||
history.append({"timestamp": time.time(), "lineup": lineup})
|
||||
history = history[-LINEUP_HISTORY_MAX:]
|
||||
try:
|
||||
LINEUP_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(LINEUP_HISTORY_FILE, "w") as f:
|
||||
json.dump({"lineups": history}, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[LineupHistory] Save failed: {e}")
|
||||
|
||||
|
||||
def _save_checkpoint():
|
||||
@@ -1096,13 +1356,22 @@ def _load_checkpoint() -> bool:
|
||||
return False
|
||||
session.id = data["session_id"]
|
||||
session.call_history = [_deserialize_call_record(r) for r in data.get("call_history", [])]
|
||||
# Drop any legacy background dicts (pre-slim schema). They'll be regenerated
|
||||
# fresh on next Session.reset or when startup sees no restored backgrounds.
|
||||
# Only restore the caller lineup if a show is genuinely mid-flight (calls
|
||||
# already made) and the checkpoint is fresh enough that we're likely
|
||||
# recovering from a crash rather than starting a new session. Otherwise
|
||||
# the next show would inherit the previous lineup verbatim.
|
||||
raw_bgs = data.get("caller_backgrounds", {})
|
||||
calls_made = len(session.call_history)
|
||||
mid_show = calls_made > 0 and age < CHECKPOINT_LINEUP_MAX_AGE
|
||||
if mid_show:
|
||||
session.caller_backgrounds = {
|
||||
k: v for k, v in raw_bgs.items()
|
||||
if isinstance(v, dict) and "identity" in v and "situation" in v
|
||||
}
|
||||
else:
|
||||
session.caller_backgrounds = {}
|
||||
if raw_bgs:
|
||||
print(f"[Checkpoint] Dropping persisted lineup (calls={calls_made}, age={age/60:.0f}m) — fresh roster will be generated")
|
||||
session.used_reasons = set(data.get("used_reasons", []))
|
||||
session.ai_respond_mode = data.get("ai_respond_mode", "manual")
|
||||
session.auto_followup = data.get("auto_followup", False)
|
||||
@@ -1161,6 +1430,7 @@ class Voicemail:
|
||||
duration: int
|
||||
file_path: str
|
||||
listened: bool = False
|
||||
transcript: str = ""
|
||||
|
||||
|
||||
_voicemails: list[Voicemail] = []
|
||||
@@ -1178,6 +1448,7 @@ def _load_voicemails():
|
||||
id=v["id"], phone=v["phone"], timestamp=v["timestamp"],
|
||||
duration=v["duration"], file_path=v["file_path"],
|
||||
listened=v.get("listened", False),
|
||||
transcript=v.get("transcript", ""),
|
||||
)
|
||||
for v in data.get("voicemails", [])
|
||||
]
|
||||
@@ -1196,7 +1467,7 @@ def _save_voicemails():
|
||||
{
|
||||
"id": v.id, "phone": v.phone, "timestamp": v.timestamp,
|
||||
"duration": v.duration, "file_path": v.file_path,
|
||||
"listened": v.listened,
|
||||
"listened": v.listened, "transcript": v.transcript,
|
||||
}
|
||||
for v in _voicemails
|
||||
],
|
||||
@@ -1262,6 +1533,43 @@ def _build_news_context() -> tuple[str, str]:
|
||||
return news_context, research_context
|
||||
|
||||
|
||||
async def _transcribe_voicemail(vm: Voicemail) -> str:
|
||||
"""Transcribe a voicemail so Devon and the callers can react to it.
|
||||
|
||||
Whisper is blocking CPU work — run it in a thread so a voicemail arriving
|
||||
mid-show can't stall the event loop (and with it the live audio).
|
||||
"""
|
||||
fp = Path(vm.file_path)
|
||||
if not fp.exists():
|
||||
return ""
|
||||
try:
|
||||
audio_bytes = fp.read_bytes()
|
||||
loop = asyncio.get_running_loop()
|
||||
text = await loop.run_in_executor(
|
||||
None, lambda: asyncio.run(transcribe_audio(audio_bytes))
|
||||
)
|
||||
vm.transcript = (text or "").strip()
|
||||
_save_voicemails()
|
||||
if vm.transcript:
|
||||
print(f"[Voicemail] Transcribed {fp.name}: {vm.transcript[:80]}")
|
||||
else:
|
||||
print(f"[Voicemail] Transcribed {fp.name}: (no speech detected)")
|
||||
return vm.transcript
|
||||
except Exception as e:
|
||||
print(f"[Voicemail] Transcription failed for {fp.name}: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
async def _backfill_voicemail_transcripts():
|
||||
"""Transcribe any voicemails that predate transcription support."""
|
||||
pending = [v for v in _voicemails if not v.transcript and Path(v.file_path).exists()]
|
||||
if not pending:
|
||||
return
|
||||
print(f"[Voicemail] Backfilling transcripts for {len(pending)} voicemail(s)...")
|
||||
for vm in pending:
|
||||
await _transcribe_voicemail(vm)
|
||||
|
||||
|
||||
async def _sync_signalwire_voicemails():
|
||||
"""Pull any recordings from SignalWire that aren't already tracked locally.
|
||||
Checks both the top-level Recordings endpoint AND per-call recordings
|
||||
@@ -1360,6 +1668,7 @@ async def startup():
|
||||
_load_voicemails()
|
||||
_load_emails()
|
||||
asyncio.create_task(_sync_signalwire_voicemails())
|
||||
asyncio.create_task(_backfill_voicemail_transcripts())
|
||||
asyncio.create_task(_poll_imap_emails())
|
||||
restored = _load_checkpoint()
|
||||
if not restored or not session.caller_backgrounds:
|
||||
@@ -1718,6 +2027,8 @@ async def _download_voicemail(recording_url: str, caller_phone: str, duration: i
|
||||
_voicemails.append(vm)
|
||||
_save_voicemails()
|
||||
print(f"[Voicemail] Saved {filename} ({duration}s) from {caller_phone}")
|
||||
# Transcribe in the background so it's ready the moment the host airs it
|
||||
asyncio.create_task(_transcribe_voicemail(vm))
|
||||
except Exception as e:
|
||||
print(f"[Voicemail] Failed to download recording: {e}")
|
||||
|
||||
@@ -1730,6 +2041,7 @@ async def list_voicemails():
|
||||
{
|
||||
"id": v.id, "phone": v.phone, "timestamp": v.timestamp,
|
||||
"duration": v.duration, "listened": v.listened,
|
||||
"transcript": v.transcript,
|
||||
}
|
||||
for v in sorted(_voicemails, key=lambda v: v.timestamp, reverse=True)
|
||||
]
|
||||
@@ -1756,6 +2068,16 @@ async def play_voicemail_on_air(vm_id: str):
|
||||
if not fp.exists():
|
||||
raise HTTPException(status_code=404, detail="Audio file missing")
|
||||
|
||||
# Devon (and the AI callers) only perceive session.conversation, so the
|
||||
# voicemail has to land there as text or they're deaf to it.
|
||||
if vm.transcript:
|
||||
session.add_message(f"voicemail:{vm.phone}", vm.transcript)
|
||||
else:
|
||||
session.add_message(
|
||||
f"voicemail:{vm.phone}",
|
||||
f"(A {vm.duration}s voicemail from {vm.phone} just played on air — transcript unavailable.)",
|
||||
)
|
||||
|
||||
def _play():
|
||||
import librosa
|
||||
audio, sr = librosa.load(str(fp), sr=24000, mono=True)
|
||||
@@ -3166,18 +3488,23 @@ async def get_conversation_updates(since: int = 0):
|
||||
|
||||
def _dynamic_context_window() -> int:
|
||||
"""Return context window size based on conversation length.
|
||||
Short calls: 10 messages. Medium: 15. Long: 20."""
|
||||
Scales up for longer calls so the caller doesn't forget what they
|
||||
already shared and circle back to the same details."""
|
||||
n = len(session.conversation)
|
||||
if n <= 10:
|
||||
return 10
|
||||
elif n <= 16:
|
||||
return 15
|
||||
elif n <= 24:
|
||||
return 24
|
||||
elif n <= 32:
|
||||
return 32
|
||||
else:
|
||||
return 20
|
||||
return 40
|
||||
|
||||
|
||||
def _normalize_messages_for_llm(messages: list[dict]) -> list[dict]:
|
||||
"""Convert custom roles (real_caller:X, ai_caller:X, intern:X) to standard LLM roles"""
|
||||
"""Convert custom roles (real_caller:X, ai_caller:X, intern:X, voicemail:X) to standard LLM roles"""
|
||||
normalized = []
|
||||
for msg in messages:
|
||||
role = msg["role"]
|
||||
@@ -3190,6 +3517,9 @@ def _normalize_messages_for_llm(messages: list[dict]) -> list[dict]:
|
||||
elif role.startswith("intern:"):
|
||||
intern_name = role.split(":", 1)[1]
|
||||
normalized.append({"role": "user", "content": f"[Intern {intern_name}, in the studio]: {content}"})
|
||||
elif role.startswith("voicemail:"):
|
||||
vm_phone = role.split(":", 1)[1]
|
||||
normalized.append({"role": "user", "content": f"[Voicemail from {vm_phone}, played on air]: {content}"})
|
||||
elif role == "host" or role == "user":
|
||||
normalized.append({"role": "user", "content": f"[Host Luke]: {content}"})
|
||||
else:
|
||||
@@ -3245,6 +3575,7 @@ async def chat(request: ChatRequest):
|
||||
session.add_message("user", request.text)
|
||||
# session._research_task = asyncio.create_task(_background_research(request.text))
|
||||
|
||||
try:
|
||||
async with _ai_response_lock:
|
||||
if _session_epoch != epoch:
|
||||
raise HTTPException(409, "Call ended while waiting")
|
||||
@@ -3263,12 +3594,12 @@ async def chat(request: ChatRequest):
|
||||
mood += "\nSay goodbye NOW and end with [HANGUP]\n"
|
||||
|
||||
slim_caller = session.caller_backgrounds.get(session.current_caller_key, {})
|
||||
system_prompt = get_caller_prompt(slim_caller)
|
||||
system_prompt = get_caller_prompt(slim_caller, theme=session.show_theme)
|
||||
|
||||
max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping)
|
||||
messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():])
|
||||
_caller_name = session.caller.get("name", "") if session.caller else ""
|
||||
_model_override = None # caller_dialog category routes to haiku-4.5
|
||||
_model_override = None # caller_dialog category routes to sonnet-4.6
|
||||
response = await llm_service.generate(
|
||||
messages=messages,
|
||||
system_prompt=system_prompt,
|
||||
@@ -3293,6 +3624,11 @@ async def chat(request: ChatRequest):
|
||||
response = retry_response
|
||||
else:
|
||||
print(f"[Chat] Anti-repetition retry no better, keeping original")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[Chat] LLM error: {e}")
|
||||
response = "Sorry, I blanked out for a second there. What was that?"
|
||||
|
||||
# Discard if call changed while we were generating
|
||||
if _session_epoch != epoch:
|
||||
@@ -4235,12 +4571,12 @@ async def _trigger_ai_auto_respond(accumulated_text: str):
|
||||
if session._wrapup_exchanges > 2:
|
||||
mood += "\nSay goodbye NOW and end with [HANGUP]\n"
|
||||
slim_caller = session.caller_backgrounds.get(session.current_caller_key, {})
|
||||
system_prompt = get_caller_prompt(slim_caller)
|
||||
system_prompt = get_caller_prompt(slim_caller, theme=session.show_theme)
|
||||
|
||||
max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping)
|
||||
messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():])
|
||||
_caller_name = session.caller.get("name", "") if session.caller else ""
|
||||
_model_override = None # caller_dialog category routes to haiku-4.5
|
||||
_model_override = None # caller_dialog category routes to sonnet-4.6
|
||||
response = await llm_service.generate(
|
||||
messages=messages,
|
||||
system_prompt=system_prompt,
|
||||
@@ -4342,6 +4678,7 @@ async def ai_respond():
|
||||
|
||||
epoch = _session_epoch
|
||||
|
||||
try:
|
||||
async with _ai_response_lock:
|
||||
if _session_epoch != epoch:
|
||||
raise HTTPException(409, "Call ended while waiting")
|
||||
@@ -4356,12 +4693,12 @@ async def ai_respond():
|
||||
if session._wrapup_exchanges > 2:
|
||||
mood += "\nSay goodbye NOW and end with [HANGUP]\n"
|
||||
slim_caller = session.caller_backgrounds.get(session.current_caller_key, {})
|
||||
system_prompt = get_caller_prompt(slim_caller)
|
||||
system_prompt = get_caller_prompt(slim_caller, theme=session.show_theme)
|
||||
|
||||
max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping)
|
||||
messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():])
|
||||
_caller_name = session.caller.get("name", "") if session.caller else ""
|
||||
_model_override = None # caller_dialog category routes to haiku-4.5
|
||||
_model_override = None # caller_dialog category routes to sonnet-4.6
|
||||
response = await llm_service.generate(
|
||||
messages=messages,
|
||||
system_prompt=system_prompt,
|
||||
@@ -4386,6 +4723,11 @@ async def ai_respond():
|
||||
response = retry_response
|
||||
else:
|
||||
print(f"[Chat] Anti-repetition retry no better, keeping original")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[AI-Respond] LLM error: {e}")
|
||||
response = "Sorry, I blanked out for a second there. What was that?"
|
||||
|
||||
if _session_epoch != epoch:
|
||||
raise HTTPException(409, "Call changed during response")
|
||||
|
||||
@@ -1344,7 +1344,8 @@ class AudioService:
|
||||
if self._monitor_write:
|
||||
self._monitor_write(indata[:, record_channel].copy())
|
||||
|
||||
self._stem_mic_stream = sd.InputStream(
|
||||
def _open():
|
||||
return sd.InputStream(
|
||||
device=self.input_device,
|
||||
channels=max_channels,
|
||||
samplerate=device_sr,
|
||||
@@ -1352,6 +1353,17 @@ class AudioService:
|
||||
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)")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
import json
|
||||
import random
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -51,23 +52,48 @@ def parse_batch_response(raw: str) -> list[CallerIdentity]:
|
||||
|
||||
def resolve_voice(suggestion: str, roster: list[str]) -> str:
|
||||
"""Map sonnet's voice suggestion to a real voice in the roster.
|
||||
Case-insensitive exact match; deterministic fallback to first roster entry."""
|
||||
if not suggestion or not roster:
|
||||
return roster[0] if roster else ""
|
||||
lower_map = {v.lower(): v for v in roster}
|
||||
return lower_map.get(suggestion.lower(), roster[0])
|
||||
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)
|
||||
|
||||
|
||||
BATCH_SYSTEM_PROMPT = """You are writing a roster of callers for Luke's late-night radio show in New Mexico.
|
||||
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 conspiracy/pattern-seers, two retired military, two grandmothers-of-many, two callers calling about a weird neighbor, two callers with religious-object stories. Each caller's defining "thing" — their hook, their obsession, the specific topic they're calling about — must appear exactly once in the roster. Before finalizing, scan your output and swap any collisions.
|
||||
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.
|
||||
|
||||
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."""
|
||||
|
||||
|
||||
@@ -81,6 +107,19 @@ def build_batch_prompt(ctx: dict) -> str:
|
||||
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"]:
|
||||
|
||||
@@ -37,10 +37,15 @@ class TTSCallRecord:
|
||||
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},
|
||||
"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},
|
||||
@@ -60,6 +65,7 @@ OPENROUTER_PRICING = {
|
||||
"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},
|
||||
|
||||
@@ -21,7 +21,7 @@ INTERN_SYSTEM_PROMPT = """You are Devon, the 23-year-old intern on "Luke at the
|
||||
|
||||
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 Deming. 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 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.
|
||||
|
||||
@@ -12,33 +12,32 @@ from .cost_tracker import cost_tracker
|
||||
OPENROUTER_MODELS = [
|
||||
# Primary
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"x-ai/grok-4.1-fast",
|
||||
"x-ai/grok-4",
|
||||
"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",
|
||||
# Other good options
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"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-fast",
|
||||
"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",
|
||||
]
|
||||
@@ -332,8 +331,8 @@ class LLMService:
|
||||
# - Grok/Mistral/DeepSeek/Kimi: slightly warmer than Sonnet defaults
|
||||
_CALLER_DIALOG_MODEL_PARAMS = {
|
||||
"anthropic/claude-sonnet-4.6": {"temperature": 0.65, "frequency_penalty": 0.3, "presence_penalty": 0.15},
|
||||
"x-ai/grok-4.1-fast": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
|
||||
"x-ai/grok-4": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
|
||||
"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},
|
||||
|
||||
@@ -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
|
||||
|
||||
+31
-9
@@ -83,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
|
||||
@@ -161,11 +165,18 @@ VOICE_PROFILES = {
|
||||
"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"},
|
||||
@@ -175,13 +186,19 @@ VOICE_PROFILES = {
|
||||
"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"},
|
||||
@@ -200,10 +217,12 @@ VOICE_PROFILES = {
|
||||
"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"},
|
||||
@@ -212,9 +231,12 @@ VOICE_PROFILES = {
|
||||
"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"},
|
||||
}
|
||||
@@ -771,7 +793,7 @@ async def generate_speech_inworld(text: str, voice_id: str, emotional_register:
|
||||
payload = {
|
||||
"text": text,
|
||||
"voiceId": voice,
|
||||
"modelId": "inworld-tts-1.5-max",
|
||||
"modelId": "inworld-tts-2",
|
||||
"temperature": temperature,
|
||||
"applyTextNormalization": "ON",
|
||||
"audioConfig": {
|
||||
|
||||
+22
-2
@@ -1434,12 +1434,32 @@ section h2 {
|
||||
|
||||
.vm-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid rgba(232, 121, 29, 0.08);
|
||||
}
|
||||
|
||||
.vm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vm-transcript {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
color: var(--text-muted);
|
||||
border-left: 2px solid rgba(232, 121, 29, 0.25);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.vm-transcript-pending {
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.vm-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
+2
-2
@@ -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?v=2">
|
||||
<link rel="stylesheet" href="/css/style.css?v=3">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
@@ -325,6 +325,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/app.js?v=28"></script>
|
||||
<script src="/js/app.js?v=29"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1993,7 +1993,11 @@ function renderVoicemails(voicemails) {
|
||||
const secs = v.duration % 60;
|
||||
const durStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
||||
const unlistenedCls = v.listened ? '' : ' vm-unlistened';
|
||||
const transcript = v.transcript
|
||||
? `<div class="vm-transcript">${escapeHtml(v.transcript)}</div>`
|
||||
: `<div class="vm-transcript vm-transcript-pending">Transcribing…</div>`;
|
||||
return `<div class="vm-item${unlistenedCls}" data-id="${v.id}">
|
||||
<div class="vm-row">
|
||||
<div class="vm-info">
|
||||
<span class="vm-phone">${v.phone}</span>
|
||||
<span class="vm-time">${timeStr}</span>
|
||||
@@ -2005,6 +2009,8 @@ function renderVoicemails(voicemails) {
|
||||
<button class="vm-btn save" onclick="saveVoicemail('${v.id}')">Save</button>
|
||||
<button class="vm-btn delete" onclick="deleteVoicemail('${v.id}')">Del</button>
|
||||
</div>
|
||||
</div>
|
||||
${transcript}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ def _llm_request(prompt: str, max_tokens: int = 2048, temperature: float = 0.3,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -63,8 +63,15 @@ def test_resolve_voice_case_insensitive():
|
||||
def test_resolve_voice_falls_back_when_no_match():
|
||||
from backend.services.caller_gen import resolve_voice
|
||||
roster = ["Marcus", "Dennis"]
|
||||
# Deterministic fallback: return first from roster
|
||||
assert resolve_voice("Santiago", roster) == "Marcus"
|
||||
# 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():
|
||||
@@ -103,7 +110,7 @@ def test_build_batch_prompt_includes_silas_lore_when_present():
|
||||
"weather": "...",
|
||||
"headlines": [],
|
||||
"recent_caller_summaries": [],
|
||||
"regulars_included": [{"name": "Silas", "lore": "Silas leads a small desert cult...", "arc_state": "seeking new members"}],
|
||||
"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"],
|
||||
}
|
||||
|
||||
@@ -22,4 +22,36 @@ def test_prompt_includes_identity_and_situation():
|
||||
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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -774,7 +774,7 @@ def sync_clips_to_website():
|
||||
deploy = subprocess.run(
|
||||
["npx", "wrangler", "pages", "deploy", "website/",
|
||||
"--project-name=lukeattheroost", "--branch=main", "--commit-dirty=true"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
capture_output=True, text=True,
|
||||
cwd=str(Path(__file__).parent),
|
||||
)
|
||||
if "Deployment complete" in deploy.stdout:
|
||||
|
||||
Reference in New Issue
Block a user