Files
ai-podcast/docs/plans/2026-04-05-caller-generation-redesign-implementation.md
T
lukeandClaude Opus 4.6 a196e8f088 Add caller generation redesign implementation plan
22 tasks across 5 phases: scaffolding, regulars v2, main.py integration
behind feature flag, user validation gate, then deletion of ~2000 lines.
TDD where possible; manual verification of LLM outputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 02:09:27 -06:00

40 KiB

Caller Generation Redesign — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace the current static-pool + 9-model caller generation with a two-stage architecture: rich identities pre-generated in one sonnet-4.6 batch call per show, plus live dialog through a single claude-haiku-4.5 model. Tiered regulars system with Silas canonical in the Obsidian vault.

Architecture: Two new service modules (caller_gen.py, regulars_v2.py). Phase 1-3 are ADDITIVE — no old code removed until user validates sample calls. Phase 5 deletes ~2000 lines after cutover.

Tech Stack: Python 3.11, FastAPI, OpenRouter (anthropic/claude-sonnet-4.6, anthropic/claude-haiku-4.5, google/gemini-2.5-flash), pytest, pydantic.

Design doc: docs/plans/2026-04-05-caller-generation-redesign.md (committed at fa491c1)


Phase 1 — New caller_gen service (additive)

Task 1: Create CallerIdentity dataclass + JSON schema

Files:

  • Create: backend/services/caller_gen.py
  • Test: tests/test_caller_gen.py

Step 1: Write failing test for dataclass parsing

# tests/test_caller_gen.py
import pytest
from backend.services.caller_gen import CallerIdentity, parse_batch_response

SAMPLE_JSON = """
{
  "callers": [
    {
      "name": "Danny Ortega",
      "age": 47,
      "voice_suggestion": "Marcus",
      "location": "Silver City, NM",
      "identity": "A plumber who inherited his uncle's taxidermy shop...",
      "situation": "He's been getting calls from people...",
      "reason_calling": "Someone left a note in his mailbox tonight...",
      "opening_line": "Luke, I need to ask you something weird.",
      "secret_want": "Permission to just throw it all away",
      "specific_details": ["the elk head in the basement", "the note said 'she forgot'", "his uncle's Rolodex"],
      "emotional_register": "quietly unsettled, trying to sound casual"
    }
  ]
}
"""

def test_parse_batch_response_returns_caller_list():
    callers = parse_batch_response(SAMPLE_JSON)
    assert len(callers) == 1
    assert callers[0].name == "Danny Ortega"
    assert callers[0].age == 47
    assert "taxidermy" in callers[0].identity
    assert len(callers[0].specific_details) == 3

def test_parse_batch_response_rejects_missing_fields():
    bad = '{"callers": [{"name": "Jim"}]}'
    with pytest.raises(ValueError, match="missing"):
        parse_batch_response(bad)

Step 2: Run test, verify it fails Run: /Users/lukemacneil/code/ai-podcast/venv/bin/python -m pytest tests/test_caller_gen.py -v Expected: ImportError / ModuleNotFoundError

Step 3: Implement the dataclass and parser

# backend/services/caller_gen.py
from dataclasses import dataclass, field
from typing import Optional
import json

REQUIRED_FIELDS = {
    "name", "age", "voice_suggestion", "location", "identity",
    "situation", "reason_calling", "opening_line", "secret_want",
    "specific_details", "emotional_register"
}

@dataclass
class CallerIdentity:
    name: str
    age: int
    voice_suggestion: str
    location: str
    identity: str
    situation: str
    reason_calling: str
    opening_line: str
    secret_want: str
    specific_details: list[str]
    emotional_register: str
    # set after voice validation
    voice_resolved: Optional[str] = None

def parse_batch_response(raw: str) -> list[CallerIdentity]:
    data = json.loads(raw)
    callers = data.get("callers", [])
    result = []
    for c in callers:
        missing = REQUIRED_FIELDS - set(c.keys())
        if missing:
            raise ValueError(f"CallerIdentity missing fields: {missing}")
        result.append(CallerIdentity(**{k: c[k] for k in REQUIRED_FIELDS}))
    return result

Step 4: Run test, verify pass Run: /Users/lukemacneil/code/ai-podcast/venv/bin/python -m pytest tests/test_caller_gen.py -v Expected: 2 passed

Step 5: Commit

git add backend/services/caller_gen.py tests/test_caller_gen.py
git commit -m "Add CallerIdentity dataclass and batch JSON parser"

Task 2: Voice roster validator

Files:

  • Modify: backend/services/caller_gen.py
  • Modify: tests/test_caller_gen.py

Step 1: Write failing test

def test_resolve_voice_matches_exact():
    from backend.services.caller_gen import resolve_voice
    roster = ["Marcus", "Dennis", "Priya", "Edward"]
    assert resolve_voice("Marcus", roster) == "Marcus"

def test_resolve_voice_case_insensitive():
    from backend.services.caller_gen import resolve_voice
    roster = ["Marcus", "Dennis"]
    assert resolve_voice("marcus", roster) == "Marcus"

