feat(podcasts): model registry integration, credential passthrough & new features (#632)

* feat(podcasts): integrate model registry for profiles and credential passthrough

Replace loose provider/model string fields with record<model> references
in podcast profiles, enabling credential passthrough to podcast-creator.

Backend:
- EpisodeProfile: outline_llm, transcript_llm (record<model>) replace
  outline_provider/outline_model strings. New language field (BCP 47).
- SpeakerProfile: voice_model (record<model>) replaces tts_provider/
  tts_model strings. Per-speaker voice_model override support.
- Migration 14: schema changes making legacy fields optional, adding new
  record<model> fields.
- Data migration (migration.py): auto-converts legacy profiles to model
  registry references on startup. Idempotent.
- podcast_commands.py: resolves credentials for ALL profiles before
  calling podcast-creator.
- New /api/languages endpoint (pycountry + babel) with BCP 47 locale
  codes (pt-BR, en-US, etc.).

Frontend:
- Episode/speaker profile forms use ModelSelector instead of manual
  provider/model dropdowns.
- Language dropdown with BCP 47 codes in episode profile form.
- Per-speaker TTS voice model override in speaker profile form.
- "Templates" tab renamed to "Profiles".
- Setup required badge on unconfigured profiles.
- i18n updated across all 8 locales.

Closes #486, closes #552

* fix(i18n): remove unused legacy podcast provider/model keys

Remove 10 orphaned i18n keys across all 8 locales that were left behind
after replacing manual provider/model dropdowns with ModelSelector.

* fix: address review violations in podcast model registry

- P1: Remove profiles with failed model resolution from dicts to prevent
  podcast-creator validation errors on unrelated profiles
- P2: Use centralized QUERY_KEYS.languages instead of inline key
- P3: Fix ISO 639-1 → BCP 47 in model field description and CLAUDE.md
- P3: Update "templates" → "profiles" in locale string values (all 8)

* chore: bump version to 1.8.0
This commit is contained in:
Luis Novo 2026-02-27 11:06:47 -03:00 committed by GitHub
parent ce64a5f20c
commit eac837d555
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1246 additions and 714 deletions

View file

@ -218,4 +218,4 @@ See dedicated CLAUDE.md files for detailed guidance:
---
**Last Updated**: February 2026 | **Project Version**: 1.7.4
**Last Updated**: February 2026 | **Project Version**: 1.8.0

View file

@ -17,6 +17,7 @@ FastAPI application serving three architectural layers: routes (HTTP endpoints),
- Load .env environment variables
- Initialize CORS middleware + password auth middleware
- Run database migrations via AsyncMigrationManager on lifespan startup
- Run podcast profile data migration (legacy string to model registry conversion)
- Register all routers
**Key services**:
@ -62,6 +63,7 @@ FastAPI application serving three architectural layers: routes (HTTP endpoints),
- **routers/transformations.py**: POST /transformations
- **routers/insights.py**: GET /sources/{source_id}/insights
- **routers/auth.py**: POST /auth/password (password-based auth)
- **routers/languages.py**: GET /languages (available podcast languages via pycountry+babel)
- **routers/commands.py**: GET /commands/{command_id} (job status tracking)
## Common Patterns

View file

@ -32,6 +32,7 @@ from api.routers import (
embedding_rebuild,
episode_profiles,
insights,
languages,
models,
notebooks,
notes,
@ -97,6 +98,15 @@ async def lifespan(app: FastAPI):
# Fail fast - don't start the API with an outdated database schema
raise RuntimeError(f"Failed to run database migrations: {str(e)}") from e
# Run podcast profile data migration (legacy strings -> Model registry)
try:
from open_notebook.podcasts.migration import migrate_podcast_profiles
await migrate_podcast_profiles()
except Exception as e:
logger.warning(f"Podcast profile migration encountered errors: {e}")
# Non-fatal: profiles can be migrated manually via UI
logger.success("API initialization completed successfully")
# Yield control to the application
@ -269,6 +279,7 @@ app.include_router(speaker_profiles.router, prefix="/api", tags=["speaker-profil
app.include_router(chat.router, prefix="/api", tags=["chat"])
app.include_router(source_chat.router, prefix="/api", tags=["source-chat"])
app.include_router(credentials.router, prefix="/api", tags=["credentials"])
app.include_router(languages.router, prefix="/api", tags=["languages"])
@app.get("/")

View file

@ -1,4 +1,4 @@
from typing import List
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from loguru import logger
@ -14,12 +14,34 @@ class EpisodeProfileResponse(BaseModel):
name: str
description: str
speaker_config: str
outline_provider: str
outline_model: str
transcript_provider: str
transcript_model: str
outline_llm: Optional[str] = None
transcript_llm: Optional[str] = None
language: Optional[str] = None
default_briefing: str
num_segments: int
# Legacy fields (for display/migration awareness)
outline_provider: Optional[str] = None
outline_model: Optional[str] = None
transcript_provider: Optional[str] = None
transcript_model: Optional[str] = None
def _profile_to_response(profile: EpisodeProfile) -> EpisodeProfileResponse:
return EpisodeProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
speaker_config=profile.speaker_config,
outline_llm=profile.outline_llm,
transcript_llm=profile.transcript_llm,
language=profile.language,
default_briefing=profile.default_briefing,
num_segments=profile.num_segments,
outline_provider=profile.outline_provider,
outline_model=profile.outline_model,
transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model,
)
@router.get("/episode-profiles", response_model=List[EpisodeProfileResponse])
@ -27,23 +49,7 @@ async def list_episode_profiles():
"""List all available episode profiles"""
try:
profiles = await EpisodeProfile.get_all(order_by="name asc")
return [
EpisodeProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
speaker_config=profile.speaker_config,
outline_provider=profile.outline_provider,
outline_model=profile.outline_model,
transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model,
default_briefing=profile.default_briefing,
num_segments=profile.num_segments,
)
for profile in profiles
]
return [_profile_to_response(p) for p in profiles]
except Exception as e:
logger.error(f"Failed to fetch episode profiles: {e}")
raise HTTPException(
@ -62,18 +68,7 @@ async def get_episode_profile(profile_name: str):
status_code=404, detail=f"Episode profile '{profile_name}' not found"
)
return EpisodeProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
speaker_config=profile.speaker_config,
outline_provider=profile.outline_provider,
outline_model=profile.outline_model,
transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model,
default_briefing=profile.default_briefing,
num_segments=profile.num_segments,
)
return _profile_to_response(profile)
except HTTPException:
raise
@ -88,14 +83,18 @@ class EpisodeProfileCreate(BaseModel):
name: str = Field(..., description="Unique profile name")
description: str = Field("", description="Profile description")
speaker_config: str = Field(..., description="Reference to speaker profile name")
outline_provider: str = Field(..., description="AI provider for outline generation")
outline_model: str = Field(..., description="AI model for outline generation")
transcript_provider: str = Field(
..., description="AI provider for transcript generation"
outline_llm: Optional[str] = Field(None, description="Model record ID for outline")
transcript_llm: Optional[str] = Field(
None, description="Model record ID for transcript"
)
transcript_model: str = Field(..., description="AI model for transcript generation")
language: Optional[str] = Field(None, description="Podcast language code")
default_briefing: str = Field(..., description="Default briefing template")
num_segments: int = Field(default=5, description="Number of podcast segments")
# Legacy fields (accepted but not required)
outline_provider: Optional[str] = None
outline_model: Optional[str] = None
transcript_provider: Optional[str] = None
transcript_model: Optional[str] = None
@router.post("/episode-profiles", response_model=EpisodeProfileResponse)
@ -106,28 +105,19 @@ async def create_episode_profile(profile_data: EpisodeProfileCreate):
name=profile_data.name,
description=profile_data.description,
speaker_config=profile_data.speaker_config,
outline_llm=profile_data.outline_llm,
transcript_llm=profile_data.transcript_llm,
language=profile_data.language,
default_briefing=profile_data.default_briefing,
num_segments=profile_data.num_segments,
outline_provider=profile_data.outline_provider,
outline_model=profile_data.outline_model,
transcript_provider=profile_data.transcript_provider,
transcript_model=profile_data.transcript_model,
default_briefing=profile_data.default_briefing,
num_segments=profile_data.num_segments,
)
await profile.save()
return EpisodeProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
speaker_config=profile.speaker_config,
outline_provider=profile.outline_provider,
outline_model=profile.outline_model,
transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model,
default_briefing=profile.default_briefing,
num_segments=profile.num_segments,
)
return _profile_to_response(profile)
except Exception as e:
logger.error(f"Failed to create episode profile: {e}")
@ -147,31 +137,21 @@ async def update_episode_profile(profile_id: str, profile_data: EpisodeProfileCr
status_code=404, detail=f"Episode profile '{profile_id}' not found"
)
# Update fields
profile.name = profile_data.name
profile.description = profile_data.description
profile.speaker_config = profile_data.speaker_config
profile.outline_llm = profile_data.outline_llm
profile.transcript_llm = profile_data.transcript_llm
profile.language = profile_data.language
profile.default_briefing = profile_data.default_briefing
profile.num_segments = profile_data.num_segments
profile.outline_provider = profile_data.outline_provider
profile.outline_model = profile_data.outline_model
profile.transcript_provider = profile_data.transcript_provider
profile.transcript_model = profile_data.transcript_model
profile.default_briefing = profile_data.default_briefing
profile.num_segments = profile_data.num_segments
await profile.save()
return EpisodeProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
speaker_config=profile.speaker_config,
outline_provider=profile.outline_provider,
outline_model=profile.outline_model,
transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model,
default_briefing=profile.default_briefing,
num_segments=profile.num_segments,
)
return _profile_to_response(profile)
except HTTPException:
raise
@ -219,33 +199,23 @@ async def duplicate_episode_profile(profile_id: str):
status_code=404, detail=f"Episode profile '{profile_id}' not found"
)
# Create duplicate with modified name
duplicate = EpisodeProfile(
name=f"{original.name} - Copy",
description=original.description,
speaker_config=original.speaker_config,
outline_llm=original.outline_llm,
transcript_llm=original.transcript_llm,
language=original.language,
default_briefing=original.default_briefing,
num_segments=original.num_segments,
outline_provider=original.outline_provider,
outline_model=original.outline_model,
transcript_provider=original.transcript_provider,
transcript_model=original.transcript_model,
default_briefing=original.default_briefing,
num_segments=original.num_segments,
)
await duplicate.save()
return EpisodeProfileResponse(
id=str(duplicate.id),
name=duplicate.name,
description=duplicate.description or "",
speaker_config=duplicate.speaker_config,
outline_provider=duplicate.outline_provider,
outline_model=duplicate.outline_model,
transcript_provider=duplicate.transcript_provider,
transcript_model=duplicate.transcript_model,
default_briefing=duplicate.default_briefing,
num_segments=duplicate.num_segments,
)
return _profile_to_response(duplicate)
except HTTPException:
raise

83
api/routers/languages.py Normal file
View file

@ -0,0 +1,83 @@
from typing import List
import pycountry
from babel import Locale
from babel.core import get_global
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
# Additional regional variants for languages where the distinction matters
# (TTS accent, vocabulary, spelling differences)
_EXTRA_VARIANTS = [
"pt_PT",
"en_GB",
"en_AU",
"en_IN",
"es_MX",
"es_AR",
"es_CO",
"fr_CA",
"fr_CH",
"zh_TW",
"zh_HK",
"de_AT",
"de_CH",
"ar_SA",
"nl_BE",
]
class LanguageResponse(BaseModel):
code: str
name: str
@router.get("/languages", response_model=List[LanguageResponse])
async def list_languages():
"""List available languages as BCP 47 locale codes (e.g. pt-BR, en-US)."""
likely_subtags = get_global("likely_subtags")
languages = []
seen = set()
# 1. For each language, resolve its default locale via CLDR likely subtags
for lang in pycountry.languages:
if not hasattr(lang, "alpha_2"):
continue
code = lang.alpha_2
likely = likely_subtags.get(code)
if likely:
try:
loc = Locale.parse(likely)
if loc.territory:
bcp47 = f"{loc.language}-{loc.territory}"
display = loc.get_display_name("en")
if bcp47 not in seen:
seen.add(bcp47)
languages.append(LanguageResponse(code=bcp47, name=display))
continue
except Exception:
pass
# Fallback: bare language code
if code not in seen:
seen.add(code)
languages.append(LanguageResponse(code=code, name=lang.name))
# 2. Add important regional variants
for locale_str in _EXTRA_VARIANTS:
try:
loc = Locale.parse(locale_str)
bcp47 = f"{loc.language}-{loc.territory}"
if bcp47 not in seen:
seen.add(bcp47)
display = loc.get_display_name("en")
languages.append(LanguageResponse(code=bcp47, name=display))
except Exception:
pass
languages.sort(key=lambda x: x.name)
return languages

View file

@ -1,4 +1,4 @@
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException
from loguru import logger
@ -13,9 +13,23 @@ class SpeakerProfileResponse(BaseModel):
id: str
name: str
description: str
tts_provider: str
tts_model: str
voice_model: Optional[str] = None
speakers: List[Dict[str, Any]]
# Legacy fields (for display/migration awareness)
tts_provider: Optional[str] = None
tts_model: Optional[str] = None
def _profile_to_response(profile: SpeakerProfile) -> SpeakerProfileResponse:
return SpeakerProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
voice_model=profile.voice_model,
speakers=profile.speakers,
tts_provider=profile.tts_provider,
tts_model=profile.tts_model,
)
@router.get("/speaker-profiles", response_model=List[SpeakerProfileResponse])
@ -23,19 +37,7 @@ async def list_speaker_profiles():
"""List all available speaker profiles"""
try:
profiles = await SpeakerProfile.get_all(order_by="name asc")
return [
SpeakerProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
tts_provider=profile.tts_provider,
tts_model=profile.tts_model,
speakers=profile.speakers,
)
for profile in profiles
]
return [_profile_to_response(p) for p in profiles]
except Exception as e:
logger.error(f"Failed to fetch speaker profiles: {e}")
raise HTTPException(
@ -54,14 +56,7 @@ async def get_speaker_profile(profile_name: str):
status_code=404, detail=f"Speaker profile '{profile_name}' not found"
)
return SpeakerProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
tts_provider=profile.tts_provider,
tts_model=profile.tts_model,
speakers=profile.speakers,
)
return _profile_to_response(profile)
except HTTPException:
raise
@ -75,11 +70,13 @@ async def get_speaker_profile(profile_name: str):
class SpeakerProfileCreate(BaseModel):
name: str = Field(..., description="Unique profile name")
description: str = Field("", description="Profile description")
tts_provider: str = Field(..., description="TTS provider")
tts_model: str = Field(..., description="TTS model name")
voice_model: Optional[str] = Field(None, description="Model record ID for TTS")
speakers: List[Dict[str, Any]] = Field(
..., description="Array of speaker configurations"
)
# Legacy fields (accepted but not required)
tts_provider: Optional[str] = None
tts_model: Optional[str] = None
@router.post("/speaker-profiles", response_model=SpeakerProfileResponse)
@ -89,21 +86,14 @@ async def create_speaker_profile(profile_data: SpeakerProfileCreate):
profile = SpeakerProfile(
name=profile_data.name,
description=profile_data.description,
voice_model=profile_data.voice_model,
speakers=profile_data.speakers,
tts_provider=profile_data.tts_provider,
tts_model=profile_data.tts_model,
speakers=profile_data.speakers,
)
await profile.save()
return SpeakerProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
tts_provider=profile.tts_provider,
tts_model=profile.tts_model,
speakers=profile.speakers,
)
return _profile_to_response(profile)
except Exception as e:
logger.error(f"Failed to create speaker profile: {e}")
@ -123,23 +113,15 @@ async def update_speaker_profile(profile_id: str, profile_data: SpeakerProfileCr
status_code=404, detail=f"Speaker profile '{profile_id}' not found"
)
# Update fields
profile.name = profile_data.name
profile.description = profile_data.description
profile.voice_model = profile_data.voice_model
profile.speakers = profile_data.speakers
profile.tts_provider = profile_data.tts_provider
profile.tts_model = profile_data.tts_model
profile.speakers = profile_data.speakers
await profile.save()
return SpeakerProfileResponse(
id=str(profile.id),
name=profile.name,
description=profile.description or "",
tts_provider=profile.tts_provider,
tts_model=profile.tts_model,
speakers=profile.speakers,
)
return _profile_to_response(profile)
except HTTPException:
raise
@ -187,25 +169,17 @@ async def duplicate_speaker_profile(profile_id: str):
status_code=404, detail=f"Speaker profile '{profile_id}' not found"
)
# Create duplicate with modified name
duplicate = SpeakerProfile(
name=f"{original.name} - Copy",
description=original.description,
voice_model=original.voice_model,
speakers=original.speakers,
tts_provider=original.tts_provider,
tts_model=original.tts_model,
speakers=original.speakers,
)
await duplicate.save()
return SpeakerProfileResponse(
id=str(duplicate.id),
name=duplicate.name,
description=duplicate.description or "",
tts_provider=duplicate.tts_provider,
tts_model=duplicate.tts_model,
speakers=duplicate.speakers,
)
return _profile_to_response(duplicate)
except HTTPException:
raise

View file

