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

@ -166,13 +166,20 @@ def bind_task_session(db_session: AsyncSession, monkeypatch) -> AsyncSession:
class FakeTextToSpeech(TextToSpeech):
"""In-memory TTS provider: every segment yields fixed bytes (the boundary)."""
"""In-memory TTS provider: every segment yields fixed bytes (the boundary).
Records each request so tests can assert how often synthesis was paid for.
"""
def __init__(self) -> None:
self.requests: list[SynthesisRequest] = []
@property
def container(self) -> str:
return "mp3"
async def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
self.requests.append(request)
return SynthesizedAudio(data=b"segment-audio", container="mp3")
@ -233,7 +240,6 @@ def make_podcast(db_session: AsyncSession):
_LADDER = [
PodcastStatus.AWAITING_BRIEF,
PodcastStatus.DRAFTING,
PodcastStatus.AWAITING_REVIEW,
PodcastStatus.RENDERING,
PodcastStatus.READY,
]
@ -259,10 +265,8 @@ def make_podcast(db_session: AsyncSession):
await service.attach_brief(podcast, build_spec())
elif target is PodcastStatus.DRAFTING:
await service.begin_drafting(podcast)
elif target is PodcastStatus.AWAITING_REVIEW:
await service.attach_transcript(podcast, build_transcript())
elif target is PodcastStatus.RENDERING:
await service.approve(podcast)
await service.attach_transcript(podcast, build_transcript())
elif target is PodcastStatus.READY:
await service.attach_audio(
podcast,

View file

@ -1,11 +1,12 @@
"""The transcript-drafting task against a real database.
Drafting is the expensive LLM step, so it runs under ``billable_call``. The
behavior that protects users' money: when billing succeeds, a drafted transcript
opens the review gate (DRAFTING -> AWAITING_REVIEW); when billing denies or
settlement fails, the podcast ends FAILED with no transcript left behind. The DB,
service, and transcript persistence run for real; only the true externals are
faked billing (the metering boundary) and the generation graph (the LLM).
behavior that protects users' money: when billing succeeds, the drafted
transcript is stored and rendering starts immediately (DRAFTING -> RENDERING,
render task enqueued the brief gate is the only approval); when billing denies
or settlement fails, the podcast ends FAILED with no transcript left behind. The
DB, service, and transcript persistence run for real; only the true externals
are faked billing (the metering boundary) and the generation graph (the LLM).
"""
from __future__ import annotations
@ -43,8 +44,8 @@ def _wire_billing(monkeypatch, *, billable_call, transcript=None) -> None:
monkeypatch.setattr(draft, "transcript_graph", SimpleNamespace(ainvoke=_ainvoke))
async def test_successful_billing_opens_review_gate_with_transcript(
monkeypatch, db_search_space, make_podcast, bind_task_session
async def test_successful_draft_stores_transcript_and_starts_rendering(
monkeypatch, db_search_space, make_podcast, bind_task_session, captured_tasks
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING
@ -58,9 +59,10 @@ async def test_successful_billing_opens_review_gate_with_transcript(
result = await draft._draft_transcript(podcast.id, db_search_space.id)
assert result["status"] == "awaiting_review"
assert podcast.status == PodcastStatus.AWAITING_REVIEW
assert result["status"] == "rendering"
assert podcast.status == PodcastStatus.RENDERING
assert read_transcript(podcast) is not None
assert captured_tasks.render == [((podcast.id,), {})]
async def test_quota_denial_fails_the_podcast_without_a_transcript(

View file

@ -0,0 +1,60 @@
"""Regeneration: the listen-then-redo loop after the brief gate.
The brief is the only approval; drafting flows straight into rendering. A user
who dislikes the finished audio sends the episode back with regenerate. These
pin the READY -> DRAFTING round trip (with the draft task enqueued) and the 409
for regenerating from states that have nothing to redo.
"""
from __future__ import annotations
import pytest
from app.podcasts.persistence import PodcastStatus
pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts"
async def test_regenerate_from_ready_returns_to_drafting_and_enqueues_draft(
client, db_search_space, make_podcast, captured_tasks
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
)
resp = await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
assert resp.status_code == 200
assert resp.json()["status"] == "drafting"
assert captured_tasks.draft == [((podcast.id, db_search_space.id), {})]
assert captured_tasks.render == []
async def test_regenerate_from_brief_gate_is_rejected(
client, db_search_space, make_podcast, captured_tasks
):
# Nothing has been drafted yet, so there is nothing to regenerate.
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
)
resp = await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
assert resp.status_code == 409
assert captured_tasks.draft == []
async def test_regenerate_from_cancelled_is_rejected(
client, db_search_space, make_podcast, captured_tasks
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
)
await client.post(f"{BASE}/{podcast.id}/cancel")
resp = await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
assert resp.status_code == 409
assert captured_tasks.draft == []

View file

@ -11,8 +11,11 @@ from __future__ import annotations
import pytest
from app.podcasts.persistence import PodcastStatus
from app.podcasts.service import PodcastService
from app.podcasts.tasks import render
from .conftest import build_transcript
pytestmark = pytest.mark.integration
@ -30,3 +33,33 @@ async def test_render_marks_ready_and_stores_audio(
assert podcast.storage_backend == "memory"
assert podcast.storage_key
assert fake_storage.objects[podcast.storage_key] == b"merged-audio"
async def test_rerender_replaces_audio_and_purges_the_old_object(
db_session,
db_search_space,
make_podcast,
bind_task_session,
fake_tts,
fake_merge,
fake_storage,
):
# A regenerated episode keeps exactly one stored object: the new render
# must not leak the superseded audio in the object store.
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
)
old_key = podcast.storage_key
fake_storage.objects[old_key] = b"old-audio"
service = PodcastService(db_session)
await service.regenerate(podcast)
await service.attach_transcript(podcast, build_transcript())
result = await render._render_audio(podcast.id)
assert result["status"] == "ready"
assert podcast.status == PodcastStatus.READY
assert podcast.storage_key != old_key
assert fake_storage.objects[podcast.storage_key] == b"merged-audio"
assert old_key in fake_storage.deleted

View file

@ -33,7 +33,7 @@ async def test_stream_serves_stored_audio(
async def test_stream_404_when_no_audio(client, db_search_space, make_podcast):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_REVIEW
search_space_id=db_search_space.id, status=PodcastStatus.DRAFTING
)
resp = await client.get(f"{BASE}/{podcast.id}/stream")

View file

@ -1,81 +0,0 @@
"""The transcript go/no-go gate: approve to render, or regenerate to redraft.
From ``awaiting_review`` the user either approves (start rendering) or regenerates
(redraft). These pin the resulting state, the Celery task each enqueues, and the
HTTP codes for acting from the wrong state (409) or without a transcript (422).
"""
from __future__ import annotations
import pytest
from app.podcasts.persistence import Podcast, PodcastStatus
pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts"
async def test_approve_transcript_starts_rendering_and_enqueues_render(
client, db_search_space, make_podcast, captured_tasks
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_REVIEW
)
resp = await client.post(f"{BASE}/{podcast.id}/transcript/approve")
assert resp.status_code == 200
assert resp.json()["status"] == "rendering"
assert captured_tasks.render == [((podcast.id,), {})]
assert captured_tasks.draft == []
async def test_regenerate_returns_to_drafting_and_enqueues_draft(
client, db_search_space, make_podcast, captured_tasks
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_REVIEW
)
resp = await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
assert resp.status_code == 200
assert resp.json()["status"] == "drafting"
assert captured_tasks.draft == [((podcast.id, db_search_space.id), {})]
assert captured_tasks.render == []
async def test_approve_transcript_from_terminal_state_is_rejected(
client, db_search_space, make_podcast, captured_tasks
):
# A ready podcast still has its transcript, so the precondition passes and
# the disallowed terminal->rendering transition is what surfaces (409).
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
)
resp = await client.post(f"{BASE}/{podcast.id}/transcript/approve")
assert resp.status_code == 409
assert captured_tasks.render == []
async def test_approve_without_transcript_is_unprocessable(
client, db_session, db_search_space, captured_tasks
):
# An anomalous awaiting_review row with no transcript exercises the route's
# precondition->422 mapping (the service refuses to render without one).
podcast = Podcast(
title="No transcript",
search_space_id=db_search_space.id,
status=PodcastStatus.AWAITING_REVIEW,
spec_version=1,
)
db_session.add(podcast)
await db_session.flush()
resp = await client.post(f"{BASE}/{podcast.id}/transcript/approve")
assert resp.status_code == 422
assert captured_tasks.render == []

View file

@ -0,0 +1,79 @@
"""Audible voice previews for the brief gate's voice picker.
A user choosing voices should hear them, not guess from names. The endpoint
synthesises a short sample for a catalog voice and caches it on disk so each
voice is paid for at most once per process lifetime. Unknown voices and voices
of an inactive provider are 404; no configured TTS is 503.
"""
from __future__ import annotations
import pytest
from app.config import config as app_config
from .conftest import FakeTextToSpeech
pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts"
@pytest.fixture
def preview_tts(monkeypatch, tmp_path) -> FakeTextToSpeech:
"""Route preview synthesis to the fake provider and an isolated cache."""
provider = FakeTextToSpeech()
monkeypatch.setattr(
"app.podcasts.api.routes.get_text_to_speech", lambda: provider
)
monkeypatch.setattr(
"app.podcasts.voices.preview.PREVIEW_CACHE_ROOT", tmp_path
)
return provider
async def test_preview_returns_playable_audio_for_a_catalog_voice(
client, preview_tts
):
resp = await client.get(f"{BASE}/voices/openai:alloy/preview")
assert resp.status_code == 200
assert resp.headers["content-type"] == "audio/mpeg"
assert resp.content == b"segment-audio"
async def test_preview_is_synthesised_once_then_served_from_cache(
client, preview_tts
):
first = await client.get(f"{BASE}/voices/openai:alloy/preview")
second = await client.get(f"{BASE}/voices/openai:alloy/preview")
assert first.status_code == second.status_code == 200
assert second.content == first.content
assert len(preview_tts.requests) == 1
async def test_preview_unknown_voice_is_404(client, preview_tts):
resp = await client.get(f"{BASE}/voices/openai:nope/preview")
assert resp.status_code == 404
assert preview_tts.requests == []
async def test_preview_voice_of_inactive_provider_is_404(client, preview_tts):
# The active provider is OpenAI (pinned in conftest); a Kokoro voice exists
# in the catalog but cannot be heard through the configured provider.
resp = await client.get(f"{BASE}/voices/kokoro:af_heart/preview")
assert resp.status_code == 404
assert preview_tts.requests == []
async def test_preview_without_tts_provider_is_503(
client, preview_tts, monkeypatch
):
monkeypatch.setattr(app_config, "TTS_SERVICE", None)
resp = await client.get(f"{BASE}/voices/openai:alloy/preview")
assert resp.status_code == 503