Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **New "Quiet Green" design foundation** — the first of three PRs applying the visual identity co-designed with the community in Discussion #1202. The design token system lands in `globals.css` (core palette, neutral reading surfaces, ink ramp, hairline borders, content-type hues, evidence/citation classes, context-state colors, a squared 4–6px radius scale and a near-flat shadow scale where popovers own the only real shadow); fonts move from Inter to Bricolage Grotesque (display) + Instrument Sans (UI) + Spline Sans Mono (data) via `next/font`; the shadcn primitives are restyled to the foundation's rules (fern green primary actions, red reserved exclusively for destructive/error, clay for warnings, teal as the AI/system voice, underline tabs, quiet neutral badges, teal focus ring); and hard-coded palette colors across 38 components were swept into the semantic tokens, which re-resolve automatically per theme. A dev-only living styleguide at `/dev/design` (404 in production) renders every token and primitive in both themes and serves as the review reference for the follow-up screen-by-screen PRs. Purely visual — no behavior, navigation or i18n changes
- **Screen-by-screen reskin in the "Quiet Green" language** (the second redesign PR): the app shell gets the recomposed tri-hue pebble wordmark (fern/gold/teal — no red left), a fern spine on the active nav item and destination-hued nav icons; the notebook workspace gets display-type titles, panel headers with identity ticks (sources sage / notes gold / chat teal), one-line source-card metadata with the overflow menu at top-right, and de-washed chat bubbles (the AI speaks in teal accents, never washed backgrounds); the notebooks home separates compact recently-viewed rows from active-notebook cards; the sources table gets the library treatment (content-type pebbles, quiet embedded pills, hover that raises the surface); dialogs and the source viewer are flattened (no card-in-card boxes, uniform breathing room, no ⋮/close collision); and Models/Settings/Podcasts/Transformations/Ask-Search get the badge diet, quiet audio-player containers, mono for data and teal AI accents. Still purely visual — no behavior, columns, navigation or i18n changes

