mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-10 22:32:16 +02:00
refactor(podcasts): drop transcript gate, add regenerate-from-ready and voice previews
This commit is contained in:
parent
ccd8209d12
commit
11a6b178a0
22 changed files with 591 additions and 347 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
67
surfsense_backend/app/podcasts/voices/preview.py
Normal file
67
surfsense_backend/app/podcasts/voices/preview.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue