refactor(podcasts): drop transcript gate, add regenerate-from-ready and voice previews

This commit is contained in:
CREDO23 2026-06-11 10:42:13 +02:00
parent ccd8209d12
commit 11a6b178a0
22 changed files with 591 additions and 347 deletions

View file

@ -1,7 +1,7 @@
"""HTTP surface for the podcast lifecycle.
Status is observed by the frontend through Zero, so these routes are about
actions (create, edit the brief, approve/regenerate, cancel) and audio delivery.
actions (create, edit/approve the brief, regenerate, cancel) and audio delivery.
Each mutating route performs the guarded transition via the service, commits,
then enqueues the matching Celery task; lifecycle errors map to 409/422.
"""
@ -11,7 +11,7 @@ from __future__ import annotations
import os
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -33,11 +33,13 @@ from app.podcasts.service import (
SpecConflict,
)
from app.podcasts.storage import open_audio_stream, purge_audio
from app.podcasts.tasks import (
draft_transcript_task,
render_audio_task,
from app.podcasts.tasks import draft_transcript_task
from app.podcasts.tts import get_text_to_speech
from app.podcasts.voices import (
get_voice_catalog,
provider_from_service,
render_voice_preview,
)
from app.podcasts.voices import get_voice_catalog, provider_from_service
from app.users import current_active_user
from app.utils.rbac import check_permission
@ -110,6 +112,29 @@ async def list_voices(language: str | None = None):
]
@router.get("/podcasts/voices/{voice_id}/preview")
async def preview_voice(
voice_id: str,
user: User = Depends(current_active_user),
):
"""A short audio sample of a voice, so users pick by sound."""
if not app_config.TTS_SERVICE:
raise HTTPException(status_code=503, detail="No TTS provider configured")
provider = provider_from_service(app_config.TTS_SERVICE)
try:
voice = get_voice_catalog().get(voice_id)
except KeyError:
raise HTTPException(status_code=404, detail="Unknown voice") from None
if voice.provider is not provider:
raise HTTPException(
status_code=404, detail="Voice not offered by the active TTS provider"
)
data, content_type = await render_voice_preview(voice, get_text_to_speech())
return Response(content=data, media_type=content_type)
@router.post("/podcasts", response_model=PodcastDetail, status_code=201)
async def create_podcast(
body: CreatePodcastRequest,
@ -180,21 +205,6 @@ async def approve_brief(
return PodcastDetail.of(podcast)
@router.post("/podcasts/{podcast_id}/transcript/approve", response_model=PodcastDetail)
async def approve_transcript(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Approve the transcript and start rendering audio."""
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
async with _lifecycle_errors():
await PodcastService(session).approve(podcast)
await session.commit()
render_audio_task.delay(podcast.id)
return PodcastDetail.of(podcast)
@router.post(
"/podcasts/{podcast_id}/transcript/regenerate", response_model=PodcastDetail
)
@ -203,7 +213,7 @@ async def regenerate_transcript(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Reject the transcript and draft a fresh one."""
"""Send a finished episode back to drafting for a fresh take."""
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
async with _lifecycle_errors():
await PodcastService(session).regenerate(podcast)

View file

@ -2,9 +2,12 @@
The status drives a guarded state machine. A podcast is proposed (``PENDING``),
gets a reviewable brief (``AWAITING_BRIEF``), is drafted into a transcript
(``DRAFTING`` ``AWAITING_REVIEW``), then rendered to audio (``RENDERING``
``READY``). ``FAILED`` and ``CANCELLED`` are terminal. The Python enum is kept
in lockstep with the ``podcast_status`` Postgres type via its paired migration.
(``DRAFTING``), then rendered to audio (``RENDERING`` ``READY``). ``FAILED``
and ``CANCELLED`` are terminal; a ``READY`` episode can be sent back to
drafting for regeneration. ``AWAITING_REVIEW`` is retained for legacy rows but
never entered anymore the brief is the only approval gate. The Python enum is
kept in lockstep with the ``podcast_status`` Postgres type via its paired
migration.
"""
from __future__ import annotations
@ -33,5 +36,5 @@ class PodcastStatus(StrEnum):
return self in _GATES
_TERMINAL = frozenset({PodcastStatus.READY, PodcastStatus.FAILED, PodcastStatus.CANCELLED})
_TERMINAL = frozenset({PodcastStatus.FAILED, PodcastStatus.CANCELLED})
_GATES = frozenset({PodcastStatus.AWAITING_BRIEF, PodcastStatus.AWAITING_REVIEW})

View file

@ -25,20 +25,18 @@ _ALLOWED: dict[PodcastStatus, frozenset[PodcastStatus]] = {
{PodcastStatus.DRAFTING, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
),
PodcastStatus.DRAFTING: frozenset(
{PodcastStatus.AWAITING_REVIEW, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
{PodcastStatus.RENDERING, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
),
# Never entered anymore (the transcript gate was dropped); kept with exits
# so legacy rows aren't stranded.
PodcastStatus.AWAITING_REVIEW: frozenset(
{
PodcastStatus.RENDERING, # approve
PodcastStatus.DRAFTING, # regenerate
PodcastStatus.FAILED,
PodcastStatus.CANCELLED,
}
{PodcastStatus.DRAFTING, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
),
PodcastStatus.RENDERING: frozenset(
{PodcastStatus.READY, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
),
PodcastStatus.READY: frozenset(),
# Not terminal: regeneration is decided by listening to the finished episode.
PodcastStatus.READY: frozenset({PodcastStatus.DRAFTING}),
PodcastStatus.FAILED: frozenset(),
PodcastStatus.CANCELLED: frozenset(),
}
@ -121,22 +119,22 @@ class PodcastService:
async def attach_transcript(
self, podcast: Podcast, transcript: Transcript
) -> Podcast:
"""Record the drafted transcript and open the go/no-go gate."""
self._transition(podcast, PodcastStatus.AWAITING_REVIEW)
"""Record the drafted transcript and move straight to rendering."""
self._transition(podcast, PodcastStatus.RENDERING)
podcast.podcast_transcript = transcript.model_dump(mode="json")
await self._session.flush()
return podcast
async def approve(self, podcast: Podcast) -> Podcast:
"""Accept the transcript and start rendering."""
if not podcast.podcast_transcript:
raise PreconditionFailed("cannot render without a transcript")
self._transition(podcast, PodcastStatus.RENDERING)
await self._session.flush()
return podcast
# Guards regenerate beyond the transition table: from AWAITING_BRIEF the
# DRAFTING target is also legal, but there it means brief approval.
_REGENERABLE = frozenset({PodcastStatus.READY, PodcastStatus.AWAITING_REVIEW})
async def regenerate(self, podcast: Podcast) -> Podcast:
"""Reject the transcript and draft a new one."""
"""Send the episode back to drafting for a fresh transcript and render."""
if _status(podcast) not in self._REGENERABLE:
raise InvalidTransition(
f"nothing to regenerate from {_status(podcast).value}"
)
self._transition(podcast, PodcastStatus.DRAFTING)
await self._session.flush()
return podcast

View file

@ -44,5 +44,10 @@ def open_audio_stream(podcast: Podcast) -> AsyncIterator[bytes]:
async def purge_audio(podcast: Podcast) -> None:
"""Delete a podcast's stored audio if present; a missing object is fine."""
if podcast.storage_key:
await get_storage_backend().delete(podcast.storage_key)
await purge_audio_object(podcast.storage_key)
async def purge_audio_object(key: str | None) -> None:
"""Delete a stored audio object by key, e.g. the one a re-render replaced."""
if key:
await get_storage_backend().delete(key)

View file

@ -1,8 +1,9 @@
"""Transcript-drafting task: DRAFTING -> AWAITING_REVIEW.
"""Transcript-drafting task: DRAFTING -> RENDERING.
The expensive, LLM-heavy step, so it runs under ``billable_call``. The API has
already moved the row to DRAFTING and stored the approved brief; this task
drafts the long-form transcript and opens the go/no-go gate.
drafts the long-form transcript and chains straight into the render the brief
gate is the only approval in the lifecycle.
"""
from __future__ import annotations
@ -23,6 +24,7 @@ from app.services.billable_calls import (
)
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
from .render import render_audio_task
from .runtime import billable_session, mark_failed
logger = logging.getLogger(__name__)
@ -90,4 +92,8 @@ async def _draft_transcript(podcast_id: int, search_space_id: int) -> dict:
await service.attach_transcript(podcast, result["transcript"])
await session.commit()
return {"status": "awaiting_review", "podcast_id": podcast_id}
# Enqueue only after the transaction is committed, so the render worker can
# never pick up a row whose transcript isn't visible yet.
render_audio_task.delay(podcast_id)
return {"status": "rendering", "podcast_id": podcast_id}

View file

@ -15,7 +15,7 @@ from app.celery_app import celery_app
from app.podcasts.persistence import PodcastRepository
from app.podcasts.rendering import PodcastRenderer
from app.podcasts.service import PodcastService, read_spec, read_transcript
from app.podcasts.storage import store_audio
from app.podcasts.storage import purge_audio_object, store_audio
from app.podcasts.tts import get_text_to_speech
from app.podcasts.voices import get_voice_catalog
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
@ -58,6 +58,8 @@ async def _render_audio(podcast_id: int) -> dict:
spec=spec, transcript=transcript, workdir=workdir
)
superseded_key = podcast.storage_key
backend_name, key = await store_audio(
search_space_id=podcast.search_space_id,
podcast_id=podcast_id,
@ -67,4 +69,8 @@ async def _render_audio(podcast_id: int) -> dict:
podcast, storage_backend=backend_name, storage_key=key
)
await session.commit()
return {"status": "ready", "podcast_id": podcast_id}
# Purge only after the new audio is committed, so a failed re-render never
# destroys the episode the user can still play.
await purge_audio_object(superseded_key)
return {"status": "ready", "podcast_id": podcast_id}

View file

@ -7,6 +7,7 @@ configured provider via :func:`provider_from_service`.
from __future__ import annotations
from .catalog import VoiceCatalog, get_voice_catalog
from .preview import render_voice_preview
from .provider import TtsProvider, provider_from_service
from .voice import ANY_LANGUAGE, CatalogVoice, VoiceGender
@ -18,4 +19,5 @@ __all__ = [
"VoiceGender",
"get_voice_catalog",
"provider_from_service",
"render_voice_preview",
]

View file

@ -0,0 +1,67 @@
"""Audible previews so users pick voices by sound, not by name.
A preview is a short sample sentence synthesised in the voice's own language.
Samples are served through the same content-addressed cache the renderer uses,
so each voice costs at most one synthesis per cache lifetime repeat listens
while comparing voices are free.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from app.podcasts.rendering.cache import SegmentCache
from app.podcasts.tts import SynthesisRequest, TextToSpeech
from .voice import ANY_LANGUAGE, CatalogVoice
# Previews are user-independent, so one rendered sample serves everyone.
PREVIEW_CACHE_ROOT = Path(tempfile.gettempdir()) / "surfsense_podcasts" / "previews"
_FALLBACK_LANGUAGE = "en"
# A voice previews best speaking its own language.
_SAMPLE_TEXTS = {
"en": "Hi there! This is how I sound when narrating your podcast.",
"es": "¡Hola! Así sueno cuando narro tu pódcast.",
"fr": "Bonjour ! Voici ma voix quand je raconte votre podcast.",
"hi": "नमस्ते! आपका पॉडकास्ट सुनाते समय मेरी आवाज़ ऐसी होती है।",
"it": "Ciao! Questa è la mia voce quando racconto il tuo podcast.",
"ja": "こんにちは。ポッドキャストをお届けするときの私の声です。",
"pt": "Olá! É assim que eu soo ao narrar o seu podcast.",
"zh": "你好!这就是我为你播报播客时的声音。",
}
_CONTENT_TYPES = {"mp3": "audio/mpeg", "wav": "audio/wav"}
async def render_voice_preview(
voice: CatalogVoice, tts: TextToSpeech
) -> tuple[bytes, str]:
"""Return ``(audio_bytes, content_type)`` for a sample spoken by ``voice``."""
language = (
_FALLBACK_LANGUAGE if voice.language == ANY_LANGUAGE else voice.language
)
request = SynthesisRequest(
text=_sample_text(language), voice=voice.native_ref, language=language
)
cache = SegmentCache(PREVIEW_CACHE_ROOT)
key = cache.key(request)
cached = cache.get(key, tts.container)
if cached is not None:
return cached.read_bytes(), _content_type(tts.container)
audio = await tts.synthesize(request)
cache.put(key, audio.container, audio.data)
return audio.data, _content_type(audio.container)
def _sample_text(language: str) -> str:
primary = language.split("-", 1)[0].strip().lower()
return _SAMPLE_TEXTS.get(primary, _SAMPLE_TEXTS[_FALLBACK_LANGUAGE])
def _content_type(container: str) -> str:
return _CONTENT_TYPES.get(container, "application/octet-stream")