def test_resolve_voice_falls_back_when_no_match():
    from backend.services.caller_gen import resolve_voice
    roster = ["Marcus", "Dennis"]
    # Deterministic fallback: return first from roster
    assert resolve_voice("Santiago", roster) == "Marcus"

def test_resolve_voice_empty_suggestion_falls_back():
    from backend.services.caller_gen import resolve_voice
    assert resolve_voice("", ["Marcus"]) == "Marcus"

Step 2: Run test, verify it fails (ImportError)

Step 3: Implement

# Add to backend/services/caller_gen.py
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])

Step 4: Run test, verify 4 pass

Step 5: Commit

git add backend/services/caller_gen.py tests/test_caller_gen.py
git commit -m "Add voice roster validator for caller_gen"

Task 3: Batch prompt builder

Files:

  • Modify: backend/services/caller_gen.py
  • Modify: tests/test_caller_gen.py

Step 1: Write failing test

def test_build_batch_prompt_includes_context():
    from backend.services.caller_gen import build_batch_prompt
    ctx = {
        "date": "Saturday, April 5, 2026",
        "weather": "cool desert night, 48°F",
        "headlines": ["New Mexico legislature approves water bill"],
        "recent_caller_summaries": ["Jerry called about his neighbor's goat"],
        "regulars_included": [],
        "caller_count": 12,
        "voice_roster": ["Marcus", "Dennis", "Priya"],
    }
    prompt = build_batch_prompt(ctx)
    assert "Saturday, April 5, 2026" in prompt
    assert "water bill" in prompt
    assert "Jerry called about his neighbor's goat" in prompt
    assert "12 callers" in prompt
    assert "Marcus" in prompt  # voice roster listed
    assert "Stern" in prompt
    assert "Coast to Coast" in prompt
    assert "Loveline" in prompt
    assert "Delilah" in prompt
    assert "Opie and Anthony" in prompt

def test_build_batch_prompt_includes_silas_lore_when_present():
    from backend.services.caller_gen import build_batch_prompt
    ctx = {
        "date": "...",
        "weather": "...",
        "headlines": [],
        "recent_caller_summaries": [],
        "regulars_included": [{"name": "Silas", "lore": "Silas leads a small desert cult...", "arc_state": "seeking new members"}],
        "caller_count": 12,
        "voice_roster": ["Marcus"],
    }
    prompt = build_batch_prompt(ctx)
    assert "Silas" in prompt
    assert "desert cult" in prompt
    assert "seeking new members" in prompt
    assert "DO NOT alter his voice, personality, or core traits" in prompt

Step 2: Run test, verify it fails

Step 3: Implement build_batch_prompt

# Add to backend/services/caller_gen.py
BATCH_SYSTEM_PROMPT = """You are writing a roster of callers for Luke's late-night radio show in New Mexico.

CREATIVE RANGE: Your callers must span the emotional range of Howard Stern (chaos, strong characters), Coast to Coast AM (earnest weirdos, sincere believers), Loveline (real problems, real advice-seeking), Delilah (emotional vulnerability, connection), and Opie and Anthony (sharp, irreverent, specific people).

Maximum character distance between callers. No two callers should feel like siblings. Do not default to sitcom plots. Real humans are specific and strange. Give each caller details that could only belong to them.

You will output strict JSON with a "callers" array. Each caller has exactly these fields: name, age, voice_suggestion, location, identity, situation, reason_calling, opening_line, secret_want, specific_details (array of 2-3 strings), emotional_register."""

def build_batch_prompt(ctx: dict) -> str:
    lines = [
        f"Tonight is {ctx['date']}. {ctx['weather']}.",
        "",
        "Today's news headlines (ground callers in real context, but do not force topicality):",
    ]
    for h in ctx["headlines"]:
        lines.append(f"- {h}")
    lines.append("")

    if ctx["recent_caller_summaries"]:
        lines.append("Recent callers (DO NOT repeat these archetypes or situations):")
        for s in ctx["recent_caller_summaries"]:
            lines.append(f"- {s}")
        lines.append("")

    if ctx["regulars_included"]:
        lines.append("RECURRING CHARACTERS IN TONIGHT'S LINEUP:")
        lines.append("")
        for r in ctx["regulars_included"]:
            lines.append(f"### {r['name']}")
            lines.append(r["lore"])
            lines.append(f"Current arc state: {r['arc_state']}")
            lines.append("")
            lines.append(f"For {r['name']}: invent a fresh reason he is calling tonight — a new development, grievance, or specific recent event. DO NOT alter his voice, personality, or core traits. Write a new scene for an existing character.")
            lines.append("")

    lines.append(f"Available voices (voice_suggestion must match one of these exactly):")
    lines.append(", ".join(ctx["voice_roster"]))
    lines.append("")
    lines.append(f"Generate {ctx['caller_count']} callers. Output JSON only, no prose.")
    return BATCH_SYSTEM_PROMPT + "\n\n" + "\n".join(lines)

Step 4: Run test, verify pass

Step 5: Commit