### Fixed
- **Podcast episode profiles honor their `language` again.** The app's `prompts/podcast/{outline,transcript}.jinja` shadow podcast-creator's bundled templates (the library resolves `Path.cwd()/prompts/podcast/` before its own resources) and had lost the `{{ language }}` block, so a profile set to `he-IL` produced an English outline and English segment titles — the field looked supported and did nothing. A regression test now fails when a variable the bundled template uses is missing from the app's copy (#1238)
- **A copied prompt placeholder no longer aborts a podcast mid-generation.** Both podcast templates showed the model a fill-in JSON skeleton (`"speaker": "[Actual Speaker Name]"`, a bare `...`) inside a ```json fence while also instructing it not to use fences; models returned the skeleton verbatim, which failed podcast-creator's speaker-name validation and discarded the outline and every segment already generated. The examples are now rendered from the episode's real speaker names (so a verbatim copy is valid output), placeholders and truncation are banned explicitly, and the contradictory fences and second copyable example are gone (#1238)
- **A speaker voice that the TTS model can't use now fails immediately instead of after the whole transcript.** Audio is generated last, so the profiles seeded on install — which carry OpenAI voice names (`nova`, `echo`, `shimmer`, …) — died on a Gemini voice model with `Google API error: Requested entity was not found.`, which reads like a missing model, once the full transcript had already been generated and paid for. Generation now pre-flights each speaker's voice (including per-speaker `voice_model` overrides) against the provider's voice catalogue and names the offending voice and the valid ones. Only voices that demonstrably belong to another provider fail the run; unknown voices are logged and allowed through, since provider catalogues can be out of date (#1238)

### Changed
- Community contribution intake now separates exploration from execution: feature requests, product/design/architecture ideas and contribution proposals start in GitHub Discussions, while Issues are reserved for reproducible bugs and maintainer-approved work items. The Issue chooser routes contributors accordingly, a structured Ideas Discussion form starts from user goals and outcomes, and the contributor/maintainer docs plus PR template now describe the Discussion → Issue → PR graduation path (#1204).
- Podcast generation failures now carry a hint that matches the failure: a placeholder/renamed speaker name, a `voice_id` its TTS model doesn't support, and a response that was truncated rather than swallowed by `<think>` tags each get their own explanation. The GPT-5 extended-thinking note used to be the only hint, so the two most common real failures got either nothing or advice about the wrong provider (#1238)
- Release image gate gained a `probe` scenario (`make release-test` runs it as part of `all`): container-level checks that a Python test suite can't cover because they depend on the shipped image's process supervision — `OPEN_NOTEBOOK_WORKER_MAX_TASKS` reaching the in-image worker (the supervisord `sh -c` expansion), and the worker surviving startup with `HTTP_PROXY` set while a user's `NO_PROXY` value is preserved (the internal SurrealDB websocket not being tunneled). Both were manual probes during the v1.14.0 release; they now run automatically. Release-process docs gained the post-tag re-cut sequence and a note on never leaving the version bump uncommitted (v1.14.0 retro)

## [1.14.0] - 2026-07-20
Expand Down
59 changes: 53 additions & 6 deletions commands/podcast_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,49 @@ def build_episode_output_dir(podcasts_folder: str = PODCASTS_FOLDER) -> tuple[st
return episode_dir_name, output_dir


def explain_generation_failure(error_msg: str) -> Optional[str]:
"""Map a podcast-generation failure to an actionable hint, or None.

Ordered most specific first. The GPT-5 extended-thinking hint used to be
the only one, so the two most common real failures got either nothing
(`Invalid speaker name`) or advice about the wrong provider - a truncated
Gemini response was told to switch to gpt-4o (#1238).
"""
if "Invalid speaker name" in error_msg:
return (
"The transcript model returned a speaker name that is not in the "
"speaker profile - usually a placeholder copied from the prompt "
'such as "..." rather than an invented person. Speaker names must '
"match the profile exactly. Retrying the episode often succeeds, "
"since each attempt is a fresh sample."
)

if "Requested entity was not found" in error_msg or (
"Voice name" in error_msg and "not supported" in error_msg
):
return (
"The speaker profile's voice_id is not valid for its TTS model - "
"Google returns 'Requested entity was not found' for an unknown "
"voice, which reads like a missing model. Check the voices in "
"Settings -> Speaker Profiles against the ones your voice model "
"provides (the profiles seeded on install use OpenAI voice names)."
)

if "Invalid json output" in error_msg or "Expecting value" in error_msg:
return (
"The model's response could not be parsed as JSON. Two common "
"causes: (1) the response was truncated - podcast-creator caps a "
"transcript segment at 5000 output tokens unless the episode "
"profile sets max_tokens, which is tight for long segments or "
"token-expensive languages, so raise max_tokens or use fewer and "
"shorter segments; (2) a model using extended thinking (e.g. "
"GPT-5) put all of its output inside <think> tags, leaving nothing "
"to parse - try gpt-4o, gpt-4o-mini or gpt-4-turbo instead."
)

return None


class PodcastGenerationInput(CommandInput):
episode_profile: str
# Speaker profile record ID or name (the API boundary resolves the
Expand Down Expand Up @@ -142,6 +185,13 @@ async def generate_podcast_command(
f"tts: {tts_provider}/{tts_model_name}"
)

# Pre-flight the speaker voices against the resolved TTS model. Audio
# is generated last, so an unusable voice_id (migration 7 seeds OpenAI
# voice names) otherwise fails only after the full transcript has been
# generated and paid for, with a provider message that names the wrong
# cause: #1238.
await speaker_profile.validate_voices()

# 4. Load all profiles and configure podcast-creator
episode_profiles = await repo_query("SELECT * FROM episode_profile")
speaker_profiles = await repo_query("SELECT * FROM speaker_profile")
Expand Down Expand Up @@ -365,11 +415,8 @@ async def generate_podcast_command(
logger.exception(e)

error_msg = str(e)
if "Invalid json output" in error_msg or "Expecting value" in error_msg:
error_msg += (
"\n\nNOTE: This error commonly occurs with GPT-5 models that use extended thinking. "
"The model may be putting all output inside <think> tags, leaving nothing to parse. "
"Try using gpt-4o, gpt-4o-mini, or gpt-4-turbo instead in your episode profile."
)
hint = explain_generation_failure(error_msg)
if hint:
error_msg += f"\n\nNOTE: {hint}"

raise RuntimeError(error_msg) from e
16 changes: 16 additions & 0 deletions docs/7-DEVELOPMENT/podcasts.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ The legacy string fields (`tts_provider`, `outline_provider`, …) that predated

`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.

## Prompt templates shadow podcast-creator's

