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

@@ -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"
),
}]