git add backend/services/caller_gen.py tests/test_caller_gen.py
git commit -m "Add batch prompt builder for caller_gen"

Task 4: Batch generation function (LLM call)

Files:

  • Modify: backend/services/caller_gen.py

Step 1: Add generation function

# Add to backend/services/caller_gen.py
import httpx
from ..config import settings
from .cost_tracker import cost_tracker

BATCH_MODEL = "anthropic/claude-sonnet-4.6"

async def generate_batch(ctx: dict) -> list[CallerIdentity]:
    """Call sonnet-4.6 with the batch prompt, parse + voice-resolve the response."""
    prompt = build_batch_prompt(ctx)
    async with httpx.AsyncClient(timeout=120.0) as client:
        resp = await client.post(
            "https://openrouter.ai/api/v1/chat/completions",
            headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
            json={
                "model": BATCH_MODEL,
                "messages": [{"role": "user", "content": prompt}],
                "response_format": {"type": "json_object"},
                "max_tokens": 8000,
                "temperature": 0.9,
            },
        )
        resp.raise_for_status()
        data = resp.json()

    content = data["choices"][0]["message"]["content"]
    usage = data.get("usage", {})
    cost_tracker.record_llm_call(
        category="background_gen",
        model=BATCH_MODEL,
        prompt_tokens=usage.get("prompt_tokens", 0),
        completion_tokens=usage.get("completion_tokens", 0),
        caller_name=None,
    )

    callers = parse_batch_response(content)
    for c in callers:
        c.voice_resolved = resolve_voice(c.voice_suggestion, ctx["voice_roster"])
    return callers

Step 2: Manual smoke test (network call, requires OPENROUTER_API_KEY)

Create scratch/test_batch_gen.py:

import asyncio
from backend.services.caller_gen import generate_batch

async def main():
    ctx = {
        "date": "Saturday, April 5, 2026",
        "weather": "cool desert night, 48°F",
        "headlines": ["NM legislature approves water bill"],
        "recent_caller_summaries": [],
        "regulars_included": [],
        "caller_count": 4,
        "voice_roster": ["Marcus", "Dennis", "Priya", "Edward"],
    }
    callers = await generate_batch(ctx)
    for c in callers:
        print(f"\n=== {c.name} ({c.age}, {c.location}) voice={c.voice_resolved}")
        print(f"identity: {c.identity}")
        print(f"situation: {c.situation}")
        print(f"reason_calling: {c.reason_calling}")
        print(f"opening_line: {c.opening_line}")

asyncio.run(main())

Run: /Users/lukemacneil/code/ai-podcast/venv/bin/python scratch/test_batch_gen.py Expected: 4 distinct, specific caller identities printed. Manually verify they feel distinct + grounded.

Step 3: Commit (keep scratch file out of git)

echo "scratch/" >> .gitignore
git add backend/services/caller_gen.py .gitignore
git commit -m "Add batch generation function using sonnet-4.6"

Phase 2 — Regulars v2

Task 5: Lore file loader (Obsidian markdown)

Files:

  • Create: backend/services/regulars_v2.py
  • Test: tests/test_regulars_v2.py

Step 1: Write failing tests

# tests/test_regulars_v2.py
import tempfile
from pathlib import Path
from backend.services.regulars_v2 import Regular, load_regular, REGULARS_DIR, SILAS_DIR

def test_load_regular_parses_frontmatter_and_body(tmp_path):
    lore_file = tmp_path / "silas.md"
    lore_file.write_text("""---
name: Silas
voice: Dennis
age: 54
arc_state: Cult is splintering after the eclipse failure
---

# Silas

Silas runs a small desert cult outside Truth or Consequences...

## Arc Log

- 2026-03-01: First call, introduced the cult
- 2026-03-20: Prophesied the eclipse
""")
    reg = load_regular(lore_file)
    assert reg.name == "Silas"
    assert reg.voice == "Dennis"
    assert reg.age == 54
    assert "splintering" in reg.arc_state
    assert "Silas runs a small desert cult" in reg.lore_body

Step 2: Run test, verify fail

Step 3: Implement

# backend/services/regulars_v2.py
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import re

HOME = Path.home()
VAULT = HOME / "code" / "dotfiles"
SILAS_DIR = VAULT / "silas"
REGULARS_DIR = VAULT / "regulars"
ARCHIVED_DIR = REGULARS_DIR / "archived"

@dataclass
class Regular:
    name: str
    voice: str
    age: int
    arc_state: str
    lore_body: str
    file_path: Path

def load_regular(path: Path) -> Regular:
    text = path.read_text()
    m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.DOTALL)
    if not m:
        raise ValueError(f"No frontmatter in {path}")
    fm_raw, body = m.group(1), m.group(2).strip()
    fm = {}
    for line in fm_raw.splitlines():
        if ":" in line:
            k, v = line.split(":", 1)
            fm[k.strip()] = v.strip()
    return Regular(
        name=fm["name"],
        voice=fm["voice"],
        age=int(fm["age"]),
        arc_state=fm.get("arc_state", ""),
        lore_body=body,
        file_path=path,
    )

