mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-28 21:49:40 +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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue