docs/7-DEVELOPMENT/podcasts.md
How podcast generation is modeled and executed: the two-tier profile system, the model-registry references, and the deliberate no-auto-retry policy.
open_notebook/podcasts/models.py)voice_model (record<model> reference for TTS) plus 1–4 speakers (name, voice_id, backstory, personality). Individual speakers can override the profile's voice_model.outline_llm / transcript_llm (record<model> references), language (BCP 47, e.g. pt-BR), segment count (3–20), briefing template. It references a SpeakerProfile by name.command field → surreal-commands RecordID).Profile fields reference Model records instead of raw provider/model strings. At generation time _resolve_model_config(model_id) loads the Model, resolves its linked credential (or falls back to provision_provider_keys()), and returns (provider, model_name, config) for podcast-creator. Legacy string fields (tts_provider, outline_provider, …) still exist as nullable columns for migration compatibility but are ignored at runtime.
migration.py converts legacy profiles on API startup (after SQL migrations): idempotent, skips already-migrated profiles, and auto-creates a Model record when a legacy string has no match but a credential exists for that provider.
PodcastEpisode stores episode_profile and speaker_profile as dicts (snapshots), not references. Editing a profile never retroactively changes past episodes — that's intentional. Corollary: deleting a profile does not cascade to episodes.
Generation runs as a generate_podcast_command job on the surreal-commands worker:
outline_llm, transcript_llm and voice_model are set.max_attempts: 1 — no automatic retries. A mid-generation retry would create duplicate episode records (records are created during execution). Failed episodes are marked failed with an error message; retry is explicitly user-initiated via POST /podcasts/episodes/{id}/retry.get_job_status() / get_job_detail() query surreal-commands and return "unknown" on failure rather than raising. Listing endpoints use the batched get_job_details_for_commands() so N episodes cost one status query, not N.