def load_all_active_regulars() -> list[Regular]:
    out = []
    if SILAS_DIR.exists():
        for f in SILAS_DIR.glob("*.md"):
            out.append(load_regular(f))
    if REGULARS_DIR.exists():
        for f in REGULARS_DIR.glob("*.md"):
            out.append(load_regular(f))
    return out

Step 4: Run test, verify pass

Step 5: Commit

git add backend/services/regulars_v2.py tests/test_regulars_v2.py
git commit -m "Add Regular dataclass + lore file loader"

Task 6: Bootstrap Silas lore file in Obsidian vault

Files:

  • Create: ~/code/dotfiles/silas/silas.md

Step 1: Inspect current Silas data Run: /Users/lukemacneil/code/ai-podcast/venv/bin/python -c "import json; d=json.load(open('data/regulars.json')); silas=[r for r in d if r.get('name','').lower()=='silas']; print(json.dumps(silas, indent=2))"

Step 2: Create the lore file by hand-crafting from existing data

File contents (template — adjust based on step 1 findings):

---
name: Silas
voice: [fill from existing data]
age: [fill from existing data]
arc_state: [current ongoing thread]
---

# Silas

[Paragraph describing who Silas is — cult leader, charismatic weirdness, his relationship to Luke, his core essence. Draw from existing regulars.json key_moments + background.]

## Canonical Traits (frozen)

- [trait 1]
- [trait 2]
- [trait 3]

## Arc Log

- YYYY-MM-DD: [event from history]
- YYYY-MM-DD: [event from history]

Step 3: Commit to the Obsidian vault (dotfiles repo)

cd ~/code/dotfiles
git add silas/silas.md
git commit -m "Add Silas canonical lore file for ai-podcast caller generation"

Step 4: Back in caller-redesign worktree, verify loader picks it up

cd /Users/lukemacneil/code/ai-podcast/.worktrees/caller-redesign
/Users/lukemacneil/code/ai-podcast/venv/bin/python -c "from backend.services.regulars_v2 import load_all_active_regulars; print([r.name for r in load_all_active_regulars()])"

Expected: ['Silas']


Task 7: Archive current regulars (except Silas)

Files:

  • Modify: data/regulars.json (filter to Silas only)
  • Create: data/regulars.archived.json (full backup)

Step 1: Write migration script

Create scripts/archive_regulars.py:

import json
from pathlib import Path

src = Path("data/regulars.json")
data = json.loads(src.read_text())

Path("data/regulars.archived.json").write_text(json.dumps(data, indent=2))

silas_only = [r for r in data if r.get("name", "").lower() == "silas"]
src.write_text(json.dumps(silas_only, indent=2))
print(f"Archived {len(data)} regulars. Kept {len(silas_only)} (Silas).")

Step 2: Run migration

cd /Users/lukemacneil/code/ai-podcast/.worktrees/caller-redesign
/Users/lukemacneil/code/ai-podcast/venv/bin/python scripts/archive_regulars.py

Step 3: Commit

git add data/regulars.json data/regulars.archived.json scripts/archive_regulars.py
git commit -m "Archive non-Silas regulars for redesign cutover"

Task 8: Promotion gate (post-call LLM evaluation)

Files:

  • Modify: backend/services/regulars_v2.py
  • Modify: tests/test_regulars_v2.py

Step 1: Write failing test with mocked LLM

# Add to tests/test_regulars_v2.py
import pytest
from unittest.mock import AsyncMock, patch
from backend.services.regulars_v2 import evaluate_promotion

@pytest.mark.asyncio
async def test_evaluate_promotion_returns_arc_plan_when_worthy():
    fake_response = {
        "promote": True,
        "arc_plan": "3 episodes. He'll start distant, then reveal he's actually the one who damaged the car, then resolve with an apology.",
        "reason": "Has clear internal conflict with room to grow",
    }
    with patch("backend.services.regulars_v2._call_sonnet", new=AsyncMock(return_value=fake_response)):
        result = await evaluate_promotion(caller_name="Bobby", call_transcript="...")
    assert result["promote"] is True
    assert "3 episodes" in result["arc_plan"]

@pytest.mark.asyncio
async def test_evaluate_promotion_rejects_when_no_arc():
    fake_response = {"promote": False, "arc_plan": None, "reason": "One-note complaint, no growth"}
    with patch("backend.services.regulars_v2._call_sonnet", new=AsyncMock(return_value=fake_response)):
        result = await evaluate_promotion(caller_name="Carl", call_transcript="...")
    assert result["promote"] is False

Step 2: Run test, verify fail

Step 3: Implement

# Add to backend/services/regulars_v2.py
import httpx
import json
from ..config import settings
from .cost_tracker import cost_tracker

PROMOTION_MODEL = "anthropic/claude-sonnet-4.6"