`prompts/podcast/{outline,transcript}.jinja` are **not** just this app's copies of the library's prompts — they replace them. podcast-creator resolves templates as inline config → `prompts_dir` config → `Path.cwd()/prompts/podcast/<name>.jinja` → its own package resources, and this app configures only profiles, so the working directory wins and the bundled prompts are never read.

Consequences to keep in mind when touching these files:

- Library prompt improvements are invisible here. The `{{ language }}` block was lost exactly this way, which made `EpisodeProfile.language` silently do nothing (#1238). `tests/test_podcast_prompt_templates.py` fails when a variable the bundled template uses is missing from the app's copy.
- The variables available are whatever `podcast_creator.nodes` passes: the transcript template gets `speaker_names`, the outline template does **not**.
- Never show the model a fill-in skeleton it can return verbatim. Examples are rendered from the real speaker names so a copied example is still valid output, and placeholders (`...`, `[like this]`) are banned explicitly — a copied `"speaker": "..."` used to abort the whole episode on podcast-creator's speaker-name validation, discarding the segments already generated.

## Voice pre-flight

Audio is generated last, so a `voice_id` the TTS model doesn't accept fails only after the full transcript has been generated and paid for — and the provider message rarely names the voice (Gemini's 3.x TTS preview answers an unknown voice with `404 Requested entity was not found.`). `SpeakerProfile.validate_voices()` runs before generation and checks each speaker's voice (honoring per-speaker `voice_model` overrides) against esperanto's `available_voices` for the resolved model.

It fails the run **only** for a voice another provider's catalogue claims — the case of the migration-7 profiles, seeded with OpenAI voices (`nova`, `echo`, `shimmer`, …), against a Gemini voice model. A voice no catalogue knows is logged and allowed through, because those catalogues go stale (esperanto's OpenAI list predates `ash`), and an unavailable catalogue never blocks generation.

## Job lifecycle and the retry policy

Generation runs as a `generate_podcast_command` job on the surreal-commands worker:
Expand Down
58 changes: 58 additions & 0 deletions open_notebook/podcasts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,64 @@ async def resolve_tts_config(self) -> Tuple[str, str, dict]:
)
return await _resolve_model_config(self.voice_model)

async def validate_voices(self) -> None:
"""Reject voice_ids that plainly belong to another TTS provider.

validate_speakers() can only check that a `voice_id` key exists - it
never sees the model, and nothing else checks either. So a profile
seeded with OpenAI voices (migration 7) against a Gemini voice_model
fails only once the first audio clip is requested - after the whole
transcript has been generated and paid for - with a provider message
that names the wrong cause (#1238).

Only certain mismatches raise: a voice missing from the model's
catalogue but present in another provider's. A voice no catalogue
knows about is logged and allowed through, because esperanto's
hard-coded lists go stale (see open_notebook.podcasts.voices). Raises
ValueError so the podcast command treats it as permanent (no retry).
"""
from open_notebook.podcasts.voices import find_voice_mismatch, format_voice_list

profile_tts: Optional[Tuple[str, str, dict]] = None
for speaker in self.speakers:
override = speaker.get("voice_model")
if override:
provider, model_name, config = await _resolve_model_config(
str(override)
)
else:
if profile_tts is None:
profile_tts = await self.resolve_tts_config()
provider, model_name, config = profile_tts

voice_id = str(speaker.get("voice_id") or "")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
mismatch = await find_voice_mismatch(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
provider, model_name, config, voice_id
)
if mismatch is None:
continue

valid_voices = format_voice_list(mismatch.known_voices)
if not mismatch.confident:
logger.warning(
f"Speaker '{speaker.get('name')}' in speaker profile "
f"'{self.name}' uses voice '{voice_id}', which is not in "
f"{provider}/{model_name}'s known voice list "
f"({valid_voices}). Generating anyway - the list may be "
"out of date - but audio generation will fail if the "
"provider rejects it."
)
continue

raise ValueError(
f"Speaker '{speaker.get('name')}' in speaker profile "
f"'{self.name}' uses voice '{voice_id}', which belongs to "
f"{', '.join(sorted(mismatch.other_providers))} and is not "
f"supported by {provider}/{model_name}. Valid voices: "
f"{valid_voices}. Update the voice in Settings -> Speaker "
"Profiles, or pick a voice model that provides this voice."
)

@classmethod
async def get_by_name(cls, name: str) -> Optional["SpeakerProfile"]:
"""Get speaker profile by name"""
Expand Down
Loading