@ -16,7 +16,7 @@
- **`process_source_command`**: Ingests content through `source_graph`, creates embeddings (optional), and generates insights. Retries on transaction conflicts (exp. jitter, max 15×, 1-120s).
- **`run_transformation_command`**: Runs a transformation on an existing source to generate an insight. Executes the transformation graph (LLM call) then creates insight via `create_insight_command`. Used by `POST /sources/{id}/insights` API endpoint. Retry: 5 attempts, exponential jitter 1-60s.
- **`generate_podcast_command`**: Creates podcasts via `podcast-creator` library using stored episode/speaker profiles.
- **`generate_podcast_command`**: Creates podcasts via podcast-creator library. Resolves model registry references and credentials for all profiles before invoking podcast-creator. Validates that outline_llm, transcript_llm, and voice_model are configured.
- **`process_text_command`** (example): Test fixture for text operations (uppercase, lowercase, reverse, word_count).
- **`analyze_data_command`** (example): Test fixture for numeric aggregations.
@ -43,7 +43,7 @@
- **source_commands**: `ensure_record_id()` wraps command IDs for DB storage; transaction conflicts trigger exponential backoff retry. ValueError exceptions are permanent (not retried).
- **embedding_commands**: Content type detection uses file extension as primary source, heuristics as fallback. Chunks >1800 chars trigger secondary splitting. Empty/whitespace-only content returns ValueError (not retried).
- **rebuild_embeddings_command**: Returns "jobs_submitted" not "processed_items" - embedding is async. Individual commands handle failures with their own retries.
- **podcast_commands**: Profiles loaded from SurrealDB by name (must exist); briefing can be extended with suffix. Episode records created mid-execution.
- **podcast_commands**: Profiles loaded from SurrealDB by name; model configs (credentials) resolved for ALL profiles before podcast-creator validation. Validates outline_llm/transcript_llm/voice_model are set. Episode records created mid-execution.
- **Example commands**: Accept optional `delay_seconds` for testing async behavior; not for production.
## Code Example

View file