PROMOTION_PROMPT = """You are evaluating whether a one-time caller should become a recurring character.

CALLER: {name}
TRANSCRIPT:
{transcript}

A recurring character must have a 3-5 episode arc with genuine progression — not just "calls weekly to complain about the same thing." The arc must have a possible resolution.

Output JSON:
{{"promote": true|false, "arc_plan": "...", "reason": "..."}}

Bar is HIGH. Only promote if the character has real internal conflict, growth potential, and a believable resolution trajectory."""

async def _call_sonnet(prompt: str) -> dict:
    async with httpx.AsyncClient(timeout=60.0) as client:
        resp = await client.post(
            "https://openrouter.ai/api/v1/chat/completions",
            headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
            json={
                "model": PROMOTION_MODEL,
                "messages": [{"role": "user", "content": prompt}],
                "response_format": {"type": "json_object"},
                "max_tokens": 500,
            },
        )
        resp.raise_for_status()
        data = resp.json()
    usage = data.get("usage", {})
    cost_tracker.record_llm_call(
        category="promotion_eval",
        model=PROMOTION_MODEL,
        prompt_tokens=usage.get("prompt_tokens", 0),
        completion_tokens=usage.get("completion_tokens", 0),
        caller_name=None,
    )
    return json.loads(data["choices"][0]["message"]["content"])

async def evaluate_promotion(caller_name: str, call_transcript: str) -> dict:
    prompt = PROMOTION_PROMPT.format(name=caller_name, transcript=call_transcript)
    return await _call_sonnet(prompt)

Step 4: Run test, verify pass

Step 5: Commit

git add backend/services/regulars_v2.py tests/test_regulars_v2.py
git commit -m "Add promotion gate for tier-2 regulars"

Task 9: Create promoted-regular writer

Files:

  • Modify: backend/services/regulars_v2.py
  • Modify: tests/test_regulars_v2.py

Step 1: Write failing test

def test_write_new_regular_creates_lore_file(tmp_path, monkeypatch):
    monkeypatch.setattr("backend.services.regulars_v2.REGULARS_DIR", tmp_path)
    from backend.services.regulars_v2 import write_new_regular

    write_new_regular(
        name="Bobby",
        voice="Marcus",
        age=34,
        identity_paragraph="A landscaper in Las Cruces who...",
        arc_plan="3 episodes: distant → reveal → apology",
        first_call_summary="Called about damaged car",
    )
    f = tmp_path / "bobby.md"
    assert f.exists()
    body = f.read_text()
    assert "name: Bobby" in body
    assert "voice: Marcus" in body
    assert "A landscaper in Las Cruces" in body
    assert "3 episodes: distant → reveal → apology" in body

Step 2: Run test, verify fail

Step 3: Implement

# Add to backend/services/regulars_v2.py
from datetime import date

def write_new_regular(name: str, voice: str, age: int, identity_paragraph: str,
                       arc_plan: str, first_call_summary: str) -> Path:
    REGULARS_DIR.mkdir(parents=True, exist_ok=True)
    slug = name.lower().replace(" ", "-")
    path = REGULARS_DIR / f"{slug}.md"
    today = date.today().isoformat()
    content = f"""---
name: {name}
voice: {voice}
age: {age}
arc_state: {arc_plan}
promoted_on: {today}
---

# {name}

{identity_paragraph}

## Arc Plan

{arc_plan}

## Arc Log

- {today}: {first_call_summary}
"""
    path.write_text(content)
    return path

Step 4: Run test, verify pass

Step 5: Commit

git add backend/services/regulars_v2.py tests/test_regulars_v2.py
git commit -m "Add writer for promoted tier-2 regulars"

Phase 3 — Integration into main.py

Task 10: Slim caller prompt builder

Files:

  • Modify: backend/main.py (add new get_caller_prompt_slim function alongside existing)
  • Create: tests/test_caller_prompt_slim.py

Step 1: Write failing test

# tests/test_caller_prompt_slim.py
from backend.main import get_caller_prompt_slim

def test_slim_prompt_includes_identity_and_situation():
    caller = {
        "name": "Danny",
        "identity": "A plumber who inherited a taxidermy shop",
        "situation": "Getting strange calls about taxidermy",
        "reason_calling": "Someone left a note",
        "secret_want": "Permission to throw it all away",
        "specific_details": ["elk head in basement", "note said she forgot"],
    }
    prompt = get_caller_prompt_slim(caller)
    assert "Danny" in prompt
    assert "taxidermy shop" in prompt
    assert "strange calls" in prompt
    assert "elk head" in prompt
    assert "she forgot" in prompt
    assert "Permission to throw it all away" in prompt
    assert "React to what Luke says" in prompt
    assert "Stay in character" in prompt
    # Assert it's under 800 tokens (roughly 3200 chars) — should be ~400 tokens
    assert len(prompt) < 3200

Step 2: Run test, verify fail

Step 3: Implement in backend/main.py

Add near existing get_caller_prompt (around line 6556):

def get_caller_prompt_slim(caller: dict) -> str:
    """Slim 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", "")
    details = caller.get("specific_details", []) or []
    detail_str = " | ".join(f"- {d}" for d in details)

    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}

Specific details you'll drop if it feels natural:
{detail_str}

Speak as this person. React to what Luke says. Stay in character.
Don't narrate. No stage directions. Just talk.
Keep responses natural — 1-3 sentences most of the time. Real callers don't monologue."""

