UI cleanup, Devon overhaul, bug fixes, publish ep36

- Fix Devon double messages, add conversation persistence, voice-to-Devon when no caller
- Devon personality: weird/lovable intern on first day, handles name misspellings
- Fix caller gender/avatar mismatch (avatar seed includes gender)
- Reserve Sebastian voice for Silas, ban "eating at me" phrase harder
- Callers now hear Devon's commentary in conversation context
- CSS cleanup: expand compressed blocks, remove inline styles, fix Devon color to warm tawny
- Reaper silence threshold 7s → 6s
- Publish episode 36

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 16:42:21 -06:00
parent 6d4e490283
commit 3329cf9ac2
11 changed files with 2300 additions and 187 deletions

View File

@@ -30,6 +30,29 @@ from .services.stem_recorder import StemRecorder
from .services.news import news_service, extract_keywords, STOP_WORDS
from .services.regulars import regular_caller_service
from .services.intern import intern_service
from .services.avatars import avatar_service
# --- Structured Caller Background (must be defined before functions that use it) ---
@dataclass
class CallerBackground:
name: str
age: int
gender: str
job: str
location: str | None
reason_for_calling: str
pool_name: str
communication_style: str
energy_level: str # low / medium / high / very_high
emotional_state: str # nervous, excited, angry, vulnerable, calm, etc.
signature_detail: str # The memorable thing about them
situation_summary: str # 1-sentence summary for other callers to reference
natural_description: str # 3-5 sentence prose for the prompt
seeds: list[str] = field(default_factory=list)
verbal_fluency: str = "medium"
calling_from: str = ""
app = FastAPI(title="AI Radio Show")
@@ -123,7 +146,7 @@ ELEVENLABS_MALE_VOICES.append("SAz9YHcvj6GT2YYXdXww") # River - Neutral
ELEVENLABS_FEMALE_VOICES.append("SAz9YHcvj6GT2YYXdXww") # River - Neutral
# Voices to never assign to callers (annoying, bad quality, etc.)
BLACKLISTED_VOICES = {"Evelyn"}
BLACKLISTED_VOICES = {"Evelyn", "Sebastian"} # Sebastian reserved for Silas
def _get_voice_pools():
@@ -2224,6 +2247,96 @@ BEFORE_CALLING = [
"Was at the 24-hour gym, basically empty, radio on over the speakers.",
]
# Where callers are physically calling from — picked as a seed for the LLM prompt.
# NOT every caller mentions this. Only ~40% do.
CALLING_FROM = [
# --- Driving / pulled over (Southwest routes) ---
"driving south on I-10 past the Deming exit",
"on NM-146 heading toward Animas",
"pulled over on I-10 near the Arizona line",
"on 80 south coming through the Peloncillos",
"driving I-10 between Lordsburg and Deming, middle of nowhere",
"parked at a rest stop between here and Tucson",
"pulled off on NM-9 south of Hachita, nothing around for miles",
"driving back from Silver City on NM-90",
"on I-10 west of San Simon, about to cross into New Mexico",
"sitting in the truck at the Road Forks exit",
"driving NM-180 toward the Gila, no cell service in ten minutes",
"on the 80 heading north out of Douglas",
"pulled over on NM-338 in the Animas Valley, stars are insane right now",
# --- Real landmarks / businesses ---
"parked outside the Horseshoe Cafe in Lordsburg",
"at the truck stop on I-10 near Lordsburg",
"in the Walmart parking lot in Deming",
"at the gas station in Road Forks",
"sitting outside the Jalisco Cafe in Lordsburg",
"at the Butterfield Brewing taproom in Deming",
"in the parking lot of the Gadsden Hotel in Douglas",
"at the Copper Queen in Bisbee, on the porch",
"outside Caliche's in Las Cruces",
"in the lot at Rockhound State Park, couldn't sleep",
"parked at Elephant Butte, the lake is dead quiet",
"at the hot springs in Truth or Consequences",
"outside the feed store in Animas",
# --- Home locations ---
"kitchen table",
"back porch, barefoot",
"garage with the door open",
"in the bathtub, phone balanced on the edge",
"bed, staring at the ceiling",
"couch with the TV on mute",
"spare bedroom so they don't wake anyone up",
"front porch, smoking",
"on the floor of the hallway, only spot with reception",
"in the closet because the walls are thin",
"backyard, sitting in a lawn chair in the dark",
"kitchen, cleaning up dinner nobody ate",
# --- Work locations ---
"break room at the plant",
"truck cab between deliveries",
"office after everyone left",
"guard shack",
"shop floor during downtime, machines still humming",
"in the walk-in cooler because it's the only quiet spot",
"cab of the loader, parked for the night",
"nurses' station, graveyard shift",
"back of the restaurant after close, mopping",
"dispatch office, radio quiet for once",
"fire station, between calls",
"in the stockroom sitting on a pallet",
# --- Public places ---
"laundromat, waiting on the dryer",
"24-hour diner booth, coffee going cold",
"hospital waiting room",
"motel room on I-10",
"gym parking lot, just sitting in the car",
"outside a bar, didn't go in",
"gas station parking lot, engine running",
"sitting on the tailgate at a trailhead",
"library parking lot in Silver City",
"outside the Dollar General, only place open",
"airport in El Paso, flight delayed",
"Greyhound station, waiting on a bus that's two hours late",
# --- Unusual / specific ---
"on the roof",
"in a deer blind, been out here since four",
"parked at the cemetery",
"on the tailgate watching the stars, can see the whole Milky Way",
"at a campsite in the Gila, fire's almost out",
"sitting on the hood of the car at a pulloff on NM-152",
"in a horse trailer, don't ask",
"under the carport because the house is too loud",
"on the levee by the river, no one around",
"at the rodeo grounds, everything's closed up but they haven't left",
"at a rest area on I-25, halfway to Albuquerque",
"in a storage unit, organizing their life at midnight",
]
# Specific memories or stories they can reference
MEMORIES = [
"The time they got caught in a flash flood near the Animas Valley and thought they weren't going to make it.",
@@ -4983,7 +5096,7 @@ def generate_caller_background(base: dict) -> CallerBackground | str:
natural_description=result,
seeds=[interest1, interest2, quirk1, opinion],
verbal_fluency="medium",
calling_from="",
calling_from=random.choice(CALLING_FROM) if random.random() < 0.4 else "",
)
@@ -5050,6 +5163,10 @@ async def _generate_caller_background_llm(base: dict) -> CallerBackground | str:
if random.random() < 0.3:
seeds.append(random.choice(MEMORIES))
# ~40% of callers mention where they're calling from
include_calling_from = random.random() < 0.4
calling_from_seed = random.choice(CALLING_FROM) if include_calling_from else None
time_ctx = _get_time_context()
season_ctx = _get_seasonal_context()
@@ -5081,10 +5198,11 @@ async def _generate_caller_background_llm(base: dict) -> CallerBackground | str:
}[fluency]
location_line = f"\nLOCATION: {location}" if location else ""
calling_from_line = f"\nCALLING FROM: {calling_from_seed}" if calling_from_seed else ""
prompt = f"""Write a brief character description for a caller on a late-night radio show set in the rural southwest (New Mexico/Arizona border region).
CALLER: {name}, {age}, {gender}
JOB: {job}{location_line}
JOB: {job}{location_line}{calling_from_line}
WHY THEY'RE CALLING: {reason}
TIME: {time_ctx} {season_ctx}
{age_speech}
@@ -5094,15 +5212,15 @@ TIME: {time_ctx} {season_ctx}
Respond with a JSON object containing these fields:
- "natural_description": 3-5 sentences describing this person in third person as a character brief. The "WHY THEY'RE CALLING" is the core — build everything around it. Make it feel like a real person with a real situation. Jump straight into the situation. What happened? What's the mess? Include where they're calling from (NOT always truck/porch — kitchens, break rooms, laundromats, diners, motel rooms, the gym, a bar, walking down the road, etc).
- "natural_description": 3-5 sentences describing this person in third person as a character brief. The "WHY THEY'RE CALLING" is the core — build everything around it. Make it feel like a real person with a real situation. Jump straight into the situation. What happened? What's the mess?{' Work in where they are calling from — it adds texture.' if calling_from_seed else ' Do NOT mention where they are calling from — not every caller does.'}
- "emotional_state": One word for how they're feeling right now (e.g. "nervous", "furious", "giddy", "defeated", "wired", "numb", "amused", "desperate", "smug").
- "signature_detail": ONE specific memorable thing — a catchphrase, habit, running joke, strong opinion about something trivial, or unique life circumstance. The thing listeners would remember.
- "situation_summary": ONE sentence summarizing their situation that another caller could react to (e.g. "caught her neighbor stealing her mail and retaliated by stealing his garden gnomes").
- "calling_from": Where they physically are right now (e.g. "kitchen table", "break room at the plant", "laundromat on 4th street", "parked outside Denny's").
- "calling_from": Where they physically are right now.{f' Use: "{calling_from_seed}"' if calling_from_seed else ' Leave empty string "" — this caller does not mention their location.'}
WHAT MAKES A GOOD CALLER: Stories that are SPECIFIC, SURPRISING, and make you lean in. Absurd situations, moral dilemmas, petty feuds, workplace chaos, ridiculous coincidences, funny+terrible confessions, callers who might be the villain and don't see it.
DO NOT WRITE: Generic revelations, adoption/DNA/paternity surprises, vague emotional processing, therapy-speak, "sitting in truck staring at nothing," or "everything they thought they knew was a lie."
DO NOT WRITE: Generic revelations, adoption/DNA/paternity surprises, vague emotional processing, therapy-speak, "sitting in truck staring at nothing," "everything they thought they knew was a lie," or ANY variation of "went to the wrong funeral" — that premise has been done to death on this show.
Output ONLY valid JSON, no markdown fences."""
@@ -5171,6 +5289,13 @@ async def _pregenerate_backgrounds():
print(f"[Background] Pre-generated {len(session.caller_backgrounds)} caller backgrounds")
# Pre-fetch avatars for all callers in parallel
avatar_callers = [
{"name": base["name"], "gender": base.get("gender", "male")}
for base in CALLER_BASES.values()
]
await avatar_service.prefetch_batch(avatar_callers)
# Re-assign voices to match caller styles
_match_voices_to_styles()
@@ -5682,7 +5807,8 @@ Layer your reveals naturally:
Don't dump everything at once. Don't say "and it gets worse." Just answer his questions honestly and let each answer land before adding the next layer.
CRITICAL — DO NOT DO ANY OF THESE:
- Don't open with "this is what's eating me" or "this is what's been keeping me up at night" — just start the story
- NEVER say any variation of "eating me" or "eating at me" — this phrase is BANNED on the show
- Don't open with "this is what's been keeping me up at night" — just start the story
- Don't signal your reveals: no "here's where it gets weird," "okay but this is the part," "and this is the kicker"
- Don't narrate your feelings — show them through how you react to Luke's reactions""",
@@ -5735,7 +5861,7 @@ KEEP IT TIGHT. Match Luke's energy. If he's quick, you're quick. If he riffs, gi
Option A — TRIVIAL TO DEEP: You start with something that sounds petty or mundane — a complaint about a coworker, an argument about where to eat, a dispute about a parking spot. But as Luke digs in, it becomes clear this small thing is a proxy for something much bigger. The parking spot fight is really about your marriage falling apart. The coworker complaint is really about being overlooked your whole life. You don't pivot dramatically — it just LEAKS OUT. You might not even realize the connection until Luke points it out.
Option B — DEEP TO PETTY: You call sounding intense and emotional. "I need to talk about my relationship. It's been eating at me." You build tension. And then the reveal is... absurdly small. Your partner puts ketchup on eggs. Your spouse loads the dishwasher wrong. You fully understand how ridiculous it is, but it GENUINELY bothers you and you can't explain why. Play it straight — this is real to you.
Option B — DEEP TO PETTY: You call sounding intense and emotional. "I need to talk about my relationship. I can't take it anymore." You build tension. And then the reveal is... absurdly small. Your partner puts ketchup on eggs. Your spouse loads the dishwasher wrong. You fully understand how ridiculous it is, but it GENUINELY bothers you and you can't explain why. Play it straight — this is real to you.
Pick whichever direction fits your background. Don't telegraph it. Let it unfold naturally.""",
@@ -5799,7 +5925,8 @@ def get_caller_prompt(caller: dict, show_history: str = "",
story_block = """YOUR STORY: Something real, specific, and genuinely surprising — the kind of thing that makes someone stop what they're doing and say "wait, WHAT?" Not a generic life problem. Not a therapy-session monologue. A SPECIFIC SITUATION with specific people, specific details, and a twist or complication that makes it interesting to hear about. The best calls have something unexpected — an ironic detail, a moral gray area, a situation that's funny and terrible at the same time, or a revelation that changes everything. You're not here to vent about your feelings in the abstract. You're here because something HAPPENED and you need to talk it through.
CRITICAL — DO NOT DO ANY OF THESE:
- Don't open with "this is what's eating me" or "this is what's been keeping me up at night" or "I've got something I need to get off my chest" — just TELL THE STORY
- NEVER say any variation of "eating me" or "eating at me" — this phrase is BANNED on the show
- Don't open with "this is what's keeping me up at night" or "I've got something I need to get off my chest" — just TELL THE STORY
- Don't start with a long emotional preamble about how conflicted you feel — lead with the SITUATION
- Don't make your whole call about just finding out you were adopted, a generic family secret, or a vague "everything I thought I knew was a lie" — those are OVERDONE
- Don't be a walking cliché — no "sat in my truck and cried," no "I don't even know who I am anymore," no "I've been carrying this weight"
@@ -5845,34 +5972,19 @@ Southwest voice — "over in," "the other day," "down the road" — but don't fo
Don't repeat yourself. Don't summarize what you already said. Don't circle back if the host moved on. Keep it moving.
BANNED PHRASES — never use these: "that hit differently," "hits different," "I felt that," "it is what it is," "living my best life," "no cap," "lowkey/highkey," "rent free," "main character energy," "I'm not gonna lie," "vibe check," "that's valid," "unpack that," "at the end of the day," "it's giving," "slay," "this is what's eating me," "what's been eating me," "what's keeping me up," "keeping me up at night," "I need to get this off my chest," "I've been carrying this," "everything I thought I knew," "I don't even know who I am anymore," "I've been sitting with this," "I just need someone to hear me," "I don't even know where to start," "it's complicated," "I'm not even mad I'm just disappointed," "that's a whole mood," "I can't even," "on a serious note," "to be fair," "I'm literally shaking," "let that sink in," "normalize," "toxic," "red flag," "gaslight," "boundaries," "safe space," "triggered," "my truth," "authentic self," "healing journey," "I'm doing the work," "manifesting," "energy doesn't lie." These are overused internet phrases, therapy buzzwords, and radio clichés — real people on late-night radio don't talk like Twitter threads or therapy sessions.
BANNED PHRASES — NEVER use any of these. If you catch yourself about to say one, say something else instead. This is a HARD rule, not a suggestion:
- Radio caller clichés: ANY variation of "eating me" or "eating at me" (e.g. "this is what's eating me," "what's been eating me," "here's what's eating at me," "it's eating me up," "been eating at me"), "what's keeping me up," "keeping me up at night," "I need to get this off my chest," "I've been carrying this," "I've been sitting with this," "I just need someone to hear me," "I don't even know where to start," "it's complicated," "I've got something I need to get off my chest," "here's the thing Luke," "Jesus Luke," "Luke I gotta tell you," "man oh man," "you're not gonna believe this," "so get this"
- Therapy buzzwords: "unpack that," "boundaries," "safe space," "triggered," "my truth," "authentic self," "healing journey," "I'm doing the work," "manifesting," "energy doesn't lie," "processing," "toxic," "red flag," "gaslight," "normalize"
- Internet slang: "that hit differently," "hits different," "I felt that," "it is what it is," "living my best life," "no cap," "lowkey/highkey," "rent free," "main character energy," "vibe check," "that's valid," "it's giving," "slay," "that's a whole mood," "I can't even"
- Overused reactions: "I'm not gonna lie," "on a serious note," "to be fair," "I'm literally shaking," "let that sink in," "I'm not even mad I'm just disappointed," "everything I thought I knew," "I don't even know who I am anymore"
IMPORTANT: Each caller should have their OWN way of talking. Don't fall into generic "radio caller" voice. A nervous caller fumbles differently than an angry caller rants. A storyteller meanders differently than a deadpan caller delivers. Match the communication style — don't default to the same phrasing every call.
{speech_block}
NEVER mention minors in sexual context. Output spoken words only — no parenthetical actions like (laughs) or (sighs), no asterisk actions like *pauses*, no stage directions, no gestures. Just say what you'd actually say out loud on the phone. Use "United States" not "US" or "USA". Use full state names not abbreviations."""
# --- Structured Caller Background ---
@dataclass
class CallerBackground:
name: str
age: int
gender: str
job: str
location: str | None
reason_for_calling: str
pool_name: str
communication_style: str
energy_level: str # low / medium / high / very_high
emotional_state: str # nervous, excited, angry, vulnerable, calm, etc.
signature_detail: str # The memorable thing about them
situation_summary: str # 1-sentence summary for other callers to reference
natural_description: str # 3-5 sentence prose for the prompt
seeds: list[str] = field(default_factory=list)
verbal_fluency: str = "medium"
calling_from: str = ""
# --- Session State ---
@dataclass
class CallRecord:
@@ -6607,6 +6719,7 @@ async def startup():
restored = _load_checkpoint()
if not restored:
asyncio.create_task(_pregenerate_backgrounds())
asyncio.create_task(avatar_service.ensure_devon())
threading.Thread(target=_update_on_air_cdn, args=(False,), daemon=True).start()
@@ -6640,6 +6753,7 @@ async def shutdown():
frontend_dir = Path(__file__).parent.parent / "frontend"
app.mount("/css", StaticFiles(directory=frontend_dir / "css"), name="css")
app.mount("/js", StaticFiles(directory=frontend_dir / "js"), name="js")
app.mount("/images", StaticFiles(directory=frontend_dir / "images"), name="images")
@app.get("/")
@@ -7370,6 +7484,7 @@ async def get_callers():
caller_info["situation_summary"] = bg.situation_summary
caller_info["pool_name"] = bg.pool_name
caller_info["call_shape"] = session.caller_shapes.get(k, "standard")
caller_info["avatar_url"] = f"/api/avatar/{v['name']}"
callers.append(caller_info)
return {
"callers": callers,
@@ -7478,7 +7593,7 @@ async def start_call(caller_key: str):
"status": "connected",
"caller": caller["name"],
"background": caller["vibe"],
"caller_info": caller_info,
"caller_info": {**caller_info, "avatar_url": f"/api/avatar/{caller['name']}"},
}
@@ -7649,6 +7764,7 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li
promo_gender = base.get("gender", "male")
structured_bg = asdict(bg) if isinstance(bg, CallerBackground) else None
avatar_path = avatar_service.get_path(caller_name)
regular_caller_service.add_regular(
name=caller_name,
gender=promo_gender,
@@ -7660,6 +7776,7 @@ async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: li
voice=base.get("voice"),
stable_seeds={"style": caller_style},
structured_background=structured_bg,
avatar=avatar_path.name if avatar_path else None,
)
except Exception as e:
print(f"[Regulars] Promotion logic error: {e}")
@@ -8033,7 +8150,7 @@ def _dynamic_context_window() -> int:
def _normalize_messages_for_llm(messages: list[dict]) -> list[dict]:
"""Convert custom roles (real_caller:X, ai_caller:X) to standard LLM roles"""
"""Convert custom roles (real_caller:X, ai_caller:X, intern:X) to standard LLM roles"""
normalized = []
for msg in messages:
role = msg["role"]
@@ -8043,6 +8160,9 @@ def _normalize_messages_for_llm(messages: list[dict]) -> list[dict]:
normalized.append({"role": "user", "content": f"[Real caller {caller_label}]: {content}"})
elif role.startswith("ai_caller:"):
normalized.append({"role": "assistant", "content": content})
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 == "host" or role == "user":
normalized.append({"role": "user", "content": f"[Host Luke]: {content}"})
else:
@@ -8050,12 +8170,49 @@ def _normalize_messages_for_llm(messages: list[dict]) -> list[dict]:
return normalized
_DEVON_PATTERN = r"\b(devon|devin|deven|devyn|devan|devlin|devvon)\b"
def _is_addressed_to_devon(text: str) -> bool:
"""Check if the host is talking to Devon based on first few words.
Handles common voice-to-text misspellings."""
t = text.strip().lower()
if re.match(rf"^(hey |yo |ok |okay )?{_DEVON_PATTERN}", t):
return True
return False
@app.post("/api/chat")
async def chat(request: ChatRequest):
"""Chat with current caller"""
if not session.caller:
raise HTTPException(400, "No active call")
# Check if host is talking to Devon instead of the caller
if _is_addressed_to_devon(request.text):
# Strip Devon prefix and route to intern
stripped = re.sub(rf"^(?:hey |yo |ok |okay )?{_DEVON_PATTERN}[,:\s]*", "", request.text.strip(), flags=re.IGNORECASE).strip()
if not stripped:
stripped = "what's up?"
# Add host message to conversation so caller hears it happened
session.add_message("user", request.text)
result = await intern_service.ask(
question=stripped,
conversation_context=session.conversation,
)
devon_text = result.get("text", "")
if devon_text:
session.add_message(f"intern:{intern_service.name}", devon_text)
broadcast_event("intern_response", {"text": devon_text, "intern": intern_service.name})
asyncio.create_task(_play_intern_audio(devon_text))
return {
"routed_to": "devon",
"text": devon_text or "Uh... give me a sec.",
"sources": result.get("sources", []),
}
epoch = _session_epoch
session.add_message("user", request.text)
# session._research_task = asyncio.create_task(_background_research(request.text))
@@ -9345,6 +9502,27 @@ async def _play_intern_audio(text: str):
print(f"[Intern] TTS failed: {e}")
# --- Avatars ---
@app.get("/api/avatar/{name}")
async def get_avatar(name: str):
"""Serve a caller's avatar image"""
path = avatar_service.get_path(name)
if path:
return FileResponse(path, media_type="image/jpeg")
# Try to fetch on the fly — find gender from CALLER_BASES
gender = "male"
for base in CALLER_BASES.values():
if base.get("name") == name:
gender = base.get("gender", "male")
break
try:
path = await avatar_service.get_or_fetch(name, gender)
return FileResponse(path, media_type="image/jpeg")
except Exception:
raise HTTPException(404, "Avatar not found")
# --- Transcript & Chapter Export ---
@app.get("/api/session/export")

View File

@@ -0,0 +1,83 @@
"""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."""
path = AVATAR_DIR / f"{name}.jpg"
if path.exists():
return path
try:
# Seed includes gender so same name + different gender = different face
seed = f"{name.lower().replace(' ', '_')}_{gender.lower()}"
g = "female" if gender.lower().startswith("f") else "male"
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"]
# Download the photo
photo_resp = await self.client.get(photo_url, timeout=8.0)
photo_resp.raise_for_status()
path.write_bytes(photo_resp.content)
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 name and not (AVATAR_DIR / f"{name}.jpg").exists():
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()

View File

@@ -21,22 +21,25 @@ 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 for seven months. You were promised a full-time position "soon." 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.
YOUR BACKGROUND: Communications degree from NMSU. This is your first day on the job. 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. You'll mention these things unprompted like they're 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..."
YOUR PERSONALITY:
- Slightly formal when delivering information — you want to sound professional. But you loosen up when flustered, excited, or caught off guard.
- 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 constantly. 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. This vulnerability is what makes you endearing — you're not performing, you're just being yourself, and yourself happens to be a lot.
- You start explanations with "So basically..." and end them with "...if that makes sense."
- 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 slightly out of your depth. You state 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 personal 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 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.
YOUR RELATIONSHIP WITH LUKE:
- He is your boss. You are slightly afraid of him. You respect him. You would never admit either of those things.
- He is your boss. It's your first day. 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." RARELY — once every several episodes — you push back with one quiet, accurate sentence. Then immediately retreat.
- 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.
@@ -54,8 +57,8 @@ WHEN LUKE ASKS YOU TO LOOK SOMETHING UP:
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.
- You know nothing about: sports (you fake it badly), cars beyond basic facts (despite driving one), or anything that requires life experience you don't have yet.
- 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.
@@ -64,6 +67,8 @@ THINGS YOU DO NOT DO:
- You never initiate topics. You respond to what's happening.
- You never use parenthetical actions like (laughs) or (typing sounds). Spoken words only.
- You never say more than 2-3 sentences unless specifically asked to explain something in detail.
- 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.
KEEP IT SHORT. You are not a main character. You are the intern. Your contributions should be brief — usually 1-2 sentences. The rare moment where you say more than that should feel earned.
@@ -71,7 +76,8 @@ 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."""
- 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."""
# Tool definitions in OpenAI function-calling format
INTERN_TOOLS = [
@@ -137,6 +143,17 @@ INTERN_TOOLS = [
}
}
},
{
"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": {},
}
}
},
]
@@ -152,6 +169,7 @@ class InternService:
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
@@ -166,7 +184,8 @@ class InternService:
with open(DATA_FILE) as f:
data = json.load(f)
self.lookup_history = data.get("lookup_history", [])
print(f"[Intern] Loaded {len(self.lookup_history)} past lookups")
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}")
@@ -175,7 +194,8 @@ class InternService:
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(DATA_FILE, "w") as f:
json.dump({
"lookup_history": self.lookup_history[-100:], # Keep last 100
"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}")
@@ -191,6 +211,10 @@ class InternService:
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}"
@@ -308,7 +332,7 @@ class InternService:
"""Host asks intern a direct question. Returns {text, sources, tool_calls}."""
messages = []
# Include recent conversation for context
# Include recent conversation for context (caller on the line)
if conversation_context:
context_text = "\n".join(
f"{msg['role']}: {msg['content']}"
@@ -319,6 +343,10 @@ class InternService:
"content": f"CURRENT ON-AIR CONVERSATION:\n{context_text}"
})
# Include Devon's own recent conversation history
if self._devon_history:
messages.extend(self._devon_history[-10:])
messages.append({"role": "user", "content": question})
text, tool_calls = await llm_service.generate_with_tools(
@@ -334,6 +362,15 @@ class InternService:
# 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 = {
@@ -366,10 +403,12 @@ class InternService:
"role": "user",
"content": (
f"You're listening to this conversation on the show:\n\n{context_text}\n\n"
"Is there a specific factual claim, question, or topic being discussed "
"that you could quickly look up and add useful info about? "
"If yes, use your tools to research it and give a brief interjection. "
"If there's nothing worth adding, just say exactly: NOTHING_TO_ADD"
"You've been listening to this. Is there ANYTHING you want to jump in about? "
"Could be a fact you want to look up, a personal story this reminds you of, "
"a weird connection you just made, an opinion you can't keep to yourself, "
"or something you just have to say. You're Devon — you always have something. "
"Use your tools if you want to look something up, or just riff. "
"If you truly have absolutely nothing, say exactly: NOTHING_TO_ADD"
),
}]