@ -8,7 +8,12 @@ from surreal_commands import CommandInput, CommandOutput, command
from open_notebook.config import DATA_FOLDER
from open_notebook.database.repository import ensure_record_id, repo_query
from open_notebook.podcasts.models import EpisodeProfile, PodcastEpisode, SpeakerProfile
from open_notebook.podcasts.models import (
EpisodeProfile,
PodcastEpisode,
SpeakerProfile,
_resolve_model_config,
)
try:
from podcast_creator import configure, create_podcast
@ -79,7 +84,41 @@ async def generate_podcast_command(
logger.info(f"Loaded episode profile: {episode_profile.name}")
logger.info(f"Loaded speaker profile: {speaker_profile.name}")
# 3. Load all profiles and configure podcast-creator
# 2. Validate that model registry fields are populated
if not episode_profile.outline_llm:
raise ValueError(
f"Episode profile '{episode_profile.name}' has no outline model configured. "
"Please update the profile to select an outline model."
)
if not episode_profile.transcript_llm:
raise ValueError(
f"Episode profile '{episode_profile.name}' has no transcript model configured. "
"Please update the profile to select a transcript model."
)
if not speaker_profile.voice_model:
raise ValueError(
f"Speaker profile '{speaker_profile.name}' has no voice model configured. "
"Please update the profile to select a voice model."
)
# 3. Resolve model configs with credentials
outline_provider, outline_model_name, outline_config = (
await episode_profile.resolve_outline_config()
)
transcript_provider, transcript_model_name, transcript_config = (
await episode_profile.resolve_transcript_config()
)
tts_provider, tts_model_name, tts_config = (
await speaker_profile.resolve_tts_config()
)
logger.info(
f"Resolved models - outline: {outline_provider}/{outline_model_name}, "
f"transcript: {transcript_provider}/{transcript_model_name}, "
f"tts: {tts_provider}/{tts_model_name}"
)
# 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")
@ -91,12 +130,74 @@ async def generate_podcast_command(
profile["name"]: profile for profile in speaker_profiles
}
# 4. Generate briefing
# 5. Inject resolved model configs into profile dicts
# Resolve ALL episode profiles (podcast-creator validates all).
# Remove profiles that fail resolution to prevent validation errors.
for ep_name in list(episode_profiles_dict.keys()):
ep_dict = episode_profiles_dict[ep_name]
try:
if ep_dict.get("outline_llm"):
prov, model, conf = await _resolve_model_config(
str(ep_dict["outline_llm"])
)
ep_dict["outline_provider"] = prov
ep_dict["outline_model"] = model
ep_dict["outline_config"] = conf
if ep_dict.get("transcript_llm"):
prov, model, conf = await _resolve_model_config(
str(ep_dict["transcript_llm"])
)
ep_dict["transcript_provider"] = prov
ep_dict["transcript_model"] = model
ep_dict["transcript_config"] = conf
except Exception as e:
logger.warning(
f"Failed to resolve models for episode profile '{ep_name}', "
f"removing from config to prevent validation errors: {e}"
)
del episode_profiles_dict[ep_name]
# Resolve TTS for ALL speaker profiles (podcast-creator validates all).
# Remove profiles that fail resolution to prevent validation errors.
for sp_name in list(speaker_profiles_dict.keys()):
sp_dict = speaker_profiles_dict[sp_name]
if sp_dict.get("voice_model"):
try:
prov, model, conf = await _resolve_model_config(
str(sp_dict["voice_model"])
)
sp_dict["tts_provider"] = prov
sp_dict["tts_model"] = model
sp_dict["tts_config"] = conf
except Exception as e:
logger.warning(
f"Failed to resolve TTS for speaker profile '{sp_name}', "
f"removing from config to prevent validation errors: {e}"
)
del speaker_profiles_dict[sp_name]
continue
# Per-speaker TTS overrides
for speaker in sp_dict.get("speakers", []):
if speaker.get("voice_model"):
try:
prov, model, conf = await _resolve_model_config(
str(speaker["voice_model"])
)
speaker["tts_provider"] = prov
speaker["tts_model"] = model
speaker["tts_config"] = conf
except Exception as e:
logger.warning(
f"Failed to resolve per-speaker TTS for '{speaker.get('name')}': {e}"
)
# 6. Generate briefing
briefing = episode_profile.default_briefing
if input_data.briefing_suffix:
briefing += f"\n\nAdditional instructions: {input_data.briefing_suffix}"
# Create the a record for the episose and associate with the ongoing command
# Create the record for the episode and associate with the ongoing command
episode = PodcastEpisode(
name=input_data.episode_name,
episode_profile=full_model_dump(episode_profile.model_dump()),
@ -119,13 +220,13 @@ async def generate_podcast_command(
logger.info(f"Generated briefing (length: {len(briefing)} chars)")
# 5. Create output directory
# 7. Create output directory
output_dir = Path(f"{DATA_FOLDER}/podcasts/episodes/{input_data.episode_name}")
output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Created output directory: {output_dir}")
# 6. Generate podcast using podcast-creator
# 8. Generate podcast using podcast-creator
logger.info("Starting podcast generation with podcast-creator...")
result = await create_podcast(

View file

@ -94,13 +94,14 @@ Speaker 1: "Expert Alex"
├─ Expertise: "Deep knowledge of alignment research"
├─ Personality: "Rigorous, academic, patient with explanation"
├─ Accent: (Optional) "British English"
└─ TTS Voice: "OpenAI Onyx" (or ElevenLabs, Google, etc.)
└─ Voice Model: Selected from model registry (e.g., OpenAI TTS)
└─ Optional per-speaker override of the episode's default voice model
Speaker 2: "Researcher Sam"
├─ Expertise: "Field observer, pragmatic perspective"
├─ Personality: "Curious, asks clarifying questions"
├─ Accent: "American English"
└─ TTS Voice: "ElevenLabs - thoughtful"
└─ Voice Model: Selected from model registry (e.g., ElevenLabs TTS)
```
### Stage 4: Outline Generation
@ -147,10 +148,10 @@ Alex: "Exactly. And that's where the three approaches come in..."
### Stage 6: Text-to-Speech
System converts dialogue to audio:
System converts dialogue to audio using the voice models configured in the model registry. Credentials are automatically resolved from each model's configuration.
```
Alex's text → OpenAI TTS → Alex's voice (audio file)
Sam's text → ElevenLabs TTS → Sam's voice (audio file)
Alex's text → Voice model (from registry) → Alex's voice (audio file)
Sam's text → Voice model (from registry) → Sam's voice (audio file)
Audio files → Mix together → Final podcast MP3
```
@ -181,7 +182,7 @@ When podcast generation fails (e.g., wrong model configured, API key expired, pr
| Error | What to Do |
|-------|-----------|
| Invalid API key | Check Settings -> Credentials for the TTS and language model providers |
| Model not found | Verify the model name in your episode profile exists and is correctly configured |
| Model not found | Verify the model exists in the model registry and has valid credentials configured |
| Rate limit exceeded | Wait a few minutes and retry |
| Provider unavailable | Check provider status page; retry later |
@ -314,7 +315,7 @@ New team member listens, gets context faster than reading 100 documents
4. Decide on podcast
├─→ Create speaker profiles
├─→ Define episode profile
├─→ Choose TTS provider
├─→ Configure voice models (from model registry)
└─→ Generate podcast
5. Listen while commuting/exercising

View file

@ -74,7 +74,7 @@ An episode profile defines the structure and tone.
**Option A: Use Preset Profile**
```
Open Notebook provides templates:
Open Notebook provides preset profiles:
Academic Presentation (Monologue)
├─ 1 speaker
@ -140,22 +140,22 @@ Speakers are the "voice" of your podcast.
**Option A: Use Preset Speakers**
```
Open Notebook provides templates:
Open Notebook provides preset profiles:
"Expert Alex"
- Expertise: Deep knowledge
- Personality: Rigorous, patient
- TTS: OpenAI (clear voice)
- Voice Model: Selected from model registry
"Curious Sam"
- Expertise: Curious newcomer
- Personality: Asks questions
- TTS: Google (natural voice)
- Voice Model: Selected from model registry
"Skeptic Jordan"
- Expertise: Critical perspective
- Personality: Challenges assumptions
- TTS: ElevenLabs (warm voice)
- Voice Model: Selected from model registry
For your first podcast: Use presets
For custom podcast: Create your own
@ -179,15 +179,17 @@ Personality:
explains clearly, asks good questions"
Voice Configuration:
- TTS Provider: OpenAI / Google / ElevenLabs / Local
- Voice selection: Choose from available voices
- Accent (optional): British / American / etc.
- Voice Model: Select from model registry (e.g., OpenAI TTS, Google TTS, ElevenLabs)
- Voice: Choose from available voices for the selected model
- Per-speaker override: Each speaker can optionally use a different voice model
Credentials are automatically resolved from the model configuration.
Example:
Name: Dr. Research Expert
Expertise: AI safety alignment research
Personality: Rigorous, academic but accessible
Voice: ElevenLabs - professional male voice
Voice Model: ElevenLabs TTS (from registry), Voice: professional male
```
### Step 6: Generate Podcast
@ -463,7 +465,7 @@ Rule: 3-5 sources per podcast
**Solutions**:
```
1. Choose different TTS providers (OpenAI + Google)
1. Choose different voice models from the registry for each speaker
2. Choose very different voice options
3. Increase personality differences in profile
4. Try different speaker count (2 vs 3 vs 4)

View file

@ -200,8 +200,8 @@ Inside a notebook, switch to Podcasts:
│ Episode Profile: [Select ▼] │
│ │
│ Speakers: │
│ ├─ Host: Alex (OpenAI)
│ └─ Guest: Sam (Google)
│ ├─ Host: Alex (voice model)
│ └─ Guest: Sam (voice model)
│ │
│ Include: │
│ ☑ Paper.pdf │

View file

@ -1,18 +1,29 @@
'use client'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { AppShell } from '@/components/layout/AppShell'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { EpisodesTab } from '@/components/podcasts/EpisodesTab'
import { TemplatesTab } from '@/components/podcasts/TemplatesTab'
import { Mic, LayoutTemplate } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
import { useEpisodeProfiles, useSpeakerProfiles } from '@/lib/hooks/use-podcasts'
import { needsModelSetup } from '@/lib/types/podcasts'
export default function PodcastsPage() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<'episodes' | 'templates'>('episodes')
const { episodeProfiles } = useEpisodeProfiles()
const { speakerProfiles } = useSpeakerProfiles(episodeProfiles)
const hasUnconfiguredProfiles = useMemo(() => {
return episodeProfiles.some(needsModelSetup) || speakerProfiles.some(needsModelSetup)
}, [episodeProfiles, speakerProfiles])
return (
<AppShell>
<div className="flex-1 overflow-y-auto">
@ -24,6 +35,16 @@ export default function PodcastsPage() {
</p>
</header>
{hasUnconfiguredProfiles ? (
<Alert className="bg-amber-50 text-amber-900 border-amber-200">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t.podcasts.setupRequired}</AlertTitle>
<AlertDescription>
{t.podcasts.setupRequiredDesc}
</AlertDescription>
</Alert>
) : null}
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as 'episodes' | 'templates')}

View file

@ -1,13 +1,14 @@
'use client'
import { useMemo, useState } from 'react'
import { Copy, Edit3, MoreVertical, Trash2, Users } from 'lucide-react'
import { AlertTriangle, Copy, Edit3, MoreVertical, Trash2, Users } from 'lucide-react'
import { EpisodeProfile, SpeakerProfile } from '@/lib/types/podcasts'
import { EpisodeProfile, SpeakerProfile, needsModelSetup } from '@/lib/types/podcasts'
import {
useDeleteEpisodeProfile,
useDuplicateEpisodeProfile,
} from '@/lib/hooks/use-podcasts'
import { useModels } from '@/lib/hooks/use-models'
import { EpisodeProfileFormDialog } from '@/components/podcasts/forms/EpisodeProfileFormDialog'
import {
AlertDialog,
@ -41,7 +42,6 @@ import { useTranslation } from '@/lib/hooks/use-translation'
interface EpisodeProfilesPanelProps {
episodeProfiles: EpisodeProfile[]
speakerProfiles: SpeakerProfile[]
modelOptions: Record<string, string[]>
}
function findSpeakerSummary(
@ -54,7 +54,6 @@ function findSpeakerSummary(
export function EpisodeProfilesPanel({
episodeProfiles,
speakerProfiles,
modelOptions,
}: EpisodeProfilesPanelProps) {
const { t } = useTranslation()
const [createOpen, setCreateOpen] = useState(false)
@ -62,6 +61,15 @@ export function EpisodeProfilesPanel({
const deleteProfile = useDeleteEpisodeProfile()
const duplicateProfile = useDuplicateEpisodeProfile()
const { data: models = [] } = useModels()
const modelNameMap = useMemo(() => {
const map: Record<string, string> = {}
for (const m of models) {
map[m.id] = `${m.provider} / ${m.name}`
}
return map
}, [models])
const sortedProfiles = useMemo(
() =>
@ -102,14 +110,23 @@ export function EpisodeProfilesPanel({
speakerProfiles,
profile.speaker_config
)
const unconfigured = needsModelSetup(profile)
return (
<Card key={profile.id} className="shadow-sm">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-lg font-semibold">
{profile.name}
</CardTitle>
<div className="flex items-center gap-2">
<CardTitle className="text-lg font-semibold">
{profile.name}
</CardTitle>
{unconfigured ? (
<Badge variant="outline" className="text-amber-600 border-amber-300 text-xs">
<AlertTriangle className="h-3 w-3 mr-1" />
{t.podcasts.setupRequired}
</Badge>
) : null}
</div>
<CardDescription className="text-sm text-muted-foreground">
{profile.description || t.podcasts.noDescription}
</CardDescription>
@ -183,7 +200,11 @@ export function EpisodeProfilesPanel({
{t.podcasts.outlineModel}
</p>
<p className="text-foreground">
{profile.outline_provider} / {profile.outline_model}
{profile.outline_llm
? (modelNameMap[profile.outline_llm] ?? profile.outline_llm)
: (profile.outline_provider && profile.outline_model
? `${profile.outline_provider} / ${profile.outline_model}`
: t.podcasts.notConfigured)}
</p>
</div>
<div>
@ -191,7 +212,11 @@ export function EpisodeProfilesPanel({
{t.podcasts.transcriptModel}
</p>
<p className="text-foreground">
{profile.transcript_provider} / {profile.transcript_model}
{profile.transcript_llm
? (modelNameMap[profile.transcript_llm] ?? profile.transcript_llm)
: (profile.transcript_provider && profile.transcript_model
? `${profile.transcript_provider} / ${profile.transcript_model}`
: t.podcasts.notConfigured)}
</p>
</div>
<div>
@ -200,6 +225,14 @@ export function EpisodeProfilesPanel({
</p>
<p className="text-foreground">{profile.num_segments}</p>
</div>
{profile.language ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.language}
</p>
<p className="text-foreground">{profile.language}</p>
</div>
) : null}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.speakerProfile}
@ -207,7 +240,11 @@ export function EpisodeProfilesPanel({
<div className="flex items-center gap-2 text-foreground">
<Users className="h-4 w-4" />
<span>{profile.speaker_config}</span>
{speakerSummary ? (
{speakerSummary?.voice_model ? (
<Badge variant="outline" className="text-xs">
{modelNameMap[speakerSummary.voice_model] ?? speakerSummary.voice_model}
</Badge>
) : speakerSummary?.tts_provider ? (
<Badge variant="outline" className="text-xs">
{speakerSummary.tts_provider} / {speakerSummary.tts_model}
</Badge>
@ -238,7 +275,6 @@ export function EpisodeProfilesPanel({
open={createOpen}
onOpenChange={setCreateOpen}
speakerProfiles={speakerProfiles}
modelOptions={modelOptions}
/>
<EpisodeProfileFormDialog
@ -250,7 +286,6 @@ export function EpisodeProfilesPanel({
}
}}
speakerProfiles={speakerProfiles}
modelOptions={modelOptions}
initialData={editProfile ?? undefined}
/>
</div>

View file

@ -1,13 +1,14 @@
'use client'
import { useMemo, useState } from 'react'
import { Copy, Edit3, MoreVertical, Trash2, Volume2 } from 'lucide-react'
import { AlertTriangle, Copy, Edit3, MoreVertical, Trash2, Volume2 } from 'lucide-react'
import { SpeakerProfile } from '@/lib/types/podcasts'
import { SpeakerProfile, needsModelSetup } from '@/lib/types/podcasts'
import {
useDeleteSpeakerProfile,
useDuplicateSpeakerProfile,
} from '@/lib/hooks/use-podcasts'
import { useModels } from '@/lib/hooks/use-models'
import { SpeakerProfileFormDialog } from '@/components/podcasts/forms/SpeakerProfileFormDialog'
import {
AlertDialog,
@ -40,13 +41,11 @@ import { useTranslation } from '@/lib/hooks/use-translation'
interface SpeakerProfilesPanelProps {
speakerProfiles: SpeakerProfile[]
modelOptions: Record<string, string[]>
usage: Record<string, number>
}
export function SpeakerProfilesPanel({
speakerProfiles,
modelOptions,
usage,
}: SpeakerProfilesPanelProps) {
const { t } = useTranslation()
@ -55,10 +54,19 @@ export function SpeakerProfilesPanel({
const deleteProfile = useDeleteSpeakerProfile()
const duplicateProfile = useDuplicateSpeakerProfile()
const { data: models = [] } = useModels()
const modelNameMap = useMemo(() => {
const map: Record<string, string> = {}
for (const m of models) {
map[m.id] = `${m.provider} / ${m.name}`
}
return map
}, [models])
const sortedProfiles = useMemo(
() =>
[...speakerProfiles].sort((a, b) => a.name.localeCompare(b.name, 'en')),
[...speakerProfiles].sort((a, b) => a.name.localeCompare(b.name, 'en')),
[speakerProfiles]
)
@ -83,21 +91,34 @@ export function SpeakerProfilesPanel({
{sortedProfiles.map((profile) => {
const usageCount = usage[profile.name] ?? 0
const deleteDisabled = usageCount > 0
const unconfigured = needsModelSetup(profile)
return (
<Card key={profile.id} className="shadow-sm">
<CardHeader className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle className="text-lg font-semibold">
{profile.name}
</CardTitle>
<div className="flex items-center gap-2">
<CardTitle className="text-lg font-semibold">
{profile.name}
</CardTitle>
{unconfigured ? (
<Badge variant="outline" className="text-amber-600 border-amber-300 text-xs">
<AlertTriangle className="h-3 w-3 mr-1" />
{t.podcasts.setupRequired}
</Badge>
) : null}
</div>
<CardDescription className="text-sm text-muted-foreground">
{profile.description || t.podcasts.noDescription}
</CardDescription>
</div>
<Badge variant="outline" className="text-xs">
{profile.tts_provider} / {profile.tts_model}
{profile.voice_model
? (modelNameMap[profile.voice_model] ?? profile.voice_model)
: (profile.tts_provider
? `${profile.tts_provider} / ${profile.tts_model}`
: t.podcasts.notConfigured)}
</Badge>
</div>
<div className="flex flex-wrap gap-2">
@ -126,9 +147,16 @@ export function SpeakerProfilesPanel({
{speaker.name}
</span>
</div>
<span className="text-xs text-muted-foreground">
{t.podcasts.voiceId}: {speaker.voice_id}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{t.podcasts.voiceId}: {speaker.voice_id}
</span>
{speaker.voice_model ? (
<Badge variant="secondary" className="text-xs">
{modelNameMap[speaker.voice_model] ?? speaker.voice_model}
</Badge>
) : null}
</div>
</div>
<p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap">
<span className="font-semibold">{t.podcasts.backstory}:</span> {speaker.backstory}
@ -219,7 +247,6 @@ export function SpeakerProfilesPanel({
mode="create"
open={createOpen}
onOpenChange={setCreateOpen}
modelOptions={modelOptions}
/>
<SpeakerProfileFormDialog
@ -230,7 +257,6 @@ export function SpeakerProfilesPanel({
setEditProfile(null)
}
}}
modelOptions={modelOptions}
initialData={editProfile ?? undefined}
/>
</div>

View file

@ -1,29 +1,14 @@
'use client'
import { useMemo } from 'react'
import { AlertCircle, Lightbulb, Loader2 } from 'lucide-react'
import { EpisodeProfilesPanel } from '@/components/podcasts/EpisodeProfilesPanel'
import { SpeakerProfilesPanel } from '@/components/podcasts/SpeakerProfilesPanel'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { useEpisodeProfiles, useSpeakerProfiles } from '@/lib/hooks/use-podcasts'
import { useModels } from '@/lib/hooks/use-models'
import { Model } from '@/lib/types/models'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { useTranslation } from '@/lib/hooks/use-translation'
function modelsByProvider(models: Model[], type: Model['type']) {
return models
.filter((model) => model.type === type)
.reduce<Record<string, string[]>>((acc, model) => {
if (!acc[model.provider]) {
acc[model.provider] = []
}
acc[model.provider].push(model.name)
return acc
}, {})
}
export function TemplatesTab() {
const { t } = useTranslation()
const {
@ -39,23 +24,8 @@ export function TemplatesTab() {
error: speakerProfilesError,
} = useSpeakerProfiles(episodeProfiles)
const {
data: models = [],
isLoading: loadingModels,
error: modelsError,
} = useModels()
const languageModelOptions = useMemo(
() => modelsByProvider(models, 'language'),
[models]
)
const ttsModelOptions = useMemo(
() => modelsByProvider(models, 'text_to_speech'),
[models]
)
const isLoading = loadingEpisodeProfiles || loadingSpeakerProfiles || loadingModels
const hasError = episodeProfilesError || speakerProfilesError || modelsError
const isLoading = loadingEpisodeProfiles || loadingSpeakerProfiles
const hasError = episodeProfilesError || speakerProfilesError
return (
<div className="space-y-6">
@ -67,8 +37,8 @@ export function TemplatesTab() {
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem
value="overview"
<AccordionItem
value="overview"
className="overflow-hidden rounded-xl border border-border bg-muted/40 px-4"
>
<AccordionTrigger className="gap-2 py-4 text-left text-sm font-semibold">
@ -137,12 +107,10 @@ export function TemplatesTab() {
<SpeakerProfilesPanel
speakerProfiles={speakerProfiles}
usage={usage}
modelOptions={ttsModelOptions}
/>
<EpisodeProfilesPanel
episodeProfiles={episodeProfiles}
speakerProfiles={speakerProfiles}
modelOptions={languageModelOptions}
/>
</div>
)}

View file

@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@ -9,6 +9,7 @@ import { EpisodeProfile, SpeakerProfile } from '@/lib/types/podcasts'
import {
useCreateEpisodeProfile,
useUpdateEpisodeProfile,
useLanguages,
} from '@/lib/hooks/use-podcasts'
import { useTranslation } from '@/lib/hooks/use-translation'
import {
@ -31,16 +32,16 @@ import {
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Separator } from '@/components/ui/separator'
import { ModelSelector } from '@/components/common/ModelSelector'
import { TranslationKeys } from '@/lib/locales'
const episodeProfileSchema = (t: TranslationKeys) => z.object({
name: z.string().min(1, t.podcasts.nameRequired || 'Name is required'),
description: z.string().optional(),
speaker_config: z.string().min(1, t.podcasts.profileRequired || 'Speaker profile is required'),
outline_provider: z.string().min(1, t.podcasts.outlineProviderRequired || 'Outline provider is required'),
outline_model: z.string().min(1, t.podcasts.outlineModelRequired || 'Outline model is required'),
transcript_provider: z.string().min(1, t.podcasts.transcriptProviderRequired || 'Transcript provider is required'),
transcript_model: z.string().min(1, t.podcasts.transcriptModelRequired || 'Transcript model is required'),
outline_llm: z.string().min(1, t.podcasts.outlineModelRequired || 'Outline model is required'),
transcript_llm: z.string().min(1, t.podcasts.transcriptModelRequired || 'Transcript model is required'),
language: z.string().nullable().optional(),
default_briefing: z.string().min(1, t.podcasts.defaultBriefingRequired || 'Default briefing is required'),
num_segments: z.number()
.int(t.podcasts.segmentsInteger || 'Must be an integer')
@ -55,7 +56,6 @@ interface EpisodeProfileFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
speakerProfiles: SpeakerProfile[]
modelOptions: Record<string, string[]>
initialData?: EpisodeProfile
}
@ -64,29 +64,24 @@ export function EpisodeProfileFormDialog({
open,
onOpenChange,
speakerProfiles,
modelOptions,
initialData,
}: EpisodeProfileFormDialogProps) {
const { t } = useTranslation()
const createProfile = useCreateEpisodeProfile()
const updateProfile = useUpdateEpisodeProfile()
const providers = useMemo(() => Object.keys(modelOptions), [modelOptions])
const { data: languages = [] } = useLanguages()
const getDefaults = useCallback((): EpisodeProfileFormValues => {
const firstSpeaker = speakerProfiles[0]?.name ?? ''
const firstProvider = providers[0] ?? ''
const firstModel = firstProvider ? modelOptions[firstProvider]?.[0] ?? '' : ''
if (initialData) {
return {
name: initialData.name,
description: initialData.description ?? '',
speaker_config: initialData.speaker_config,
outline_provider: initialData.outline_provider,
outline_model: initialData.outline_model,
transcript_provider: initialData.transcript_provider,
transcript_model: initialData.transcript_model,
outline_llm: initialData.outline_llm ?? '',
transcript_llm: initialData.transcript_llm ?? '',
language: initialData.language ?? null,
default_briefing: initialData.default_briefing,
num_segments: initialData.num_segments,
}
@ -96,35 +91,25 @@ export function EpisodeProfileFormDialog({
name: '',
description: '',
speaker_config: firstSpeaker,
outline_provider: firstProvider,
outline_model: firstModel,
transcript_provider: firstProvider,
transcript_model: firstModel,
outline_llm: '',
transcript_llm: '',
language: null,
default_briefing: '',
num_segments: 5,
}
}, [initialData, modelOptions, providers, speakerProfiles])
}, [initialData, speakerProfiles])
const {
control,
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
} = useForm<EpisodeProfileFormValues>({
resolver: zodResolver(episodeProfileSchema(t)),
defaultValues: getDefaults(),
})
const outlineProvider = watch('outline_provider')
const outlineModel = watch('outline_model')
const transcriptProvider = watch('transcript_provider')
const transcriptModel = watch('transcript_model')
const availableOutlineModels = modelOptions[outlineProvider] ?? []
const availableTranscriptModels = modelOptions[transcriptProvider] ?? []
useEffect(() => {
if (!open) {
return
@ -132,38 +117,11 @@ export function EpisodeProfileFormDialog({
reset(getDefaults())
}, [open, reset, getDefaults])
useEffect(() => {
if (!outlineProvider) {
return
}
const models = modelOptions[outlineProvider] ?? []
if (models.length === 0) {
setValue('outline_model', '')
return
}
if (!models.includes(outlineModel)) {
setValue('outline_model', models[0])
}
}, [outlineProvider, outlineModel, modelOptions, setValue])
useEffect(() => {
if (!transcriptProvider) {
return
}
const models = modelOptions[transcriptProvider] ?? []
if (models.length === 0) {
setValue('transcript_model', '')
return
}
if (!models.includes(transcriptModel)) {
setValue('transcript_model', models[0])
}
}, [transcriptProvider, transcriptModel, modelOptions, setValue])
const onSubmit = async (values: EpisodeProfileFormValues) => {
const payload = {
...values,
description: values.description ?? '',
language: values.language || null,
}
if (mode === 'create') {
@ -179,8 +137,7 @@ export function EpisodeProfileFormDialog({
}
const isSubmitting = createProfile.isPending || updateProfile.isPending
const disableSubmit =
isSubmitting || speakerProfiles.length === 0 || providers.length === 0
const disableSubmit = isSubmitting || speakerProfiles.length === 0
const isEdit = mode === 'edit'
return (
@ -204,15 +161,6 @@ export function EpisodeProfileFormDialog({
</Alert>
) : null}
{providers.length === 0 ? (
<Alert className="bg-amber-50 text-amber-900 border-amber-200">
<AlertTitle>{t.podcasts.noLanguageModelsAvailable}</AlertTitle>
<AlertDescription>
{t.podcasts.noLanguageModelsDesc}
</AlertDescription>
</Alert>
) : null}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
@ -292,61 +240,26 @@ export function EpisodeProfileFormDialog({
</h3>
<Separator className="mt-2" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Controller
control={control}
name="outline_provider"
render={({ field }) => (
<div className="space-y-2">
<Label htmlFor="outline_provider">{t.models.provider} *</Label>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="outline_provider">
<SelectValue placeholder={t.models.selectProviderPlaceholder} />
</SelectTrigger>
<SelectContent title={t.models.provider}>
{providers.map((provider) => (
<SelectItem key={provider} value={provider}>
<span className="capitalize">{provider}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.outline_provider ? (
<p className="text-xs text-red-600">
{errors.outline_provider.message}
</p>
) : null}
</div>
)}
/>
<Controller
control={control}
name="outline_model"
render={({ field }) => (
<div className="space-y-2">
<Label htmlFor="outline_model">{t.common.model} *</Label>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="outline_model">
<SelectValue placeholder={t.models.selectModelPlaceholder} />
</SelectTrigger>
<SelectContent title={t.common.model}>
{availableOutlineModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.outline_model ? (
<p className="text-xs text-red-600">
{errors.outline_model.message}
</p>
) : null}
</div>
)}
/>
</div>
<Controller
control={control}
name="outline_llm"
render={({ field }) => (
<div>
<ModelSelector
label={`${t.podcasts.outlineModel} *`}
modelType="language"
value={field.value}
onChange={field.onChange}
placeholder={t.podcasts.selectOutlineModel}
/>
{errors.outline_llm ? (
<p className="text-xs text-red-600 mt-1">
{errors.outline_llm.message}
</p>
) : null}
</div>
)}
/>
</div>
<div className="space-y-4">
@ -356,61 +269,59 @@ export function EpisodeProfileFormDialog({
</h3>
<Separator className="mt-2" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Controller
control={control}
name="transcript_provider"
render={({ field }) => (
<div className="space-y-2">
<Label htmlFor="transcript_provider">{t.models.provider} *</Label>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="transcript_provider">
<SelectValue placeholder={t.models.selectProviderPlaceholder} />
</SelectTrigger>
<SelectContent title={t.models.provider}>
{providers.map((provider) => (
<SelectItem key={provider} value={provider}>
<span className="capitalize">{provider}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.transcript_provider ? (
<p className="text-xs text-red-600">
{errors.transcript_provider.message}
</p>
) : null}
</div>
)}
/>
<Controller
control={control}
name="transcript_llm"
render={({ field }) => (
<div>
<ModelSelector
label={`${t.podcasts.transcriptModel} *`}
modelType="language"
value={field.value}
onChange={field.onChange}
placeholder={t.podcasts.selectTranscriptModel}
/>
{errors.transcript_llm ? (
<p className="text-xs text-red-600 mt-1">
{errors.transcript_llm.message}
</p>
) : null}
</div>
)}
/>
</div>
<Controller
control={control}
name="transcript_model"
render={({ field }) => (
<div className="space-y-2">
<Label htmlFor="transcript_model">{t.common.model} *</Label>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="transcript_model">
<SelectValue placeholder={t.models.selectModelPlaceholder} />
</SelectTrigger>
<SelectContent title={t.common.model}>
{availableTranscriptModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.transcript_model ? (
<p className="text-xs text-red-600">
{errors.transcript_model.message}
</p>
) : null}
</div>
)}
/>
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.podcastLanguage}
</h3>
<Separator className="mt-2" />
</div>
<Controller
control={control}
name="language"
render={({ field }) => (
<div className="space-y-2">
<Label htmlFor="language">{t.podcasts.language}</Label>
<Select
value={field.value ?? ''}
onValueChange={(v) => field.onChange(v || null)}
>
<SelectTrigger id="language">
<SelectValue placeholder={t.podcasts.languagePlaceholder} />
</SelectTrigger>
<SelectContent title={t.podcasts.language}>
{languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.name} ({lang.code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
/>
</div>
<div className="space-y-2">

View file

@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect } from 'react'
import { Controller, useFieldArray, useForm } from 'react-hook-form'
import type { FieldErrorsImpl } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
@ -19,19 +19,12 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Separator } from '@/components/ui/separator'
import { ModelSelector } from '@/components/common/ModelSelector'
import { TranslationKeys } from '@/lib/locales'
import { useTranslation } from '@/lib/hooks/use-translation'
@ -41,13 +34,13 @@ const speakerConfigSchema = (t: TranslationKeys) => z.object({
voice_id: z.string().min(1, t.podcasts.voiceIdRequired || 'Voice ID is required'),
backstory: z.string().min(1, t.podcasts.backstoryRequired || 'Backstory is required'),
personality: z.string().min(1, t.podcasts.personalityRequired || 'Personality is required'),
voice_model: z.string().nullable().optional(),
})
const speakerProfileSchema = (t: TranslationKeys) => z.object({
name: z.string().min(1, t.common.nameRequired || 'Name is required'),
description: z.string().optional(),
tts_provider: z.string().min(1, t.models.providerRequired || 'Provider is required'),
tts_model: z.string().min(1, t.models.modelRequired || 'Model is required'),
voice_model: z.string().min(1, t.podcasts.voiceModelRequired || 'Voice model is required'),
speakers: z
.array(speakerConfigSchema(t))
.min(1, t.podcasts.speakerCountMin || 'At least one speaker is required')
@ -60,7 +53,6 @@ interface SpeakerProfileFormDialogProps {
mode: 'create' | 'edit'
open: boolean
onOpenChange: (open: boolean) => void
modelOptions: Record<string, string[]>
initialData?: SpeakerProfile
}
@ -69,51 +61,45 @@ const EMPTY_SPEAKER = {
voice_id: '',
backstory: '',
personality: '',
voice_model: null as string | null,
}
export function SpeakerProfileFormDialog({
mode,
open,
onOpenChange,
modelOptions,
initialData,
}: SpeakerProfileFormDialogProps) {
const { t } = useTranslation()
const createProfile = useCreateSpeakerProfile()
const updateProfile = useUpdateSpeakerProfile()
const providers = useMemo(() => Object.keys(modelOptions), [modelOptions])
const getDefaults = useCallback((): SpeakerProfileFormValues => {
const firstProvider = providers[0] ?? ''
const firstModel = firstProvider ? modelOptions[firstProvider]?.[0] ?? '' : ''
if (initialData) {
return {
name: initialData.name,
description: initialData.description ?? '',
tts_provider: initialData.tts_provider,
tts_model: initialData.tts_model,
speakers: initialData.speakers?.map((speaker) => ({ ...speaker })) ?? [{ ...EMPTY_SPEAKER }],
voice_model: initialData.voice_model ?? '',
speakers: initialData.speakers?.map((speaker) => ({
...speaker,
voice_model: speaker.voice_model ?? null,
})) ?? [{ ...EMPTY_SPEAKER }],
}
}
return {
name: '',
description: '',
tts_provider: firstProvider,
tts_model: firstModel,
voice_model: '',
speakers: [{ ...EMPTY_SPEAKER }],
}
}, [initialData, modelOptions, providers])
}, [initialData])
const {
control,
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
} = useForm<SpeakerProfileFormValues>({
resolver: zodResolver(speakerProfileSchema(t)),
@ -129,13 +115,6 @@ export function SpeakerProfileFormDialog({
name: 'speakers',
})
const provider = watch('tts_provider')
const currentModel = watch('tts_model')
const availableModels = useMemo(
() => modelOptions[provider] ?? [],
[modelOptions, provider]
)
const speakersArrayError = (
errors.speakers as FieldErrorsImpl<{ root?: { message?: string } }> | undefined
)?.root?.message
@ -147,24 +126,14 @@ export function SpeakerProfileFormDialog({
reset(getDefaults())
}, [open, reset, getDefaults])
useEffect(() => {
if (!provider) {
return
}
const models = modelOptions[provider] ?? []
if (models.length === 0) {
setValue('tts_model', '')
return
}
if (!models.includes(currentModel)) {
setValue('tts_model', models[0])
}
}, [provider, currentModel, modelOptions, setValue])
const onSubmit = async (values: SpeakerProfileFormValues) => {
const payload = {
...values,
description: values.description ?? '',
speakers: values.speakers.map((s) => ({
...s,
voice_model: s.voice_model || null,
})),
}
if (mode === 'create') {
@ -180,7 +149,7 @@ export function SpeakerProfileFormDialog({
}
const isSubmitting = createProfile.isPending || updateProfile.isPending
const disableSubmit = isSubmitting || providers.length === 0
const disableSubmit = isSubmitting
const isEdit = mode === 'edit'
return (
@ -195,15 +164,6 @@ export function SpeakerProfileFormDialog({
</DialogDescription>
</DialogHeader>
{providers.length === 0 ? (
<Alert className="bg-amber-50 text-amber-900 border-amber-200">
<AlertTitle>{t.podcasts.noTtsModelsAvailable}</AlertTitle>
<AlertDescription>
{t.podcasts.noTtsModelsDesc}
</AlertDescription>
</Alert>
) : null}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
@ -214,56 +174,6 @@ export function SpeakerProfileFormDialog({
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="tts_provider">{t.models.provider} *</Label>
<Controller
control={control}
name="tts_provider"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="tts_provider">
<SelectValue placeholder={t.models.selectProviderPlaceholder} />
</SelectTrigger>
<SelectContent title={t.models.provider}>
{providers.map((option) => (
<SelectItem key={option} value={option}>
<span className="capitalize">{option}</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.tts_provider ? (
<p className="text-xs text-red-600">{errors.tts_provider.message}</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="tts_model">{t.common.model} *</Label>
<Controller
control={control}
name="tts_model"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="tts_model">
<SelectValue placeholder={t.models.selectModelPlaceholder} />
</SelectTrigger>
<SelectContent title={t.common.model}>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.tts_model ? (
<p className="text-xs text-red-600">{errors.tts_model.message}</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="description">{t.common.description}</Label>
<Textarea
@ -275,6 +185,35 @@ export function SpeakerProfileFormDialog({
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.voiceModel}
</h3>
<Separator className="mt-2" />
</div>
<Controller
control={control}
name="voice_model"
render={({ field }) => (
<div>
<ModelSelector
label={`${t.podcasts.voiceModel} *`}
modelType="text_to_speech"
value={field.value}
onChange={field.onChange}
placeholder={t.podcasts.selectVoiceModel}
/>
{errors.voice_model ? (
<p className="text-xs text-red-600 mt-1">
{errors.voice_model.message}
</p>
) : null}
</div>
)}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
@ -374,6 +313,21 @@ export function SpeakerProfileFormDialog({
</p>
) : null}
</div>
<Controller
control={control}
name={`speakers.${index}.voice_model` as const}
render={({ field: vmField }) => (
<div>
<ModelSelector
label={t.podcasts.perSpeakerTtsOverride}
modelType="text_to_speech"
value={vmField.value ?? ''}
onChange={(v) => vmField.onChange(v || null)}
placeholder={t.podcasts.useProfileDefault}
/>
</div>
)}
/>
</div>
))}

View file

@ -4,6 +4,7 @@ import {
PodcastEpisode,
EpisodeProfile,
SpeakerProfile,
Language,
PodcastGenerationRequest,
PodcastGenerationResponse,
} from '@/lib/types/podcasts'
@ -117,4 +118,9 @@ export const podcastsApi = {
)
return response.data
},
listLanguages: async () => {
const response = await apiClient.get<Language[]>('/languages')
return response.data
},
}

View file

@ -31,4 +31,5 @@ export const QUERY_KEYS = {
podcastEpisode: (episodeId: string) => ['podcasts', 'episodes', episodeId] as const,
episodeProfiles: ['podcasts', 'episode-profiles'] as const,
speakerProfiles: ['podcasts', 'speaker-profiles'] as const,
languages: ['languages'] as const,
}

View file

@ -16,6 +16,14 @@ import {
speakerUsageMap,
} from '@/lib/types/podcasts'
export function useLanguages() {
return useQuery({
queryKey: QUERY_KEYS.languages,
queryFn: podcastsApi.listLanguages,
staleTime: Infinity,
})
}
interface EpisodeStatusCounts {
total: number
running: number

View file

@ -534,10 +534,10 @@ export const enUS = {
loadingProfiles: "Loading episode profiles...",
noProfilesFound: "No episode profiles found. Create an episode profile before generating a podcast.",
listTitle: "Podcasts",
listDesc: "Keep track of generated episodes and manage reusable templates.",
listDesc: "Keep track of generated episodes and manage reusable profiles.",
chooseAView: "Choose a view",
episodesTab: "Episodes",
templatesTab: "Templates",
templatesTab: "Profiles",
overviewTitle: "Episodes overview",
overviewDesc: "Monitor podcast generation jobs and review the final artefacts.",
generateBtn: "Generate Podcast",
@ -558,10 +558,10 @@ export const enUS = {
statusCompletedDesc: "Ready to review, download, or publish.",
statusFailedTitle: "Failed Episodes",
statusFailedDesc: "Episodes that encountered issues during generation.",
templatesWorkspaceTitle: "Templates workspace",
templatesWorkspaceTitle: "Profiles workspace",
templatesWorkspaceDesc: "Build reusable episode and speaker configurations for fast podcast production.",
howTemplatesPowerTitle: "How templates power podcast generation",
howTemplatesPowerDesc: "Templates split the podcast workflow into two reusable building blocks. Mix and match them whenever you generate a new episode.",
howTemplatesPowerTitle: "How profiles power podcast generation",
howTemplatesPowerDesc: "Profiles split the podcast workflow into two reusable building blocks. Mix and match them whenever you generate a new episode.",
episodeProfilesSetFormat: "Episode profiles set the format",
episodeProfilesList1: "Outline the number of segments and how the story flows",
episodeProfilesList2: "Pick the language models used for briefing, outlining, and script writing",
@ -575,13 +575,13 @@ export const enUS = {
workflowStep2: "Build episode profiles that reference those speakers by name",
workflowStep3: "Generate podcasts by selecting the episode profile that fits the story",
workflowHint: "Episode profiles reference speaker profiles by name, so starting with speakers avoids missing voice assignments later.",
failedToLoadTemplates: "Failed to load templates data",
failedToLoadTemplates: "Failed to load profiles data",
failedToLoadTemplatesDesc: "Ensure the API is running and try again. Some sections may be incomplete.",
loadingTemplates: "Loading templates…",
loadingTemplates: "Loading profiles…",
speakerProfilesTitle: "Speaker profiles",
speakerProfilesDesc: "Configure voices and personalities for generated episodes.",
createSpeaker: "Create speaker",
noSpeakerProfiles: "No speaker profiles yet. Create one to make episode templates available.",
noSpeakerProfiles: "No speaker profiles yet. Create one to make episode profiles available.",
noDescription: "No description provided.",
usedByCount_one: "Used by 1 episode",
usedByCount_other: "Used by {count} episodes",
@ -659,12 +659,10 @@ export const enUS = {
embedded: "Embedded",
notEmbedded: "Not embedded",
noSpeakerProfilesAvailable: "No speaker profiles available",
noLanguageModelsAvailable: "No language models available",
editEpisodeProfile: "Edit Episode Profile",
createEpisodeProfile: "Create Episode Profile",
episodeProfileFormDesc: "Define how episodes should be generated and which speaker configuration they use by default.",
noSpeakerProfilesDesc: "Create a speaker profile before configuring an episode profile.",
noLanguageModelsDesc: "Add language models in the Models section to configure outline and transcript generation.",
profileName: "Profile name",
profileNamePlaceholder: "e.g., Tech discussion",
descriptionPlaceholder: "Short summary of when to use this profile",
@ -676,17 +674,13 @@ export const enUS = {
editSpeakerProfile: "Edit Speaker Profile",
createSpeakerProfile: "Create Speaker Profile",
speakerProfileFormDesc: "Configure text-to-speech settings and define up to four speakers.",
noTtsModelsAvailable: "No text-to-speech models available",
noTtsModelsDesc: "Add TTS models in the Models section before creating a speaker profile.",
speakers: "Speakers",
speakersDesc: "Configure between one and four voices for this profile.",
addSpeaker: "Add speaker",
speakerNumber: "Speaker {number}",
backstoryPlaceholder: "Short biography or context for the speaker",
personalityPlaceholder: "Describe style and tone",
outlineProviderRequired: "Outline provider is required",
outlineModelRequired: "Outline model is required",
transcriptProviderRequired: "Transcript provider is required",
transcriptModelRequired: "Transcript model is required",
defaultBriefingRequired: "Default briefing is required",
segmentsInteger: "Must be an integer",
@ -705,6 +699,20 @@ export const enUS = {
retryStartedDesc: "A new podcast generation job has been submitted.",
failedToRetry: "Failed to retry episode",
errorDetails: "Error details",
language: "Language",
languagePlaceholder: "Select a language (optional)",
podcastLanguage: "Podcast language",
selectOutlineModel: "Select outline model",
selectTranscriptModel: "Select transcript model",
voiceModel: "Voice model",
voiceModelRequired: "Voice model is required",
selectVoiceModel: "Select voice model",
perSpeakerTtsOverride: "Per-speaker TTS override (optional)",
useProfileDefault: "Use profile default",
setupRequired: "Setup required",
setupRequiredDesc:
"Some profiles don't have models configured yet. Edit them to select models before generating podcasts.",
notConfigured: "Not configured",
},
settings: {
contentProcessing: "Content Processing",
@ -818,7 +826,6 @@ export const enUS = {
embedding: "Embedding Models",
tts: "Text to Speech (TTS)",
stt: "Speech to Text (STT)",
provider: "Provider",
apiKey: "API Key",
deleteSuccess: "Model deleted successfully",
saveSuccess: "Model saved successfully",
@ -847,9 +854,6 @@ export const enUS = {
ttsModelDesc: "Used for podcast generation",
sttModelLabel: "Speech-to-Text Model",
sttModelDesc: "Used for audio transcription",
selectProviderPlaceholder: "Select a provider",
providerRequired: "Provider is required",
modelRequired: "Model is required",
embeddingChangeTitle: "Embedding Model Change",
embeddingChangeConfirm: "You are about to change your embedding model from {from} to {to}.",
rebuildRequired: "Important: Rebuild Required",

View file

@ -534,10 +534,10 @@ export const frFR = {
loadingProfiles: "Chargement des profils d'épisode...",
noProfilesFound: "Aucun profil d'épisode trouvé. Créez un profil d'épisode avant de générer un podcast.",
listTitle: "Podcasts",
listDesc: "Suivez les épisodes générés et gérez les modèles réutilisables.",
listDesc: "Suivez les épisodes générés et gérez les profils réutilisables.",
chooseAView: "Choisir une vue",
episodesTab: "Épisodes",
templatesTab: "Modèles",
templatesTab: "Profils",
overviewTitle: "Aperçu des épisodes",
overviewDesc: "Surveillez les tâches de génération de podcast et consultez les artefacts finaux.",
generateBtn: "Générer un podcast",
@ -558,10 +558,10 @@ export const frFR = {
statusCompletedDesc: "Prêts à être consultés, téléchargés ou publiés.",
statusFailedTitle: "Épisodes échoués",
statusFailedDesc: "Épisodes ayant rencontré des problèmes lors de la génération.",
templatesWorkspaceTitle: "Espace de travail des modèles",
templatesWorkspaceTitle: "Espace de travail des profils",
templatesWorkspaceDesc: "Créez des configurations d'épisodes et d'intervenants réutilisables pour une production rapide.",
howTemplatesPowerTitle: "Comment les modèles propulsent la génération",
howTemplatesPowerDesc: "Les modèles divisent le flux de travail en deux blocs réutilisables. Mélangez-les à chaque génération d'épisode.",
howTemplatesPowerTitle: "Comment les profils propulsent la génération",
howTemplatesPowerDesc: "Les profils divisent le flux de travail en deux blocs réutilisables. Mélangez-les à chaque génération d'épisode.",
episodeProfilesSetFormat: "Les profils d'épisode définissent le format",
episodeProfilesList1: "Définissez le nombre de segments et le déroulement de l'histoire",
episodeProfilesList2: "Choisissez les modèles de langue pour le briefing, le plan et l'écriture du script",
@ -575,13 +575,13 @@ export const frFR = {
workflowStep2: "Créez des profils d'épisodes qui référencent ces intervenants par leur nom",
workflowStep3: "Générez des podcasts en sélectionnant le profil d'épisode adapté",
workflowHint: "Les profils d'épisode référencent les intervenants par nom ; commencer par les voix évite les oublis d'attribution plus tard.",
failedToLoadTemplates: "Échec du chargement des modèles",
failedToLoadTemplates: "Échec du chargement des profils",
failedToLoadTemplatesDesc: "Vérifiez que l'API fonctionne et réessayez. Certaines sections peuvent être incomplètes.",
loadingTemplates: "Chargement des modèles…",
loadingTemplates: "Chargement des profils…",
speakerProfilesTitle: "Profils d'intervenants",
speakerProfilesDesc: "Configurez les voix et personnalités pour les épisodes générés.",
createSpeaker: "Créer un intervenant",
noSpeakerProfiles: "Aucun profil d'intervenant. Créez-en un pour activer les modèles d'épisodes.",
noSpeakerProfiles: "Aucun profil d'intervenant. Créez-en un pour activer les profils d'épisodes.",
noDescription: "Aucune description fournie.",
usedByCount_one: "Utilisé par 1 épisode",
usedByCount_other: "Utilisé par {count} épisodes",
@ -659,12 +659,10 @@ export const frFR = {
embedded: "Indexé",
notEmbedded: "Non indexé",
noSpeakerProfilesAvailable: "Aucun profil d'intervenant disponible",
noLanguageModelsAvailable: "Aucun modèle de langue disponible",
editEpisodeProfile: "Modifier le profil d'épisode",
createEpisodeProfile: "Créer un profil d'épisode",
episodeProfileFormDesc: "Définissez comment les épisodes doivent être générés et quelle configuration d'intervenants ils utilisent par défaut.",
noSpeakerProfilesDesc: "Créez un profil d'intervenant avant de configurer un profil d'épisode.",
noLanguageModelsDesc: "Ajoutez des modèles de langue dans la section Modèles pour configurer la génération du plan et de la transcription.",
profileName: "Nom du profil",
profileNamePlaceholder: "ex: Discussion tech",
descriptionPlaceholder: "Bref résumé de l'usage de ce profil",
@ -676,17 +674,13 @@ export const frFR = {
editSpeakerProfile: "Modifier le profil de l'intervenant",
createSpeakerProfile: "Créer un profil d'intervenant",
speakerProfileFormDesc: "Configurez les paramètres de synthèse vocale et définissez jusqu'à quatre intervenants.",
noTtsModelsAvailable: "Aucun modèle TTS disponible",
noTtsModelsDesc: "Ajoutez des modèles TTS dans la section Modèles avant de créer un profil d'intervenant.",
speakers: "Intervenants",
speakersDesc: "Configurez entre un et quatre intervenants pour ce profil.",
addSpeaker: "Ajouter un intervenant",
speakerNumber: "Intervenant {number}",
backstoryPlaceholder: "Courte biographie ou contexte de l'intervenant",
personalityPlaceholder: "Décrivez le style et le ton",
outlineProviderRequired: "Le fournisseur du plan est requis",
outlineModelRequired: "Le modèle du plan est requis",
transcriptProviderRequired: "Le fournisseur de transcription est requis",
transcriptModelRequired: "Le modèle de transcription est requis",
defaultBriefingRequired: "Le briefing par défaut est requis",
segmentsInteger: "Doit être un nombre entier",
@ -705,6 +699,19 @@ export const frFR = {
retryStartedDesc: "Un nouveau travail de génération de podcast a été soumis.",
failedToRetry: "Échec de la nouvelle tentative",
errorDetails: "Détails de l'erreur",
language: "Langue",
languagePlaceholder: "Sélectionnez une langue (optionnel)",
podcastLanguage: "Langue du podcast",
selectOutlineModel: "Sélectionnez le modèle de plan",
selectTranscriptModel: "Sélectionnez le modèle de transcription",
voiceModel: "Modèle vocal",
voiceModelRequired: "Le modèle vocal est requis",
selectVoiceModel: "Sélectionnez le modèle vocal",
perSpeakerTtsOverride: "Remplacement TTS par intervenant (optionnel)",
useProfileDefault: "Utiliser le profil par défaut",
setupRequired: "Configuration requise",
setupRequiredDesc: "Certains profils n'ont pas encore de modèles configurés. Modifiez-les pour sélectionner des modèles avant de générer des podcasts.",
notConfigured: "Non configuré",
},
settings: {
contentProcessing: "Traitement du contenu",
@ -818,7 +825,6 @@ export const frFR = {
embedding: "Modèles d'Embedding",
tts: "Synthèse vocale (TTS)",
stt: "Transcription vocale (STT)",
provider: "Fournisseur",
apiKey: "Clé API",
deleteSuccess: "Modèle supprimé avec succès",
saveSuccess: "Modèle enregistré avec succès",
@ -847,9 +853,6 @@ export const frFR = {
ttsModelDesc: "Utilisé pour la génération de podcasts",
sttModelLabel: "Modèle de Transcription Vocale (STT)",
sttModelDesc: "Utilisé pour la transcription audio",
selectProviderPlaceholder: "Sélectionnez un fournisseur",
providerRequired: "Le fournisseur est requis",
modelRequired: "Le modèle est requis",
embeddingChangeTitle: "Changement de modèle d'embedding",
embeddingChangeConfirm: "Vous êtes sur le point de changer votre modèle d'embedding de {from} à {to}.",
rebuildRequired: "Important : Reconstruction requise",

View file

@ -534,10 +534,10 @@ export const itIT = {
loadingProfiles: "Caricamento profili episodio...",
noProfilesFound: "Nessun profilo episodio trovato. Crea un profilo episodio prima di generare un podcast.",
listTitle: "Podcast",
listDesc: "Tieni traccia degli episodi generati e gestisci i modelli riutilizzabili.",
listDesc: "Tieni traccia degli episodi generati e gestisci i profili riutilizzabili.",
chooseAView: "Scegli una vista",
episodesTab: "Episodi",
templatesTab: "Modelli",
templatesTab: "Profili",
overviewTitle: "Panoramica episodi",
overviewDesc: "Monitora i lavori di generazione podcast e rivedi gli artefatti finali.",
generateBtn: "Genera Podcast",
@ -558,10 +558,10 @@ export const itIT = {
statusCompletedDesc: "Pronti per revisione, download o pubblicazione.",
statusFailedTitle: "Episodi falliti",
statusFailedDesc: "Episodi che hanno riscontrato problemi durante la generazione.",
templatesWorkspaceTitle: "Area di lavoro modelli",
templatesWorkspaceTitle: "Area di lavoro profili",
templatesWorkspaceDesc: "Costruisci configurazioni riutilizzabili per episodi e speaker per una produzione podcast rapida.",
howTemplatesPowerTitle: "Come i modelli potenziano la generazione podcast",
howTemplatesPowerDesc: "I modelli dividono il flusso di lavoro podcast in due blocchi riutilizzabili. Combinali quando generi un nuovo episodio.",
howTemplatesPowerTitle: "Come i profili potenziano la generazione podcast",
howTemplatesPowerDesc: "I profili dividono il flusso di lavoro podcast in due blocchi riutilizzabili. Combinali quando generi un nuovo episodio.",
episodeProfilesSetFormat: "I profili episodio impostano il formato",
episodeProfilesList1: "Delinea il numero di segmenti e come scorre la storia",
episodeProfilesList2: "Scegli i modelli linguistici usati per briefing, outline e scrittura script",
@ -575,13 +575,13 @@ export const itIT = {
workflowStep2: "Costruisci profili episodio che riferiscono quegli speaker per nome",
workflowStep3: "Genera podcast selezionando il profilo episodio adatto alla storia",
workflowHint: "I profili episodio riferiscono i profili speaker per nome, quindi iniziare dagli speaker evita assegnazioni vocali mancanti.",
failedToLoadTemplates: "Impossibile caricare i dati dei modelli",
failedToLoadTemplates: "Impossibile caricare i dati dei profili",
failedToLoadTemplatesDesc: "Assicurati che l'API sia in esecuzione e riprova. Alcune sezioni potrebbero essere incomplete.",
loadingTemplates: "Caricamento modelli…",
loadingTemplates: "Caricamento profili…",
speakerProfilesTitle: "Profili speaker",
speakerProfilesDesc: "Configura voci e personalità per gli episodi generati.",
createSpeaker: "Crea speaker",
noSpeakerProfiles: "Ancora nessun profilo speaker. Creane uno per rendere disponibili i modelli episodio.",
noSpeakerProfiles: "Ancora nessun profilo speaker. Creane uno per rendere disponibili i profili episodio.",
noDescription: "Nessuna descrizione fornita.",
usedByCount_one: "Usato da 1 episodio",
usedByCount_other: "Usato da {count} episodi",
@ -659,12 +659,10 @@ export const itIT = {
embedded: "Indicizzato",
notEmbedded: "Non indicizzato",
noSpeakerProfilesAvailable: "Nessun profilo speaker disponibile",
noLanguageModelsAvailable: "Nessun modello linguistico disponibile",
editEpisodeProfile: "Modifica profilo episodio",
createEpisodeProfile: "Crea profilo episodio",
episodeProfileFormDesc: "Definisci come devono essere generati gli episodi e quale configurazione speaker usare di default.",
noSpeakerProfilesDesc: "Crea un profilo speaker prima di configurare un profilo episodio.",
noLanguageModelsDesc: "Aggiungi modelli linguistici nella sezione modelli per configurare la generazione di outline e trascrizione.",
profileName: "Nome profilo",
profileNamePlaceholder: "es., Discussione tech",
descriptionPlaceholder: "Breve riepilogo di quando usare questo profilo",
@ -676,17 +674,13 @@ export const itIT = {
editSpeakerProfile: "Modifica profilo speaker",
createSpeakerProfile: "Crea profilo speaker",
speakerProfileFormDesc: "Configura le impostazioni text-to-speech e definisci fino a quattro speaker.",
noTtsModelsAvailable: "Nessun modello text-to-speech disponibile",
noTtsModelsDesc: "Aggiungi modelli TTS nella sezione modelli prima di creare un profilo speaker.",
speakers: "Speaker",
speakersDesc: "Configura da una a quattro voci per questo profilo.",
addSpeaker: "Aggiungi speaker",
speakerNumber: "Speaker {number}",
backstoryPlaceholder: "Breve biografia o contesto per lo speaker",
personalityPlaceholder: "Descrivi stile e tono",
outlineProviderRequired: "Il provider outline è obbligatorio",
outlineModelRequired: "Il modello outline è obbligatorio",
transcriptProviderRequired: "Il provider trascrizione è obbligatorio",
transcriptModelRequired: "Il modello trascrizione è obbligatorio",
defaultBriefingRequired: "Il briefing predefinito è obbligatorio",
segmentsInteger: "Deve essere un numero intero",
@ -705,6 +699,19 @@ export const itIT = {
retryStartedDesc: "Un nuovo lavoro di generazione podcast è stato inviato.",
failedToRetry: "Impossibile riprovare",
errorDetails: "Dettagli errore",
language: "Lingua",
languagePlaceholder: "Seleziona una lingua (opzionale)",
podcastLanguage: "Lingua del podcast",
selectOutlineModel: "Seleziona modello outline",
selectTranscriptModel: "Seleziona modello trascrizione",
voiceModel: "Modello vocale",
voiceModelRequired: "Il modello vocale è obbligatorio",
selectVoiceModel: "Seleziona modello vocale",
perSpeakerTtsOverride: "Override TTS per speaker (opzionale)",
useProfileDefault: "Usa predefinito del profilo",
setupRequired: "Configurazione necessaria",
setupRequiredDesc: "Alcuni profili non hanno ancora modelli configurati. Modificali per selezionare i modelli prima di generare podcast.",
notConfigured: "Non configurato",
},
settings: {
contentProcessing: "Elaborazione contenuti",
@ -818,7 +825,6 @@ export const itIT = {
embedding: "Modelli di embedding",
tts: "Text to Speech (TTS)",
stt: "Speech to Text (STT)",
provider: "Provider",
apiKey: "Chiave API",
deleteSuccess: "Modello eliminato con successo",
saveSuccess: "Modello salvato con successo",
@ -847,9 +853,6 @@ export const itIT = {
ttsModelDesc: "Usato per la generazione podcast",
sttModelLabel: "Modello Speech-to-Text",
sttModelDesc: "Usato per la trascrizione audio",
selectProviderPlaceholder: "Seleziona un provider",
providerRequired: "Il provider è obbligatorio",
modelRequired: "Il modello è obbligatorio",
embeddingChangeTitle: "Cambio modello di embedding",
embeddingChangeConfirm: "Stai per cambiare il modello di embedding da {from} a {to}.",
rebuildRequired: "Importante: ricostruzione richiesta",

View file

@ -534,10 +534,10 @@ export const jaJP = {
loadingProfiles: "エピソードプロファイルを読み込み中...",
noProfilesFound: "エピソードプロファイルが見つかりません。ポッドキャストを生成する前にエピソードプロファイルを作成してください。",
listTitle: "ポッドキャスト",
listDesc: "生成されたエピソードを追跡し、再利用可能なテンプレートを管理します。",
listDesc: "生成されたエピソードを追跡し、再利用可能なプロファイルを管理します。",
chooseAView: "表示を選択",
episodesTab: "エピソード",
templatesTab: "テンプレート",
templatesTab: "プロファイル",
overviewTitle: "エピソード概要",
overviewDesc: "ポッドキャスト生成ジョブを監視し、最終成果物を確認します。",
generateBtn: "ポッドキャストを生成",
@ -558,10 +558,10 @@ export const jaJP = {
statusCompletedDesc: "確認、ダウンロード、公開の準備が整っています。",
statusFailedTitle: "失敗したエピソード",
statusFailedDesc: "生成中に問題が発生したエピソード。",
templatesWorkspaceTitle: "テンプレートワークスペース",
templatesWorkspaceTitle: "プロファイルワークスペース",
templatesWorkspaceDesc: "高速なポッドキャスト制作のための再利用可能なエピソードおよびスピーカー設定を構築します。",
howTemplatesPowerTitle: "テンプレートがポッドキャスト生成を強化する仕組み",
howTemplatesPowerDesc: "テンプレートはポッドキャストワークフローを2つの再利用可能なビルディングブロックに分割します。新しいエピソードを生成する際に自由に組み合わせてください。",
howTemplatesPowerTitle: "プロファイルがポッドキャスト生成を強化する仕組み",
howTemplatesPowerDesc: "プロファイルはポッドキャストワークフローを2つの再利用可能なビルディングブロックに分割します。新しいエピソードを生成する際に自由に組み合わせてください。",
episodeProfilesSetFormat: "エピソードプロファイルがフォーマットを設定",
episodeProfilesList1: "セグメント数とストーリーの流れを概説",
episodeProfilesList2: "ブリーフィング、アウトライン、スクリプト作成に使用する言語モデルを選択",
@ -575,13 +575,13 @@ export const jaJP = {
workflowStep2: "それらのスピーカーを名前で参照するエピソードプロファイルを構築",
workflowStep3: "ストーリーに合ったエピソードプロファイルを選択してポッドキャストを生成",
workflowHint: "エピソードプロファイルはスピーカープロファイルを名前で参照するため、スピーカーから始めることで後の声の割り当て漏れを防ぎます。",
failedToLoadTemplates: "テンプレートデータの読み込みに失敗しました",
failedToLoadTemplates: "プロファイルデータの読み込みに失敗しました",
failedToLoadTemplatesDesc: "APIが実行中か確認して再試行してください。一部のセクションが不完全な場合があります。",
loadingTemplates: "テンプレートを読み込み中...",
loadingTemplates: "プロファイルを読み込み中...",
speakerProfilesTitle: "スピーカープロファイル",
speakerProfilesDesc: "生成されるエピソードの声と個性を設定します。",
createSpeaker: "スピーカーを作成",
noSpeakerProfiles: "スピーカープロファイルがまだありません。エピソードテンプレートを利用するには作成してください。",
noSpeakerProfiles: "スピーカープロファイルがまだありません。エピソードプロファイルを利用するには作成してください。",
noDescription: "説明なし。",
usedByCount_one: "1つのエピソードで使用",
usedByCount_other: "{count}個のエピソードで使用",
@ -659,12 +659,10 @@ export const jaJP = {
embedded: "Embedding済み",
notEmbedded: "未Embedding",
noSpeakerProfilesAvailable: "スピーカープロファイルがありません",
noLanguageModelsAvailable: "言語モデルがありません",
editEpisodeProfile: "エピソードプロファイルを編集",
createEpisodeProfile: "エピソードプロファイルを作成",
episodeProfileFormDesc: "エピソードの生成方法とデフォルトで使用するスピーカー設定を定義します。",
noSpeakerProfilesDesc: "エピソードプロファイルを設定する前にスピーカープロファイルを作成してください。",
noLanguageModelsDesc: "アウトラインとトランスクリプト生成を設定するにはモデルセクションで言語モデルを追加してください。",
profileName: "プロファイル名",
profileNamePlaceholder: "例:テックディスカッション",
descriptionPlaceholder: "このプロファイルを使用する場面の簡単な説明",
@ -676,17 +674,13 @@ export const jaJP = {
editSpeakerProfile: "スピーカープロファイルを編集",
createSpeakerProfile: "スピーカープロファイルを作成",
speakerProfileFormDesc: "音声合成設定を構成し、最大4人のスピーカーを定義します。",
noTtsModelsAvailable: "音声合成モデルがありません",
noTtsModelsDesc: "スピーカープロファイルを作成する前にモデルセクションでTTSモデルを追加してください。",
speakers: "スピーカー",
speakersDesc: "このプロファイルに1〜4人の声を設定します。",
addSpeaker: "スピーカーを追加",
speakerNumber: "スピーカー{number}",
backstoryPlaceholder: "スピーカーの短い経歴やコンテキスト",
personalityPlaceholder: "スタイルとトーンを説明",
outlineProviderRequired: "アウトラインプロバイダーは必須です",
outlineModelRequired: "アウトラインモデルは必須です",
transcriptProviderRequired: "トランスクリプトプロバイダーは必須です",
transcriptModelRequired: "トランスクリプトモデルは必須です",
defaultBriefingRequired: "デフォルトブリーフィングは必須です",
segmentsInteger: "整数である必要があります",
@ -705,6 +699,19 @@ export const jaJP = {
retryStartedDesc: "新しいポッドキャスト生成ジョブが送信されました。",
failedToRetry: "再試行に失敗しました",
errorDetails: "エラー詳細",
language: "言語",
languagePlaceholder: "言語を選択(任意)",
podcastLanguage: "ポッドキャストの言語",
selectOutlineModel: "アウトラインモデルを選択",
selectTranscriptModel: "トランスクリプトモデルを選択",
voiceModel: "音声モデル",
voiceModelRequired: "音声モデルは必須です",
selectVoiceModel: "音声モデルを選択",
perSpeakerTtsOverride: "スピーカーごとのTTSオーバーライド任意",
useProfileDefault: "プロファイルのデフォルトを使用",
setupRequired: "設定が必要",
setupRequiredDesc: "一部のプロファイルにモデルが設定されていません。ポッドキャストを生成する前に、編集してモデルを選択してください。",
notConfigured: "未設定",
},
settings: {
contentProcessing: "コンテンツ処理",
@ -818,7 +825,6 @@ export const jaJP = {
embedding: "Embeddingモデル",
tts: "音声合成TTS",
stt: "音声認識STT",
provider: "プロバイダー",
apiKey: "APIキー",
deleteSuccess: "モデルを削除しました",
saveSuccess: "モデルを保存しました",
@ -847,9 +853,6 @@ export const jaJP = {
ttsModelDesc: "ポッドキャスト生成に使用",
sttModelLabel: "音声認識モデル",
sttModelDesc: "音声の書き起こしに使用",
selectProviderPlaceholder: "プロバイダーを選択",
providerRequired: "プロバイダーは必須です",
modelRequired: "モデルは必須です",
embeddingChangeTitle: "Embeddingモデルの変更",
embeddingChangeConfirm: "Embeddingモデルを{from}から{to}に変更しようとしています。",
rebuildRequired: "重要:再構築が必要",

View file

@ -534,10 +534,10 @@ export const ptBR = {
loadingProfiles: "Carregando perfis de episódio...",
noProfilesFound: "Nenhum perfil de episódio encontrado. Crie um perfil de episódio antes de gerar um podcast.",
listTitle: "Podcasts",
listDesc: "Acompanhe episódios gerados e gerencie templates reutilizáveis.",
listDesc: "Acompanhe episódios gerados e gerencie perfis reutilizáveis.",
chooseAView: "Escolha uma visualização",
episodesTab: "Episódios",
templatesTab: "Templates",
templatesTab: "Perfis",
overviewTitle: "Visão geral dos episódios",
overviewDesc: "Monitore trabalhos de geração de podcast e revise os artefatos finais.",
generateBtn: "Gerar Podcast",
@ -558,10 +558,10 @@ export const ptBR = {
statusCompletedDesc: "Prontos para revisar, baixar ou publicar.",
statusFailedTitle: "Episódios com Falha",
statusFailedDesc: "Episódios que encontraram problemas durante a geração.",
templatesWorkspaceTitle: "Área de trabalho de templates",
templatesWorkspaceTitle: "Área de trabalho de perfis",
templatesWorkspaceDesc: "Construa configurações de episódio e locutor reutilizáveis para produção rápida de podcasts.",
howTemplatesPowerTitle: "Como os templates potencializam a geração de podcasts",
howTemplatesPowerDesc: "Templates dividem o fluxo de trabalho do podcast em dois blocos de construção reutilizáveis. Misture e combine-os sempre que gerar um novo episódio.",
howTemplatesPowerTitle: "Como os perfis potencializam a geração de podcasts",
howTemplatesPowerDesc: "Os perfis dividem o fluxo de trabalho do podcast em dois blocos de construção reutilizáveis. Misture e combine-os sempre que gerar um novo episódio.",
episodeProfilesSetFormat: "Perfis de episódio definem o formato",
episodeProfilesList1: "Delineiam o número de segmentos e como a história flui",
episodeProfilesList2: "Escolhem os modelos de linguagem usados para briefing, outline e escrita do roteiro",
@ -575,13 +575,13 @@ export const ptBR = {
workflowStep2: "Construa perfis de episódio que referenciam esses locutores pelo nome",
workflowStep3: "Gere podcasts selecionando o perfil de episódio que se encaixa na história",
workflowHint: "Perfis de episódio referenciam perfis de locutor pelo nome, então começar com locutores evita atribuições de voz faltantes depois.",
failedToLoadTemplates: "Falha ao carregar dados de templates",
failedToLoadTemplates: "Falha ao carregar dados de perfis",
failedToLoadTemplatesDesc: "Certifique-se de que a API está rodando e tente novamente. Algumas seções podem estar incompletas.",
loadingTemplates: "Carregando templates…",
loadingTemplates: "Carregando perfis…",
speakerProfilesTitle: "Perfis de locutor",
speakerProfilesDesc: "Configure vozes e personalidades para episódios gerados.",
createSpeaker: "Criar locutor",
noSpeakerProfiles: "Nenhum perfil de locutor ainda. Crie um para disponibilizar templates de episódio.",
noSpeakerProfiles: "Nenhum perfil de locutor ainda. Crie um para disponibilizar perfis de episódio.",
noDescription: "Nenhuma descrição fornecida.",
usedByCount_one: "Usado por 1 episódio",
usedByCount_other: "Usado por {count} episódios",
@ -659,12 +659,10 @@ export const ptBR = {
embedded: "Incorporado",
notEmbedded: "Não incorporado",
noSpeakerProfilesAvailable: "Nenhum perfil de locutor disponível",
noLanguageModelsAvailable: "Nenhum modelo de linguagem disponível",
editEpisodeProfile: "Editar Perfil de Episódio",
createEpisodeProfile: "Criar Perfil de Episódio",
episodeProfileFormDesc: "Defina como os episódios devem ser gerados e qual configuração de locutor usar por padrão.",
noSpeakerProfilesDesc: "Crie um perfil de locutor antes de configurar um perfil de episódio.",
noLanguageModelsDesc: "Adicione modelos de linguagem na seção Modelos para configurar a geração de outline e transcrição.",
profileName: "Nome do perfil",
profileNamePlaceholder: "ex., Discussão tech",
descriptionPlaceholder: "Breve resumo de quando usar este perfil",
@ -676,17 +674,13 @@ export const ptBR = {
editSpeakerProfile: "Editar Perfil de Locutor",
createSpeakerProfile: "Criar Perfil de Locutor",
speakerProfileFormDesc: "Configure as configurações de text-to-speech e defina até quatro locutores.",
noTtsModelsAvailable: "Nenhum modelo de text-to-speech disponível",
noTtsModelsDesc: "Adicione modelos TTS na seção Modelos antes de criar um perfil de locutor.",
speakers: "Locutores",
speakersDesc: "Configure entre uma e quatro vozes para este perfil.",
addSpeaker: "Adicionar locutor",
speakerNumber: "Locutor {number}",
backstoryPlaceholder: "Breve biografia ou contexto para o locutor",
personalityPlaceholder: "Descreva estilo e tom",
outlineProviderRequired: "Provedor de outline é obrigatório",
outlineModelRequired: "Modelo de outline é obrigatório",
transcriptProviderRequired: "Provedor de transcrição é obrigatório",
transcriptModelRequired: "Modelo de transcrição é obrigatório",
defaultBriefingRequired: "Briefing padrão é obrigatório",
segmentsInteger: "Deve ser um número inteiro",
@ -705,6 +699,19 @@ export const ptBR = {
retryStartedDesc: "Um novo trabalho de geração de podcast foi enviado.",
failedToRetry: "Falha ao tentar novamente",
errorDetails: "Detalhes do erro",
language: "Idioma",
languagePlaceholder: "Selecione um idioma (opcional)",
podcastLanguage: "Idioma do podcast",
selectOutlineModel: "Selecione o modelo de roteiro",
selectTranscriptModel: "Selecione o modelo de transcrição",
voiceModel: "Modelo de voz",
voiceModelRequired: "Modelo de voz é obrigatório",
selectVoiceModel: "Selecione o modelo de voz",
perSpeakerTtsOverride: "Override de TTS por speaker (opcional)",
useProfileDefault: "Usar padrão do perfil",
setupRequired: "Configuração necessária",
setupRequiredDesc: "Alguns perfis ainda não têm modelos configurados. Edite-os para selecionar modelos antes de gerar podcasts.",
notConfigured: "Não configurado",
},
settings: {
contentProcessing: "Processamento de Conteúdo",
@ -818,7 +825,6 @@ export const ptBR = {
embedding: "Modelos de Embedding",
tts: "Text to Speech (TTS)",
stt: "Speech to Text (STT)",
provider: "Provedor",
apiKey: "Chave da API",
deleteSuccess: "Modelo excluído com sucesso",
saveSuccess: "Modelo salvo com sucesso",
@ -847,9 +853,6 @@ export const ptBR = {
ttsModelDesc: "Usado para geração de podcast",
sttModelLabel: "Modelo Speech-to-Text",
sttModelDesc: "Usado para transcrição de áudio",
selectProviderPlaceholder: "Selecione um provedor",
providerRequired: "Provedor é obrigatório",
modelRequired: "Modelo é obrigatório",
embeddingChangeTitle: "Alteração de Modelo de Embedding",
embeddingChangeConfirm: "Você está prestes a alterar seu modelo de embedding de {from} para {to}.",
rebuildRequired: "Importante: Reconstrução Necessária",

View file

@ -534,10 +534,10 @@ export const ruRU = {
loadingProfiles: "Загрузка профилей эпизодов...",
noProfilesFound: "Профили эпизодов не найдены. Создайте профиль перед генерацией подкаста.",
listTitle: "Подкасты",
listDesc: "Отслеживайте сгенерированные эпизоды и управляйте шаблонами.",
listDesc: "Отслеживайте сгенерированные эпизоды и управляйте профилями.",
chooseAView: "Выберите представление",
episodesTab: "Эпизоды",
templatesTab: "Шаблоны",
templatesTab: "Профили",
overviewTitle: "Обзор эпизодов",
overviewDesc: "Отслеживайте задачи генерации подкастов и просматривайте готовые материалы.",
generateBtn: "Сгенерировать подкаст",
@ -558,10 +558,10 @@ export const ruRU = {
statusCompletedDesc: "Готовы к просмотру, загрузке или публикации.",
statusFailedTitle: "Неудачные эпизоды",
statusFailedDesc: "Эпизоды с ошибками во время генерации.",
templatesWorkspaceTitle: "Рабочее пространство шаблонов",
templatesWorkspaceTitle: "Рабочее пространство профилей",
templatesWorkspaceDesc: "Создавайте переиспользуемые конфигурации эпизодов и говорящих для быстрого производства подкастов.",
howTemplatesPowerTitle: "Как шаблоны ускоряют генерацию подкастов",
howTemplatesPowerDesc: "Шаблоны разделяют процесс на два переиспользуемых компонента. Комбинируйте их при генерации нового эпизода.",
howTemplatesPowerTitle: "Как профили ускоряют генерацию подкастов",
howTemplatesPowerDesc: "Профили разделяют процесс на два переиспользуемых компонента. Комбинируйте их при генерации нового эпизода.",
episodeProfilesSetFormat: "Профили эпизодов задают формат",
episodeProfilesList1: "Определяют количество сегментов и структуру повествования",
episodeProfilesList2: "Выбирают языковые модели для брифинга, планирования и написания сценария",
@ -575,13 +575,13 @@ export const ruRU = {
workflowStep2: "Создайте профили эпизодов со ссылками на говорящих по имени",
workflowStep3: "Генерируйте подкасты, выбирая подходящий профиль эпизода",
workflowHint: "Профили эпизодов ссылаются на профили говорящих по имени, поэтому начинайте с говорящих, чтобы избежать пропущенных назначений голосов.",
failedToLoadTemplates: "Не удалось загрузить данные шаблонов",
failedToLoadTemplates: "Не удалось загрузить данные профилей",
failedToLoadTemplatesDesc: "Убедитесь, что API работает, и попробуйте снова. Некоторые разделы могут быть неполными.",
loadingTemplates: "Загрузка шаблонов…",
loadingTemplates: "Загрузка профилей…",
speakerProfilesTitle: "Профили говорящих",
speakerProfilesDesc: "Настройте голоса и личности для генерируемых эпизодов.",
createSpeaker: "Создать говорящего",
noSpeakerProfiles: "Пока нет профилей говорящих. Создайте один, чтобы шаблоны эпизодов стали доступны.",
noSpeakerProfiles: "Пока нет профилей говорящих. Создайте один, чтобы профили эпизодов стали доступны.",
noDescription: "Описание не указано.",
usedByCount_one: "Используется в 1 эпизоде",
usedByCount_other: "Используется в {count} эпизодах",
@ -659,12 +659,10 @@ export const ruRU = {
embedded: "С эмбеддингом",
notEmbedded: "Без эмбеддинга",
noSpeakerProfilesAvailable: "Нет доступных профилей говорящих",
noLanguageModelsAvailable: "Нет доступных языковых моделей",
editEpisodeProfile: "Редактировать профиль эпизода",
createEpisodeProfile: "Создать профиль эпизода",
episodeProfileFormDesc: "Определите, как должны генерироваться эпизоды и какую конфигурацию говорящих использовать по умолчанию.",
noSpeakerProfilesDesc: "Создайте профиль говорящего перед настройкой профиля эпизода.",
noLanguageModelsDesc: "Добавьте языковые модели в разделе «Модели» для настройки генерации плана и транскрипта.",
profileName: "Название профиля",
profileNamePlaceholder: "напр., Техническая дискуссия",
descriptionPlaceholder: "Краткое описание, когда использовать этот профиль",
@ -676,17 +674,13 @@ export const ruRU = {
editSpeakerProfile: "Редактировать профиль говорящего",
createSpeakerProfile: "Создать профиль говорящего",
speakerProfileFormDesc: "Настройте параметры озвучивания и определите до четырёх говорящих.",
noTtsModelsAvailable: "Нет доступных моделей озвучивания",
noTtsModelsDesc: "Добавьте TTS-модели в разделе «Модели» перед созданием профиля говорящего.",
speakers: "Говорящие",
speakersDesc: "Настройте от одного до четырёх голосов для этого профиля.",
addSpeaker: "Добавить говорящего",
speakerNumber: "Говорящий {number}",
backstoryPlaceholder: "Краткая биография или контекст для говорящего",
personalityPlaceholder: "Опишите стиль и тон",
outlineProviderRequired: "Требуется провайдер плана",
outlineModelRequired: "Требуется модель плана",
transcriptProviderRequired: "Требуется провайдер транскрипта",
transcriptModelRequired: "Требуется модель транскрипта",
defaultBriefingRequired: "Требуется брифинг по умолчанию",
segmentsInteger: "Должно быть целым числом",
@ -705,6 +699,19 @@ export const ruRU = {
retryStartedDesc: "Новое задание на генерацию подкаста отправлено.",
failedToRetry: "Не удалось повторить",
errorDetails: "Подробности ошибки",
language: "Язык",
languagePlaceholder: "Выберите язык (необязательно)",
podcastLanguage: "Язык подкаста",
selectOutlineModel: "Выберите модель плана",
selectTranscriptModel: "Выберите модель транскрипта",
voiceModel: "Голосовая модель",
voiceModelRequired: "Требуется голосовая модель",
selectVoiceModel: "Выберите голосовую модель",
perSpeakerTtsOverride: "Переопределение TTS для говорящего (необязательно)",
useProfileDefault: "Использовать настройки профиля",
setupRequired: "Требуется настройка",
setupRequiredDesc: "Некоторые профили ещё не имеют настроенных моделей. Отредактируйте их для выбора моделей перед генерацией подкастов.",
notConfigured: "Не настроено",
},
settings: {
contentProcessing: "Обработка контента",
@ -818,7 +825,6 @@ export const ruRU = {
embedding: "Модели эмбеддинга",
tts: "Озвучивание (TTS)",
stt: "Распознавание речи (STT)",
provider: "Провайдер",
apiKey: "API-ключ",
deleteSuccess: "Модель успешно удалена",
saveSuccess: "Модель успешно сохранена",
@ -847,9 +853,6 @@ export const ruRU = {
ttsModelDesc: "Используется для генерации подкастов",
sttModelLabel: "Модель распознавания речи",
sttModelDesc: "Используется для транскрибации аудио",
selectProviderPlaceholder: "Выберите провайдера",
providerRequired: "Требуется провайдер",
modelRequired: "Требуется модель",
embeddingChangeTitle: "Изменение модели эмбеддинга",
embeddingChangeConfirm: "Вы собираетесь изменить модель эмбеддинга с {from} на {to}.",
rebuildRequired: "Важно: Требуется пересоздание",

View file

@ -534,10 +534,10 @@ export const zhCN = {
loadingProfiles: "正在加载单集简介...",
noProfilesFound: "未找到单集简介。在生成播客之前,请先创建一个单集简介。",
listTitle: "播客",
listDesc: "跟踪生成的单集并管理可重复使用的模板。",
listDesc: "跟踪生成的单集并管理可重复使用的简介。",
chooseAView: "选择视图",
episodesTab: "单集",
templatesTab: "模板",
templatesTab: "配置",
overviewTitle: "单集概览",
overviewDesc: "监控播客生成任务并查看最终成品。",
generateBtn: "生成播客",
@ -558,10 +558,10 @@ export const zhCN = {
statusCompletedDesc: "可以查看、下载或发布。",
statusFailedTitle: "失败单集",
statusFailedDesc: "在生成过程中遇到问题的单集。",
templatesWorkspaceTitle: "模板工作区",
templatesWorkspaceTitle: "简介工作区",
templatesWorkspaceDesc: "构建可重复使用的单集和发言人配置,以实现快速的播客制作。",
howTemplatesPowerTitle: "模板如何驱动播客生成",
howTemplatesPowerDesc: "模板将播客工作流拆分为两个可重复使用的构建块。在生成新单集时可以随时混合搭配它们。",
howTemplatesPowerTitle: "简介如何驱动播客生成",
howTemplatesPowerDesc: "简介将播客工作流拆分为两个可重复使用的构建块。在生成新单集时可以随时混合搭配它们。",
episodeProfilesSetFormat: "单集简介设定格式",
episodeProfilesList1: "概述分段数量及故事流向",
episodeProfilesList2: "选择用于简报、大纲和脚本编写的语言模型",
@ -575,13 +575,13 @@ export const zhCN = {
workflowStep2: "构建按名称引用这些发言人的单集简介",
workflowStep3: "通过选择适合故事的单集简介来生成播客",
workflowHint: "单集简介按名称引用发言人简介,因此从发言人开始可以避免以后缺少声音指派。",
failedToLoadTemplates: "加载模板数据失败",
failedToLoadTemplates: "加载简介数据失败",
failedToLoadTemplatesDesc: "请确保 API 正在运行并重试。某些部分可能不完整。",
loadingTemplates: "正在加载模板...",
loadingTemplates: "正在加载简介...",
speakerProfilesTitle: "发言人简介",
speakerProfilesDesc: "为生成的单集配置声音和性格。",
createSpeaker: "创建发言人",
noSpeakerProfiles: "暂无发言人简介。创建一个以使单集模板可用。",
noSpeakerProfiles: "暂无发言人简介。创建一个以使单集简介可用。",
noDescription: "未提供描述。",
usedByCount_one: "被 1 个单集使用",
usedByCount_other: "被 {count} 个单集使用",
@ -659,12 +659,10 @@ export const zhCN = {
embedded: "已嵌入",
notEmbedded: "未嵌入",
noSpeakerProfilesAvailable: "没有可用的发言人简介",
noLanguageModelsAvailable: "没有可用的语言模型",
editEpisodeProfile: "编辑单集简介",
createEpisodeProfile: "创建单集简介",
episodeProfileFormDesc: "定义单集生成的规则及默认使用的发言人配置。",
noSpeakerProfilesDesc: "在配置单集简介之前,请先创建一个发言人简介。",
noLanguageModelsDesc: "在“模型”部分添加语言模型,以配置大纲和文稿生成。",
profileName: "简介名称",
profileNamePlaceholder: "例如:技术讨论",
descriptionPlaceholder: "简要说明何时使用此简介",
@ -676,17 +674,13 @@ export const zhCN = {
editSpeakerProfile: "编辑发言人简介",
createSpeakerProfile: "创建发言人简介",
speakerProfileFormDesc: "配置文字转语音设置并定义最多四名发言人。",
noTtsModelsAvailable: "没有可用的文字转语音模型",
noTtsModelsDesc: "在创建发言人简介之前,请先在“模型”部分添加 TTS 模型。",
speakers: "发言人",
speakersDesc: "为此简介配置一到四种声音。",
addSpeaker: "添加发言人",
speakerNumber: "发言人 {number}",
backstoryPlaceholder: "发言人的简要传记或背景信息",
personalityPlaceholder: "描述风格和语气",
outlineProviderRequired: "必须选择大纲提供商",
outlineModelRequired: "必须选择大纲模型",
transcriptProviderRequired: "必须选择文稿提供商",
transcriptModelRequired: "必须选择文稿模型",
defaultBriefingRequired: "必须填写默认简介",
segmentsInteger: "必须是整数",
@ -705,6 +699,19 @@ export const zhCN = {
retryStartedDesc: "已提交新的播客生成任务。",
failedToRetry: "重试失败",
errorDetails: "错误详情",
language: "语言",
languagePlaceholder: "选择语言(可选)",
podcastLanguage: "播客语言",
selectOutlineModel: "选择大纲模型",
selectTranscriptModel: "选择转录模型",
voiceModel: "语音模型",
voiceModelRequired: "语音模型为必填项",
selectVoiceModel: "选择语音模型",
perSpeakerTtsOverride: "每个发言人的TTS覆盖可选",
useProfileDefault: "使用配置默认值",
setupRequired: "需要配置",
setupRequiredDesc: "部分配置尚未设置模型。请编辑它们以在生成播客之前选择模型。",
notConfigured: "未配置",
},
settings: {
contentProcessing: "内容处理",
@ -818,7 +825,6 @@ export const zhCN = {
embedding: "嵌入模型",
tts: "文字转语音",
stt: "语音转文字",
provider: "服务商",
apiKey: "API 密钥",
deleteSuccess: "模型删除成功",
saveSuccess: "模型保存成功",
@ -847,9 +853,6 @@ export const zhCN = {
ttsModelDesc: "用于生成播客",
sttModelLabel: "语音转文字模型",
sttModelDesc: "用于音频转录",
selectProviderPlaceholder: "选择服务商",
providerRequired: "服务商是必填项",
modelRequired: "模型是必填项",
embeddingChangeTitle: "嵌入模型变更",
embeddingChangeConfirm: "您即将将嵌入模型从 {from} 更改为 {to}。",
rebuildRequired: "重要提示:需要重建索引",

View file

@ -534,10 +534,10 @@ export const zhTW = {
loadingProfiles: "正在載入單集簡介...",
noProfilesFound: "未找到單集簡介。在生成播客之前,請先建立一個單集簡介。",
listTitle: "播客",
listDesc: "跟踪生成的單集並管理可重複使用的模板。",
listDesc: "跟踪生成的單集並管理可重複使用的簡介。",
chooseAView: "選擇視圖",
episodesTab: "單集",
templatesTab: "模板",
templatesTab: "設定檔",
overviewTitle: "單集概覽",
overviewDesc: "監控播客生成任務並查看最終成品。",
generateBtn: "生成播客",
@ -558,10 +558,10 @@ export const zhTW = {
statusCompletedDesc: "可以查看、下載或發布。",
statusFailedTitle: "失敗單集",
statusFailedDesc: "在生成過程中遇到問題的單集。",
templatesWorkspaceTitle: "模板工作區",
templatesWorkspaceTitle: "簡介工作區",
templatesWorkspaceDesc: "構建可重複使用的單集和發言人設定,以實現快速的播客製作。",
howTemplatesPowerTitle: "模板如何驅動播客生成",
howTemplatesPowerDesc: "模板將播客工作流拆分為兩個可重複使用的構建塊。在生成新單集時可以隨時混合搭配它們。",
howTemplatesPowerTitle: "簡介如何驅動播客生成",
howTemplatesPowerDesc: "簡介將播客工作流拆分為兩個可重複使用的構建塊。在生成新單集時可以隨時混合搭配它們。",
episodeProfilesSetFormat: "單集簡介設定格式",
episodeProfilesList1: "概述分段數量及故事流向",
episodeProfilesList2: "選擇用於簡報、大綱和腳本編寫的語言模型",
@ -575,13 +575,13 @@ export const zhTW = {
workflowStep2: "構建按名稱引用這些發言人的單集簡介",
workflowStep3: "通過選擇適合故事的單集簡介來生成播客",
workflowHint: "單集簡介按名稱引用發言人簡介,因此從發言人開始可以避免以後缺少聲音指派。",
failedToLoadTemplates: "載入模板資料失敗",
failedToLoadTemplates: "載入簡介資料失敗",
failedToLoadTemplatesDesc: "請確保 API 正在運行並重試。某些部分可能不完整。",
loadingTemplates: "正在載入模板...",
loadingTemplates: "正在載入簡介...",
speakerProfilesTitle: "發言人簡介",
speakerProfilesDesc: "為生成的單集設定聲音和性格。",
createSpeaker: "建立發言人",
noSpeakerProfiles: "暫無發言人簡介。建立一個以使單集模板可用。",
noSpeakerProfiles: "暫無發言人簡介。建立一個以使單集簡介可用。",
noDescription: "未提供描述。",
usedByCount_one: "被 1 個單集使用",
usedByCount_other: "被 {count} 個單集使用",
@ -659,12 +659,10 @@ export const zhTW = {
embedded: "已嵌入",
notEmbedded: "未嵌入",
noSpeakerProfilesAvailable: "沒有可用的發言人簡介",
noLanguageModelsAvailable: "沒有可用的語言模型",
editEpisodeProfile: "編輯單集簡介",
createEpisodeProfile: "建立單集簡介",
episodeProfileFormDesc: "定義單集生成的規則及預設使用的發言人設定。",
noSpeakerProfilesDesc: "在設定單集簡介之前,請先建立一個發言人簡介。",
noLanguageModelsDesc: "在“模型”部分新增語言模型,以設定大綱和文稿生成。",
profileName: "簡介名稱",
profileNamePlaceholder: "例如:技術討論",
descriptionPlaceholder: "簡要說明何時使用此簡介",
@ -676,17 +674,13 @@ export const zhTW = {
editSpeakerProfile: "編輯發言人簡介",
createSpeakerProfile: "建立發言人簡介",
speakerProfileFormDesc: "設定文字轉語音設定並定義最多四名發言人。",
noTtsModelsAvailable: "沒有可用的文字轉語音模型",
noTtsModelsDesc: "在建立發言人簡介之前,請先在“模型”部分新增 TTS 模型。",
speakers: "發言人",
speakersDesc: "為此簡介設定一到四種聲音。",
addSpeaker: "新增發言人",
speakerNumber: "發言人 {number}",
backstoryPlaceholder: "發言人的簡要傳記或背景資訊",
personalityPlaceholder: "描述風格和語氣",
outlineProviderRequired: "必須選擇大綱提供商",
outlineModelRequired: "必須選擇大綱模型",
transcriptProviderRequired: "必須選擇文稿提供商",
transcriptModelRequired: "必須選擇文稿模型",
defaultBriefingRequired: "必須填寫預設簡介",
segmentsInteger: "必須是整數",
@ -705,6 +699,19 @@ export const zhTW = {
retryStartedDesc: "已提交新的播客生成任務。",
failedToRetry: "重試失敗",
errorDetails: "錯誤詳情",
language: "語言",
languagePlaceholder: "選擇語言(可選)",
podcastLanguage: "播客語言",
selectOutlineModel: "選擇大綱模型",
selectTranscriptModel: "選擇轉錄模型",
voiceModel: "語音模型",
voiceModelRequired: "語音模型為必填項",
selectVoiceModel: "選擇語音模型",
perSpeakerTtsOverride: "每位發言人的TTS覆蓋可選",
useProfileDefault: "使用設定檔預設值",
setupRequired: "需要設定",
setupRequiredDesc: "部分設定檔尚未設定模型。請編輯它們以在生成播客之前選擇模型。",
notConfigured: "未設定",
},
settings: {
contentProcessing: "內容處理",
@ -818,7 +825,6 @@ export const zhTW = {
embedding: "嵌入模型",
tts: "文字轉語音",
stt: "語音轉文字",
provider: "提供商",
apiKey: "API 密鑰",
deleteSuccess: "模型刪除成功",
saveSuccess: "模型儲存成功",
@ -847,9 +853,6 @@ export const zhTW = {
ttsModelDesc: "用於生成播客",
sttModelLabel: "語音轉文字模型",
sttModelDesc: "用於音訊轉錄",
selectProviderPlaceholder: "選擇提供商",
providerRequired: "提供商是必填項",
modelRequired: "模型是必填項",
embeddingChangeTitle: "嵌入模型變更",
embeddingChangeConfirm: "您即將將嵌入模型從 {from} 更改為 {to}。",
rebuildRequired: "重要提示:需要重建索引",

View file

@ -13,12 +13,16 @@ export interface EpisodeProfile {
name: string
description: string
speaker_config: string
outline_provider: string
outline_model: string
transcript_provider: string
transcript_model: string
outline_llm?: string | null
transcript_llm?: string | null
language?: string | null
default_briefing: string
num_segments: number
// Legacy fields (app ignores, kept in DB for migration)
outline_provider?: string | null
outline_model?: string | null
transcript_provider?: string | null
transcript_model?: string | null
}
export interface SpeakerVoiceConfig {
@ -26,15 +30,23 @@ export interface SpeakerVoiceConfig {
voice_id: string
backstory: string
personality: string
voice_model?: string | null
}
export interface SpeakerProfile {
id: string
name: string
description: string
tts_provider: string
tts_model: string
voice_model?: string | null
speakers: SpeakerVoiceConfig[]
// Legacy fields
tts_provider?: string | null
tts_model?: string | null
}
export interface Language {
code: string
name: string
}
export interface PodcastEpisode {
@ -132,3 +144,13 @@ export function speakerUsageMap(
return usage
}
/** Check if a profile needs model configuration (missing required model references) */
export function needsModelSetup(profile: EpisodeProfile | SpeakerProfile): boolean {
if ('outline_llm' in profile) {
const ep = profile as EpisodeProfile
return !ep.outline_llm || !ep.transcript_llm
}
const sp = profile as SpeakerProfile
return !sp.voice_model
}

View file

@ -50,7 +50,7 @@ Both leverage connection context manager for lifecycle management and automatic
- `run_one_down()`: Rollback latest migration
- `AsyncMigrationManager`: Main orchestrator
- Loads 12 up migrations + 12 down migrations (hard-coded in __init__; migrations 11-12 add credential table and model-credential link)
- Loads 14 up migrations + 14 down migrations (hard-coded in __init__; migrations 11-12 add credential system, 13 adds model-credential link, 14 adds podcast model registry fields)
- `get_current_version()`: Query max version from _sbl_migrations table
- `needs_migration()`: Boolean check (current < total migrations available)
- `run_migration_up()`: Run all pending migrations with logging
@ -87,7 +87,7 @@ Both leverage connection context manager for lifecycle management and automatic
## Important Quirks & Gotchas
- **No connection pooling**: Each repo_* operation creates new connection; adequate for HTTP request-scoped operations but inefficient for bulk workloads
- **Hard-coded migration files**: AsyncMigrationManager lists migrations 1-12 explicitly; adding new migration requires code change (not auto-discovery)
- **Hard-coded migration files**: AsyncMigrationManager lists migrations 1-14 explicitly; adding new migration requires code change (not auto-discovery)
- **Record ID format inconsistency**: repo_update() accepts both `table:id` format and full RecordID; path handling can be subtle
- **ISO date parsing**: repo_update() parses `created` field from string to datetime if present; assumes ISO format
- **Timestamp overwrite risk**: repo_create() always sets new timestamps; can't preserve original created time on reimport

View file

@ -115,6 +115,9 @@ class AsyncMigrationManager:
AsyncMigration.from_file(
"open_notebook/database/migrations/13.surrealql"
),
AsyncMigration.from_file(
"open_notebook/database/migrations/14.surrealql"
),
]
self.down_migrations = [
AsyncMigration.from_file(
@ -156,6 +159,9 @@ class AsyncMigrationManager:
AsyncMigration.from_file(
"open_notebook/database/migrations/13_down.surrealql"
),
AsyncMigration.from_file(
"open_notebook/database/migrations/14_down.surrealql"
),
]
self.runner = AsyncMigrationRunner(
up_migrations=self.up_migrations,

View file

@ -0,0 +1,27 @@
-- Migration 14: Podcast profiles model registry integration
-- Adds record<model> references to replace loose provider/model strings
-- Adds language field to episode_profile
-- Adds per-speaker TTS override support
-- EPISODE PROFILE
-- Legacy fields: make optional (app ignores, preserved for data migration)
DEFINE FIELD OVERWRITE outline_provider ON TABLE episode_profile TYPE option<string>;
DEFINE FIELD OVERWRITE outline_model ON TABLE episode_profile TYPE option<string>;
DEFINE FIELD OVERWRITE transcript_provider ON TABLE episode_profile TYPE option<string>;
DEFINE FIELD OVERWRITE transcript_model ON TABLE episode_profile TYPE option<string>;
-- New fields: reference to Model registry
DEFINE FIELD IF NOT EXISTS outline_llm ON TABLE episode_profile TYPE option<record<model>>;
DEFINE FIELD IF NOT EXISTS transcript_llm ON TABLE episode_profile TYPE option<record<model>>;
DEFINE FIELD IF NOT EXISTS language ON TABLE episode_profile TYPE option<string>;
-- SPEAKER PROFILE
-- Legacy fields: make optional
DEFINE FIELD OVERWRITE tts_provider ON TABLE speaker_profile TYPE option<string>;
DEFINE FIELD OVERWRITE tts_model ON TABLE speaker_profile TYPE option<string>;
-- New field: reference to Model registry (profile-level)
DEFINE FIELD IF NOT EXISTS voice_model ON TABLE speaker_profile TYPE option<record<model>>;
-- Per-speaker TTS override
DEFINE FIELD IF NOT EXISTS speakers.*.voice_model ON TABLE speaker_profile TYPE option<record<model>>;

View file

@ -0,0 +1,20 @@
-- Migration 14 rollback: Remove model registry fields from podcast profiles
-- Remove new fields from episode_profile
REMOVE FIELD IF EXISTS outline_llm ON TABLE episode_profile;
REMOVE FIELD IF EXISTS transcript_llm ON TABLE episode_profile;
REMOVE FIELD IF EXISTS language ON TABLE episode_profile;
-- Restore episode_profile legacy fields as required strings
DEFINE FIELD OVERWRITE outline_provider ON TABLE episode_profile TYPE string;
DEFINE FIELD OVERWRITE outline_model ON TABLE episode_profile TYPE string;
DEFINE FIELD OVERWRITE transcript_provider ON TABLE episode_profile TYPE string;
DEFINE FIELD OVERWRITE transcript_model ON TABLE episode_profile TYPE string;
-- Remove new fields from speaker_profile
REMOVE FIELD IF EXISTS voice_model ON TABLE speaker_profile;
REMOVE FIELD IF EXISTS speakers.*.voice_model ON TABLE speaker_profile;
-- Restore speaker_profile legacy fields as required strings
DEFINE FIELD OVERWRITE tts_provider ON TABLE speaker_profile TYPE string;
DEFINE FIELD OVERWRITE tts_model ON TABLE speaker_profile TYPE string;

View file

@ -8,63 +8,96 @@ Encapsulates podcast metadata and configuration: speaker profiles (voice/persona
## Architecture Overview
Two-tier profile system:
- **SpeakerProfile**: TTS provider/model + 1-4 speaker configurations (name, voice_id, backstory, personality)
- **EpisodeProfile**: Generation settings (outline/transcript models, segment count, briefing template)
- **PodcastEpisode**: Generated episode record linking profiles, content, and async job
Two-tier profile system using the **model registry** for AI model references:
- **SpeakerProfile**: `voice_model` (record<model> reference) + 1-4 speaker configurations (name, voice_id, backstory, personality). Per-speaker `voice_model` overrides supported.
- **EpisodeProfile**: `outline_llm`/`transcript_llm` (record<model> references) for LLM selection, `language` field (BCP 47 locale code), segment count, briefing template.
- **PodcastEpisode**: Generated episode record linking profiles, content, and async job.
All inherit from `ObjectModel` (SurrealDB base class with table_name and save/load).
## Component Catalog
### SpeakerProfile
- Validates 1-4 speakers with required fields: name, voice_id, backstory, personality
- Stores TTS provider/model (e.g., "elevenlabs", "openai")
- `get_by_name()` async query by profile name
- Raises ValueError on invalid speaker counts or missing fields
### models.py
### EpisodeProfile
- Configures outline/transcript generation: provider, model, num_segments (3-20 validated)
- References speaker_config by name
- Stores default_briefing template for episode generation
- `get_by_name()` async query
#### `_resolve_model_config(model_id)` (module-level helper)
- Loads a Model record by ID, resolves its credential, returns `(provider, model_name, config_dict)` tuple.
- Used by `resolve_outline_config()`, `resolve_transcript_config()`, `resolve_tts_config()`, and per-speaker TTS overrides in `podcast_commands.py`.
- Falls back to `provision_provider_keys()` if no credential is linked.
### PodcastEpisode
- Stores episode_profile and speaker_profile as dicts (snapshots of config at generation time)
- Optional audio_file path, transcript/outline dicts
- **Job tracking**: command field links to surreal-commands RecordID
- `get_job_status()` fetches async job status via surreal-commands library
- `get_job_detail()` returns both status and error_message from the job (used for retry validation and UI error display)
- `_prepare_save_data()` ensures command field is always RecordID format for database
#### SpeakerProfile
- `voice_model`: Optional `record<model>` reference for TTS (replaces legacy `tts_provider`/`tts_model` strings).
- Legacy fields `tts_provider`/`tts_model` kept as optional for migration compatibility.
- `nullable_fields` ClassVar lists fields that may be null in the database.
- Validates 1-4 speakers with required fields: name, voice_id, backstory, personality.
- Per-speaker `voice_model` override: individual speakers can reference a different TTS model.
- `_prepare_save_data()` converts `voice_model` (and per-speaker overrides) to RecordID before save.
- `resolve_tts_config()` resolves `voice_model` via `_resolve_model_config()`. Raises ValueError if not set.
- `get_by_name()` async query by profile name.
#### EpisodeProfile
- `outline_llm`/`transcript_llm`: Optional `record<model>` references (replace legacy `outline_provider`/`outline_model`/`transcript_provider`/`transcript_model` strings).
- `language`: Optional BCP 47 locale code for podcast language (e.g. `pt-BR`, `en-US`).
- Legacy fields kept as optional for migration compatibility.
- `nullable_fields` ClassVar lists fields that may be null in the database.
- `num_segments` validated between 3 and 20.
- References `speaker_config` by name.
- `_prepare_save_data()` converts `outline_llm`/`transcript_llm` to RecordID before save.
- `resolve_outline_config()` / `resolve_transcript_config()` resolve model references via `_resolve_model_config()`. Raise ValueError if not set.
- `get_by_name()` async query.
#### PodcastEpisode
- Stores episode_profile and speaker_profile as dicts (snapshots of config at generation time).
- Optional audio_file path, transcript/outline dicts.
- **Job tracking**: command field links to surreal-commands RecordID.
- `get_job_status()` fetches async job status via surreal-commands library.
- `get_job_detail()` returns both status and error_message from the job (used for retry validation and UI error display).
- `_prepare_save_data()` ensures command field is always RecordID format for database.
### migration.py
Data migration for podcast profiles: maps legacy provider/model strings to Model registry record IDs. Runs on API startup after SQL migrations (called from `api/main.py` lifespan).
- `_find_model_record()`: Finds an existing Model record matching provider + name + type.
- `_find_or_create_model()`: Finds existing Model record or auto-creates one linked to a provider credential.
- `migrate_podcast_profiles()`: Migrates all episode and speaker profiles. Idempotent -- skips profiles where new fields are already populated. Logs counts of migrated/skipped/failed profiles.
## Common Patterns
- **Profile snapshots**: episode_profile and speaker_profile stored as dicts to freeze config at generation time
- **Field validation**: Pydantic validators enforce constraints (segment count, speaker count, required fields)
- **Async database access**: `get_by_name()` queries via repo_query
- **Job tracking**: command field delegates to surreal-commands; get_job_status() returns "unknown" on failure
- **Record ID handling**: ensure_record_id() converts string to RecordID before save
- **Model registry references**: Profile fields reference `record<model>` IDs instead of raw provider/model strings. Credentials are resolved at runtime via `_resolve_model_config()`.
- **Profile snapshots**: episode_profile and speaker_profile stored as dicts on PodcastEpisode to freeze config at generation time.
- **Field validation**: Pydantic validators enforce constraints (segment count, speaker count, required fields).
- **Async database access**: `get_by_name()` queries via repo_query.
- **Job tracking**: command field delegates to surreal-commands; get_job_status() returns "unknown" on failure.
- **Record ID handling**: `_prepare_save_data()` converts model ID strings to RecordID before save; `ensure_record_id()` handles both string and RecordID inputs.
- **nullable_fields ClassVar**: Declares fields that may be null/absent in the database, allowing ObjectModel to handle them during deserialization.
## Key Dependencies
- `pydantic`: Field validators, ObjectModel inheritance
- `surrealdb`: RecordID type for job references
- `surrealdb`: RecordID type for job and model references
- `open_notebook.database.repository`: repo_query, ensure_record_id
- `open_notebook.domain.base`: ObjectModel base class
- `open_notebook.ai.models`: Model class (for `_resolve_model_config`)
- `open_notebook.ai.key_provider`: provision_provider_keys (fallback)
- `open_notebook.domain.credential`: Credential (for migration)
- `surreal_commands` (optional): get_command_status() for job status
## Important Quirks & Gotchas
- **Snapshot approach**: Episode/speaker profiles stored as dicts (not references), so profile updates don't retroactively affect past episodes
- **Job status resilience**: get_job_status() catches all exceptions and returns "unknown" (no error propagation)
- **No automatic retries**: Podcast generation commands use `retry={"max_attempts": 1}` to prevent duplicate episode records on failure; retry is user-initiated via `POST /podcasts/episodes/{id}/retry`
- **validate_speakers executes late**: Validators run at instantiation; bulk inserts may not trigger full validation
- **RecordID coercion**: ensure_record_id() handles both string and RecordID inputs; command field parsed during deserialization
- **No cascade delete**: Removing a profile doesn't cascade to episodes using it
- **Legacy fields preserved**: `tts_provider`/`tts_model` on SpeakerProfile and `outline_provider`/`outline_model`/`transcript_provider`/`transcript_model` on EpisodeProfile are kept as optional nullable fields for backward compatibility with the data migration. The app ignores them at runtime.
- **Snapshot approach**: Episode/speaker profiles stored as dicts (not references), so profile updates don't retroactively affect past episodes.
- **Job status resilience**: get_job_status() catches all exceptions and returns "unknown" (no error propagation).
- **No automatic retries**: Podcast generation commands use `retry={"max_attempts": 1}` to prevent duplicate episode records on failure; retry is user-initiated via `POST /podcasts/episodes/{id}/retry`.
- **validate_speakers executes late**: Validators run at instantiation; bulk inserts may not trigger full validation.
- **RecordID coercion**: `_prepare_save_data()` converts model ID strings to RecordID; command field parsed during deserialization.
- **No cascade delete**: Removing a profile doesn't cascade to episodes using it.
- **Migration is idempotent**: `migrate_podcast_profiles()` skips profiles that already have new fields populated. Safe to run multiple times.
- **Migration auto-creates models**: If a legacy provider/model string has no matching Model record but a credential exists for that provider, the migration auto-creates a Model record linked to the credential.
## How to Extend
1. **Add new speaker field**: Add to required_fields list in validate_speakers()
2. **Add episode config field**: Validate in EpisodeProfile, update briefing generation code
2. **Add episode config field**: Validate in EpisodeProfile, update briefing generation code; add to nullable_fields if optional
3. **Add job metadata**: Extend PodcastEpisode with new fields (e.g., progress tracking)
4. **Change job provider**: Replace surreal-commands with alternative job queue library; update get_job_status()
5. **Add new model reference field**: Add field, add to nullable_fields, add RecordID conversion in `_prepare_save_data()`, add resolve method using `_resolve_model_config()`

View file

@ -0,0 +1,189 @@
"""
Data migration for podcast profiles: maps legacy provider/model strings
to Model registry record IDs.
Runs on API startup after SQL migrations. Idempotent - skips profiles
that already have the new fields populated.
"""
from loguru import logger
from open_notebook.database.repository import repo_query
async def _find_model_record(
provider: str, model_name: str, model_type: str
) -> str | None:
"""Find an existing Model record matching provider + name + type."""
results = await repo_query(
"SELECT * FROM model WHERE provider = $provider AND name = $name AND type = $type",
{"provider": provider, "name": model_name, "type": model_type},
)
if results:
return str(results[0]["id"])
return None
async def _find_or_create_model(
provider: str, model_name: str, model_type: str
) -> str | None:
"""Find existing Model record or auto-create one linked to provider credential."""
# Try exact match first
model_id = await _find_model_record(provider, model_name, model_type)
if model_id:
return model_id
# Try to find a credential for this provider and auto-create the model
from open_notebook.domain.credential import Credential
credentials = await Credential.get_by_provider(provider)
if not credentials:
logger.warning(
f"No credential found for provider '{provider}'. "
f"Cannot auto-create model '{model_name}'. Profile needs manual migration."
)
return None
# Use the first credential for the provider
credential = credentials[0]
from open_notebook.ai.models import Model
model = Model(
name=model_name,
provider=provider,
type=model_type,
credential=str(credential.id),
)
await model.save()
logger.info(
f"Auto-created model '{model_name}' ({model_type}) "
f"linked to credential '{credential.name}'"
)
return str(model.id)
async def migrate_podcast_profiles() -> None:
"""Migrate episode and speaker profiles from legacy strings to Model record IDs.
Idempotent: skips profiles where new fields are already populated.
"""
logger.info("Starting podcast profile data migration...")
ep_migrated = 0
ep_skipped = 0
ep_failed = 0
# Migrate EpisodeProfiles
episode_profiles = await repo_query("SELECT * FROM episode_profile")
for raw in episode_profiles:
profile_name = raw.get("name", raw.get("id", "unknown"))
try:
outline_llm = raw.get("outline_llm")
transcript_llm = raw.get("transcript_llm")
needs_outline = not outline_llm
needs_transcript = not transcript_llm
if not needs_outline and not needs_transcript:
ep_skipped += 1
continue
updates = {}
if needs_outline:
outline_provider = raw.get("outline_provider")
outline_model = raw.get("outline_model")
if outline_provider and outline_model:
model_id = await _find_or_create_model(
outline_provider, outline_model, "language"
)
if model_id:
from open_notebook.database.repository import ensure_record_id
updates["outline_llm"] = ensure_record_id(model_id)
if needs_transcript:
transcript_provider = raw.get("transcript_provider")
transcript_model = raw.get("transcript_model")
if transcript_provider and transcript_model:
model_id = await _find_or_create_model(
transcript_provider, transcript_model, "language"
)
if model_id:
from open_notebook.database.repository import ensure_record_id
updates["transcript_llm"] = ensure_record_id(model_id)
if updates:
from open_notebook.database.repository import repo_update
await repo_update("episode_profile", str(raw["id"]), updates)
ep_migrated += 1
logger.info(
f"Migrated episode profile '{profile_name}': {list(updates.keys())}"
)
else:
ep_failed += 1
logger.warning(
f"Could not migrate episode profile '{profile_name}': "
"no matching models found"
)
except Exception as e:
ep_failed += 1
logger.error(f"Failed to migrate episode profile '{profile_name}': {e}")
# Migrate SpeakerProfiles
sp_migrated = 0
sp_skipped = 0
sp_failed = 0
speaker_profiles = await repo_query("SELECT * FROM speaker_profile")
for raw in speaker_profiles:
profile_name = raw.get("name", raw.get("id", "unknown"))
try:
voice_model = raw.get("voice_model")
if voice_model:
sp_skipped += 1
continue
tts_provider = raw.get("tts_provider")
tts_model = raw.get("tts_model")
if not tts_provider or not tts_model:
sp_failed += 1
logger.warning(
f"Speaker profile '{profile_name}' has no legacy TTS config"
)
continue
model_id = await _find_or_create_model(
tts_provider, tts_model, "text_to_speech"
)
if model_id:
from open_notebook.database.repository import ensure_record_id, repo_update
await repo_update(
"speaker_profile",
str(raw["id"]),
{"voice_model": ensure_record_id(model_id)},
)
sp_migrated += 1
logger.info(f"Migrated speaker profile '{profile_name}'")
else:
sp_failed += 1
logger.warning(
f"Could not migrate speaker profile '{profile_name}': "
"no matching model found"
)
except Exception as e:
sp_failed += 1
logger.error(f"Failed to migrate speaker profile '{profile_name}': {e}")
logger.info(
f"Podcast profile migration complete. "
f"Episodes: {ep_migrated} migrated, {ep_skipped} skipped, {ep_failed} failed. "
f"Speakers: {sp_migrated} migrated, {sp_skipped} skipped, {sp_failed} failed."
)

View file

@ -1,5 +1,6 @@
from typing import Any, ClassVar, Dict, List, Optional, Union
from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union
from loguru import logger
from pydantic import ConfigDict, Field, field_validator
from surrealdb import RecordID
@ -7,6 +8,27 @@ from open_notebook.database.repository import ensure_record_id, repo_query
from open_notebook.domain.base import ObjectModel
async def _resolve_model_config(model_id: str) -> Tuple[str, str, dict]:
"""Load Model record, resolve credential -> (provider, model_name, config_dict).
Used by resolve_outline_config, resolve_transcript_config, resolve_tts_config,
and per-speaker TTS overrides.
"""
from open_notebook.ai.models import Model
model = await Model.get(model_id)
config: dict = {}
if model.credential:
credential = await model.get_credential_obj()
if credential:
config = credential.to_esperanto_config()
if not config:
from open_notebook.ai.key_provider import provision_provider_keys
await provision_provider_keys(model.provider)
return (model.provider, model.name, config)
class EpisodeProfile(ObjectModel):
"""
Episode Profile - Simplified podcast configuration.
@ -14,16 +36,46 @@ class EpisodeProfile(ObjectModel):
"""
table_name: ClassVar[str] = "episode_profile"
nullable_fields: ClassVar[set[str]] = {
"description",
"outline_provider",
"outline_model",
"transcript_provider",
"transcript_model",
"outline_llm",
"transcript_llm",
"language",
}
name: str = Field(..., description="Unique profile name")
description: Optional[str] = Field(None, description="Profile description")
speaker_config: str = Field(..., description="Reference to speaker profile name")
outline_provider: str = Field(..., description="AI provider for outline generation")
outline_model: str = Field(..., description="AI model for outline generation")
transcript_provider: str = Field(
..., description="AI provider for transcript generation"
# Legacy fields (kept for migration, app ignores)
outline_provider: Optional[str] = Field(
None, description="[Legacy] AI provider for outline generation"
)
transcript_model: str = Field(..., description="AI model for transcript generation")
outline_model: Optional[str] = Field(
None, description="[Legacy] AI model for outline generation"
)
transcript_provider: Optional[str] = Field(
None, description="[Legacy] AI provider for transcript generation"
)
transcript_model: Optional[str] = Field(
None, description="[Legacy] AI model for transcript generation"
)
# New fields: Model registry references
outline_llm: Optional[str] = Field(
None, description="Model record ID for outline generation"
)
transcript_llm: Optional[str] = Field(
None, description="Model record ID for transcript generation"
)
language: Optional[str] = Field(
None, description="Podcast language (BCP 47 locale code, e.g. pt-BR, en-US)"
)
default_briefing: str = Field(..., description="Default briefing template")
num_segments: int = Field(default=5, description="Number of podcast segments")
@ -34,6 +86,32 @@ class EpisodeProfile(ObjectModel):
raise ValueError("Number of segments must be between 3 and 20")
return v
def _prepare_save_data(self) -> dict:
data = super()._prepare_save_data()
if data.get("outline_llm"):
data["outline_llm"] = ensure_record_id(data["outline_llm"])
if data.get("transcript_llm"):
data["transcript_llm"] = ensure_record_id(data["transcript_llm"])
return data
async def resolve_outline_config(self) -> Tuple[str, str, dict]:
"""Resolve outline model -> (provider, model_name, config_dict)"""
if not self.outline_llm:
raise ValueError(
f"Episode profile '{self.name}' has no outline model configured. "
"Please update the profile to select an outline model."
)
return await _resolve_model_config(self.outline_llm)
async def resolve_transcript_config(self) -> Tuple[str, str, dict]:
"""Resolve transcript model -> (provider, model_name, config_dict)"""
if not self.transcript_llm:
raise ValueError(
f"Episode profile '{self.name}' has no transcript model configured. "
"Please update the profile to select a transcript model."
)
return await _resolve_model_config(self.transcript_llm)
@classmethod
async def get_by_name(cls, name: str) -> Optional["EpisodeProfile"]:
"""Get episode profile by name"""
@ -52,13 +130,27 @@ class SpeakerProfile(ObjectModel):
"""
table_name: ClassVar[str] = "speaker_profile"
nullable_fields: ClassVar[set[str]] = {
"description",
"tts_provider",
"tts_model",
"voice_model",
}
name: str = Field(..., description="Unique profile name")
description: Optional[str] = Field(None, description="Profile description")
tts_provider: str = Field(
..., description="TTS provider (openai, elevenlabs, etc.)"
# Legacy fields (kept for migration, app ignores)
tts_provider: Optional[str] = Field(
None, description="[Legacy] TTS provider (openai, elevenlabs, etc.)"
)
tts_model: str = Field(..., description="TTS model name")
tts_model: Optional[str] = Field(None, description="[Legacy] TTS model name")
# New field: Model registry reference
voice_model: Optional[str] = Field(
None, description="Model record ID for TTS"
)
speakers: List[Dict[str, Any]] = Field(
..., description="Array of speaker configurations"
)
@ -76,6 +168,26 @@ class SpeakerProfile(ObjectModel):
raise ValueError(f"Speaker missing required field: {field}")
return v
def _prepare_save_data(self) -> dict:
data = super()._prepare_save_data()
if data.get("voice_model"):
data["voice_model"] = ensure_record_id(data["voice_model"])
# Handle per-speaker voice_model overrides
if data.get("speakers"):
for speaker in data["speakers"]:
if speaker.get("voice_model"):
speaker["voice_model"] = ensure_record_id(speaker["voice_model"])
return data
async def resolve_tts_config(self) -> Tuple[str, str, dict]:
"""Resolve TTS model -> (provider, model_name, config_dict)"""
if not self.voice_model:
raise ValueError(
f"Speaker profile '{self.name}' has no voice model configured. "
"Please update the profile to select a voice model."
)
return await _resolve_model_config(self.voice_model)
@classmethod
async def get_by_name(cls, name: str) -> Optional["SpeakerProfile"]:
"""Get speaker profile by name"""

View file

@ -1,6 +1,6 @@
[project]
name = "open-notebook"
version = "1.7.4"
version = "1.8.0"
description = "An open source implementation of a research assistant, inspired by Google Notebook LM"
authors = [
{name = "Luis Novo", email = "lfnovo@gmail.com"}
@ -39,6 +39,8 @@ dependencies = [
"podcast-creator>=0.11.2,<1",
"surreal-commands>=1.3.1,<2",
"numpy>=2.4.1",
"pycountry>=26.2.16",
"babel>=2.18.0",
]
[tool.setuptools]

22
uv.lock
View file

@ -210,6 +210,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" },
]
[[package]]
name = "babel"
version = "2.18.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" },
]
[[package]]
name = "backports-tarfile"
version = "1.2.0"
@ -2099,6 +2108,7 @@ version = "1.7.4"
source = { editable = "." }
dependencies = [
{ name = "ai-prompter" },
{ name = "babel" },
{ name = "content-core" },
{ name = "esperanto" },
{ name = "fastapi" },
@ -2117,6 +2127,7 @@ dependencies = [
{ name = "loguru" },
{ name = "numpy" },
{ name = "podcast-creator" },
{ name = "pycountry" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "surreal-commands" },
@ -2148,6 +2159,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "ai-prompter", specifier = ">=0.3,<1" },
{ name = "babel", specifier = ">=2.18.0" },
{ name = "content-core", specifier = ">=1.14.1,<2" },
{ name = "esperanto", specifier = ">=2.19.3,<3" },
{ name = "fastapi", specifier = ">=0.104.0" },
@ -2170,6 +2182,7 @@ requires-dist = [
{ name = "numpy", specifier = ">=2.4.1" },
{ name = "podcast-creator", specifier = ">=0.11.2,<1" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.1" },
{ name = "pycountry", specifier = ">=26.2.16" },
{ name = "pydantic", specifier = ">=2.9.2" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "python-dotenv", specifier = ">=1.0.1" },
@ -2725,6 +2738,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]]
name = "pycountry"
version = "26.2.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/1d/061b9e7a48b85cfd69f33c33d2ef784a531c359399ad764243399673c8f5/pycountry-26.2.16.tar.gz", hash = "sha256:5b6027d453fcd6060112b951dd010f01f168b51b4bf8a1f1fc8c95c8d94a0801", size = 7711342, upload-time = "2026-02-17T03:42:52.367Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/42/7703bd45b62fecd44cd7d3495423097e2f7d28bc2e99e7c1af68892ab157/pycountry-26.2.16-py3-none-any.whl", hash = "sha256:115c4baf7cceaa30f59a4694d79483c9167dbce7a9de4d3d571c5f3ea77c305a", size = 8044600, upload-time = "2026-02-17T03:42:49.777Z" },
]
[[package]]
name = "pycparser"
version = "3.0"