Step 4: Run test, verify pass

Step 5: Commit

git add backend/main.py tests/test_caller_prompt_slim.py
git commit -m "Add slim caller prompt builder (~400 tokens)"

Task 11: Switch dialog model to haiku-4.5 (behind flag)

Files:

  • Modify: backend/main.py around Session.get_caller_model (~line 6894)

Step 1: Read current implementation Read backend/main.py:6800-6900 to find Session.get_caller_model and the caller_model_map.

Step 2: Add feature flag check

Modify Session.get_caller_model to accept a flag, returning haiku when enabled:

# Add class-level attribute on Session:
use_slim_caller_path: bool = False  # flipped by env var or settings

# Modify get_caller_model:
def get_caller_model(self, caller_key: str) -> str:
    if self.use_slim_caller_path:
        return "anthropic/claude-haiku-4.5"
    # ... existing style-matched logic unchanged ...

Step 3: Add env var reader in Session.init

import os
self.use_slim_caller_path = os.environ.get("CALLER_REDESIGN", "0") == "1"

Step 4: Commit

git add backend/main.py
git commit -m "Add feature flag for slim caller path (haiku-4.5)"

Task 12: Wire batch gen into Session.reset (behind flag)

Files:

  • Modify: backend/main.py (Session.reset + caller setup)

Step 1: Read current _pregenerate_backgrounds at backend/main.py:5908.

Step 2: Add new batch path

Create an async method on Session (new code, not replacing):

async def _pregenerate_backgrounds_slim(self):
    """New path: single sonnet-4.6 batch call generates all caller identities."""
    from .services import caller_gen, regulars_v2
    from datetime import datetime

    voice_roster = [name for name in INWORLD_MALE + INWORLD_FEMALE
                    if name not in BLACKLISTED_VOICES]

    active_regulars = regulars_v2.load_all_active_regulars()
    # Include Silas always if he exists; tier-2 based on arc state (implement later)
    regulars_for_tonight = [
        {"name": r.name, "lore": r.lore_body, "arc_state": r.arc_state}
        for r in active_regulars
    ][:3]  # cap at 3 regulars max

    ctx = {
        "date": datetime.now().strftime("%A, %B %d, %Y"),
        "weather": "cool desert night",  # TODO: real weather feed
        "headlines": self.news_headlines[:5] if self.news_headlines else [],
        "recent_caller_summaries": self._get_recent_summaries(),
        "regulars_included": regulars_for_tonight,
        "caller_count": 12,
        "voice_roster": voice_roster,
    }

    identities = await caller_gen.generate_batch(ctx)

    # Map identities into CALLER_BASES slots
    for i, identity in enumerate(identities[:10]):
        key = str(i + 1)
        self.caller_backgrounds[key] = {
            "name": identity.name,
            "age": identity.age,
            "voice": identity.voice_resolved,
            "location": identity.location,
            "identity": identity.identity,
            "situation": identity.situation,
            "reason_calling": identity.reason_calling,
            "opening_line": identity.opening_line,
            "secret_want": identity.secret_want,
            "specific_details": identity.specific_details,
            "emotional_register": identity.emotional_register,
        }

def _get_recent_summaries(self) -> list[str]:
    # Return last 2 shows' caller summaries — stub for now, can wire into cost_db
    return []

Step 3: Call it from Session.reset when flag is on

Find _pregenerate_backgrounds call in Session.reset, replace with:

if self.use_slim_caller_path:
    asyncio.create_task(self._pregenerate_backgrounds_slim())
else:
    asyncio.create_task(self._pregenerate_backgrounds())

Step 4: Route caller prompt to slim builder when flag is on

In the caller-prompt assembly code (wherever get_caller_prompt is called), add:

if session.use_slim_caller_path:
    system_prompt = get_caller_prompt_slim(caller)
else:
    system_prompt = get_caller_prompt(caller, ...)

Step 5: Smoke test — start server with flag on

cd /Users/lukemacneil/code/ai-podcast/.worktrees/caller-redesign
CALLER_REDESIGN=1 /Users/lukemacneil/code/ai-podcast/venv/bin/python -m uvicorn backend.main:app --reload-dir backend --host 0.0.0.0 --port 8000

In browser, click "Reset session" or similar, verify logs show batch gen happening.

Step 6: Commit

git add backend/main.py
git commit -m "Wire batch gen into Session.reset behind CALLER_REDESIGN flag"

Phase 4 — Validation Gate

Task 13: Sample call generation script

Files:

  • Create: scripts/generate_sample_calls.py

Step 1: Implement

# scripts/generate_sample_calls.py
"""Generate 10 sample caller dialogues for user validation before cutover.

5 with Silas (if lore exists), 5 walk-ins. Writes transcripts to docs/samples/.
"""
import asyncio
import json
from pathlib import Path
from datetime import datetime
import httpx

from backend.services import caller_gen, regulars_v2
from backend.services.tts import INWORLD_MALE, INWORLD_FEMALE
from backend.main import get_caller_prompt_slim
from backend.config import settings

DIALOG_MODEL = "anthropic/claude-haiku-4.5"
HOST_PROMPTS = [
    "Hey, what's going on tonight?",
    "So what's the story?",
    "Tell me more about that.",
    "Wait — really? When did that happen?",
    "Okay, and what did you do?",
]

async def dialog_turn(system_prompt: str, conversation: list) -> str:
    async with httpx.AsyncClient(timeout=60.0) as client:
        resp = await client.post(
            "https://openrouter.ai/api/v1/chat/completions",
            headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
            json={
                "model": DIALOG_MODEL,
                "messages": [{"role": "system", "content": system_prompt}] + conversation,
                "max_tokens": 300,
                "temperature": 0.9,
            },
        )
        resp.raise_for_status()
        return resp.json()["choices"][0]["message"]["content"]

async def main():
    out_dir = Path("docs/samples")
    out_dir.mkdir(parents=True, exist_ok=True)

    voice_roster = [n for n in INWORLD_MALE + INWORLD_FEMALE]

    # Batch 1: with Silas
    regulars = regulars_v2.load_all_active_regulars()
    if regulars:
        ctx_with_silas = {
            "date": datetime.now().strftime("%A, %B %d, %Y"),
            "weather": "cool desert night",
            "headlines": [],
            "recent_caller_summaries": [],
            "regulars_included": [{"name": r.name, "lore": r.lore_body, "arc_state": r.arc_state} for r in regulars],
            "caller_count": 5,
            "voice_roster": voice_roster,
        }
        silas_batch = await caller_gen.generate_batch(ctx_with_silas)
    else:
        silas_batch = []

    # Batch 2: walk-ins
    ctx_walkins = {
        "date": datetime.now().strftime("%A, %B %d, %Y"),
        "weather": "cool desert night",
        "headlines": [],
        "recent_caller_summaries": [],
        "regulars_included": [],
        "caller_count": 5,
        "voice_roster": voice_roster,
    }
    walkin_batch = await caller_gen.generate_batch(ctx_walkins)

    all_callers = silas_batch + walkin_batch
    for caller in all_callers:
        cdict = {
            "name": caller.name,
            "identity": caller.identity,
            "situation": caller.situation,
            "reason_calling": caller.reason_calling,
            "secret_want": caller.secret_want,
            "specific_details": caller.specific_details,
        }
        system_prompt = get_caller_prompt_slim(cdict)
        conversation = [{"role": "assistant", "content": caller.opening_line}]

        for host_line in HOST_PROMPTS:
            conversation.append({"role": "user", "content": host_line})
            reply = await dialog_turn(system_prompt, conversation)
            conversation.append({"role": "assistant", "content": reply})

        transcript = [f"CALLER ({caller.name}, {caller.age}, {caller.location}): {conversation[0]['content']}"]
        for i in range(1, len(conversation), 2):
            transcript.append(f"LUKE: {conversation[i]['content']}")
            if i + 1 < len(conversation):
                transcript.append(f"CALLER: {conversation[i+1]['content']}")

        fname = out_dir / f"sample_{caller.name.replace(' ', '_').lower()}.txt"
        fname.write_text(
            f"=== {caller.name} ({caller.age}, {caller.location}) ===\n"
            f"voice: {caller.voice_resolved}\n"
            f"emotional_register: {caller.emotional_register}\n"
            f"secret_want: {caller.secret_want}\n\n"
            + "\n".join(transcript)
        )
        print(f"Wrote {fname}")

asyncio.run(main())

Step 2: Run it

cd /Users/lukemacneil/code/ai-podcast/.worktrees/caller-redesign
/Users/lukemacneil/code/ai-podcast/venv/bin/python scripts/generate_sample_calls.py

Expected: 10 files in docs/samples/.

Step 3: Commit

git add scripts/generate_sample_calls.py docs/samples/
git commit -m "Add sample call generator for validation gate"

Task 14: USER VALIDATION CHECKPOINT 🛑

STOP HERE. User must:

  1. Read all 10 sample transcripts in docs/samples/
  2. Verify Silas (if present) still sounds like Silas — voice, personality, essence intact
  3. Verify walk-ins span the emotional range (some earnest, some chaotic, some vulnerable, some absurd)
  4. Verify callers feel distinct, not sibling-like
  5. Verify dialog responses are terse, specific, and reactive to the host

Decision:

  • Approve → proceed to Phase 5 (deletion)
  • Drift/bland → iterate prompts in caller_gen.py BATCH_SYSTEM_PROMPT + get_caller_prompt_slim, regenerate, re-validate

Phase 5 — Deletion (only after user approval)

Task 15: Delete static content pools

Files:

  • Modify: backend/main.py

Step 1: Remove these constants (grep to find their definitions):

  • PROBLEMS, STORIES, GOSSIP, ADVICE, TOPIC_CALLIN, CELEBRATIONS, WEIRD, HOT_TAKES

Step 2: Remove functions that use them:

  • _generate_pool_weights
  • _pick_unique_reason

Step 3: Remove references (grep for each pool name, delete lines that use them).

Step 4: Run tests

/Users/lukemacneil/code/ai-podcast/venv/bin/python -m pytest tests/ -v

Fix any test failures caused by removals.

Step 5: Commit

git add backend/main.py
git commit -m "Delete static content pools (PROBLEMS, STORIES, etc.)"

Task 16: Delete color-detail pools

Files:

  • Modify: backend/main.py

Step 1: Remove these constants: INTERESTS, QUIRKS, RELATIONSHIP_STATUS, VEHICLES, BEFORE_CALLING, CALLING_FROM, MEMORIES, HAVING_RIGHT_NOW, STRONG_OPINIONS, CONTRADICTIONS, VERBAL_TICS, EMOTIONAL_ARCS, SHOW_RELATIONSHIP, LATE_NIGHT_REASONS, DRIFT_TENDENCIES, ROAD_CONTEXT, PHONE_SITUATION, BACKGROUND_MUSIC, RECENT_ERRAND, TV_TONIGHT, LOCAL_FOOD_OPINIONS, NOSTALGIA

Step 2: Remove keyword filter lists: _SPICY_KEYWORDS, _ABSURD_KEYWORDS, _HEAVY_POOLS, _LIGHT_POOLS, _HEAVY_STYLES, _LIGHT_STYLES, _EVASIVE_STYLES

Step 3: Run tests, fix breakages, commit

git add backend/main.py
git commit -m "Delete color-detail pools and keyword filters"

Task 17: Delete style system

Files:

  • Modify: backend/main.py

Remove:

  • CALLER_STYLES (18-style dict)
  • CALLER_STYLE_KEYS
  • STYLE_VOICE_PREFERENCES
  • STYLE_SPEED_MODIFIERS
  • STYLE_PHONE_QUALITY
  • _pick_caller_style function
  • caller_model_map, _CALLER_DIALOG_MODEL_PARAMS
  • style-matching branches in Session.get_caller_model (should return haiku unconditionally now)

Commit:

git add backend/main.py
git commit -m "Delete style system (CALLER_STYLES, style-to-model map)"

Task 18: Delete shape system

Files:

  • Modify: backend/main.py

Remove:

  • CALL_SHAPES
  • SHAPE_STYLE_AFFINITIES
  • SHAPE_DIRECTIVES (big dict)
  • _LATE_SHOW_SHAPES
  • _pick_call_shape
  • _assign_call_shape
  • All shape-related fields from CallerBackground and downstream uses

Commit:

git add backend/main.py
git commit -m "Delete call-shape system"

Task 19: Delete voice matching and queue sort

Files:

  • Modify: backend/main.py

Remove:

  • _match_voices_to_styles function
  • Voice-scoring logic (uses VOICE_PROFILES dimensions)
  • _sort_caller_queue (greedy placement scoring)
  • SHOW_HISTORY_REACTIONS + adaptive reaction frequency
  • _build_relationship_context (inter-caller thematic scoring)

Commit:

git add backend/main.py
git commit -m "Delete voice matching, queue sort, thematic scoring"

Task 20: Delete template fallback + remove flag

Files:

  • Modify: backend/main.py

Remove:

  • generate_caller_background (template-based fallback, ~400 lines)
  • _pregenerate_backgrounds (old path — keep only _pregenerate_backgrounds_slim and rename it to drop _slim)
  • Old get_caller_prompt (keep get_caller_prompt_slim, rename to get_caller_prompt)
  • CALLER_REDESIGN env var + use_slim_caller_path flag (always-on now)

Commit:

git add backend/main.py
git commit -m "Remove template fallback, old generation path, feature flag"

Task 21: Final test sweep + reload documentation

Step 1: Run all tests

/Users/lukemacneil/code/ai-podcast/venv/bin/python -m pytest tests/ -v

Expected: All pass.

Step 2: Line count diff

git diff main --stat backend/main.py

Expected: ~2000 lines removed, ~300 added.

Step 3: Update CLAUDE.md

Update the "Caller Generation System" section in CLAUDE.md to describe the new architecture (replace old description of CallerBackground dataclass, voice-personality matching, SHAPE_STYLE_AFFINITIES, etc.).

Step 4: Commit

git add CLAUDE.md
git commit -m "Update CLAUDE.md to document new caller generation architecture"

Task 22: Ship decision

Stop here. Merge strategy is user's call:

  • PR on GitHub via /push flow
  • Direct merge to main
  • More test shows on the feature branch first

Post-Ship Work (out of scope for this plan)

  • Real weather feed integration (currently "cool desert night" hardcoded)
  • Recent-shows summary feed from data/costs.db for anti-repeat context
  • Arc retirement automation (detect resolved arcs, move to regulars/archived/)
  • Post-call promotion trigger wired into hangup flow
  • Pre-generated opening-line audio (cut latency when caller clicked)