mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-14 20:55:15 +02:00
204 lines
7.5 KiB
Python
204 lines
7.5 KiB
Python
"""The podcast lifecycle authority: every status change goes through here.
|
|
|
|
The service owns the state machine. Each method names a real lifecycle step,
|
|
validates it against the allowed-transition table, and (de)serializes the brief
|
|
and transcript to/from their JSONB columns. It deliberately does not enqueue
|
|
Celery work — callers transition the row here, then schedule the next task — so
|
|
the rules stay testable and free of task-queue coupling.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.podcasts.persistence import Podcast, PodcastRepository, PodcastStatus
|
|
from app.podcasts.schemas import PodcastSpec, Transcript
|
|
|
|
_MAX_ERROR_CHARS = 2000
|
|
|
|
# The only status changes the machine permits. Terminal states have no exits.
|
|
_ALLOWED: dict[PodcastStatus, frozenset[PodcastStatus]] = {
|
|
PodcastStatus.PENDING: frozenset(
|
|
{PodcastStatus.AWAITING_BRIEF, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
|
|
),
|
|
PodcastStatus.AWAITING_BRIEF: frozenset(
|
|
{PodcastStatus.DRAFTING, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
|
|
),
|
|
PodcastStatus.DRAFTING: frozenset(
|
|
{PodcastStatus.AWAITING_REVIEW, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
|
|
),
|
|
PodcastStatus.AWAITING_REVIEW: frozenset(
|
|
{
|
|
PodcastStatus.RENDERING, # approve
|
|
PodcastStatus.DRAFTING, # regenerate
|
|
PodcastStatus.FAILED,
|
|
PodcastStatus.CANCELLED,
|
|
}
|
|
),
|
|
PodcastStatus.RENDERING: frozenset(
|
|
{PodcastStatus.READY, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
|
|
),
|
|
PodcastStatus.READY: frozenset(),
|
|
PodcastStatus.FAILED: frozenset(),
|
|
PodcastStatus.CANCELLED: frozenset(),
|
|
}
|
|
|
|
|
|
class PodcastError(RuntimeError):
|
|
"""Base class for lifecycle errors."""
|
|
|
|
|
|
class InvalidTransition(PodcastError):
|
|
"""A requested status change is not permitted from the current state."""
|
|
|
|
|
|
class SpecConflict(PodcastError):
|
|
"""A spec edit raced another: the expected version is stale."""
|
|
|
|
def __init__(self, expected: int, actual: int) -> None:
|
|
super().__init__(
|
|
f"spec version conflict: expected {expected}, current is {actual}"
|
|
)
|
|
self.expected = expected
|
|
self.actual = actual
|
|
|
|
|
|
class PreconditionFailed(PodcastError):
|
|
"""A transition's data precondition (brief/transcript present) is unmet."""
|
|
|
|
|
|
class PodcastService:
|
|
"""Drives one podcast through its lifecycle within a single session."""
|
|
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self._session = session
|
|
self._repo = PodcastRepository(session)
|
|
|
|
async def create(
|
|
self, *, title: str, search_space_id: int, thread_id: int | None = None
|
|
) -> Podcast:
|
|
"""Create a fresh podcast in ``PENDING`` awaiting its brief."""
|
|
podcast = Podcast(
|
|
title=title,
|
|
search_space_id=search_space_id,
|
|
thread_id=thread_id,
|
|
status=PodcastStatus.PENDING,
|
|
spec_version=1,
|
|
)
|
|
return await self._repo.add(podcast)
|
|
|
|
async def attach_brief(self, podcast: Podcast, spec: PodcastSpec) -> Podcast:
|
|
"""Record the proposed brief and open the review gate."""
|
|
self._transition(podcast, PodcastStatus.AWAITING_BRIEF)
|
|
podcast.spec = spec.model_dump(mode="json")
|
|
await self._session.flush()
|
|
return podcast
|
|
|
|
async def update_spec(
|
|
self, podcast: Podcast, spec: PodcastSpec, expected_version: int
|
|
) -> Podcast:
|
|
"""Edit the brief at the gate, guarded by optimistic concurrency."""
|
|
if _status(podcast) is not PodcastStatus.AWAITING_BRIEF:
|
|
raise InvalidTransition(
|
|
f"the brief can only be edited while awaiting_brief, "
|
|
f"not {_status(podcast).value}"
|
|
)
|
|
if expected_version != podcast.spec_version:
|
|
raise SpecConflict(expected_version, podcast.spec_version)
|
|
podcast.spec = spec.model_dump(mode="json")
|
|
podcast.spec_version += 1
|
|
await self._session.flush()
|
|
return podcast
|
|
|
|
async def begin_drafting(self, podcast: Podcast) -> Podcast:
|
|
"""Approve the brief and start transcript drafting."""
|
|
if podcast.spec is None:
|
|
raise PreconditionFailed("cannot draft without a brief")
|
|
self._transition(podcast, PodcastStatus.DRAFTING)
|
|
await self._session.flush()
|
|
return podcast
|
|
|
|
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)
|
|
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
|
|
|
|
async def regenerate(self, podcast: Podcast) -> Podcast:
|
|
"""Reject the transcript and draft a new one."""
|
|
self._transition(podcast, PodcastStatus.DRAFTING)
|
|
await self._session.flush()
|
|
return podcast
|
|
|
|
async def attach_audio(
|
|
self,
|
|
podcast: Podcast,
|
|
*,
|
|
storage_backend: str,
|
|
storage_key: str,
|
|
duration_seconds: int | None = None,
|
|
) -> Podcast:
|
|
"""Record rendered audio and mark the podcast ready."""
|
|
self._transition(podcast, PodcastStatus.READY)
|
|
podcast.storage_backend = storage_backend
|
|
podcast.storage_key = storage_key
|
|
podcast.duration_seconds = duration_seconds
|
|
podcast.error = None
|
|
await self._session.flush()
|
|
return podcast
|
|
|
|
async def fail(self, podcast: Podcast, error: str) -> Podcast:
|
|
"""Move a non-terminal podcast to ``FAILED`` with a reason."""
|
|
self._transition(podcast, PodcastStatus.FAILED)
|
|
podcast.error = (error or "")[:_MAX_ERROR_CHARS] or None
|
|
await self._session.flush()
|
|
return podcast
|
|
|
|
async def cancel(self, podcast: Podcast) -> Podcast:
|
|
"""Cancel a non-terminal podcast at the user's request."""
|
|
self._transition(podcast, PodcastStatus.CANCELLED)
|
|
await self._session.flush()
|
|
return podcast
|
|
|
|
def _transition(self, podcast: Podcast, target: PodcastStatus) -> None:
|
|
current = _status(podcast)
|
|
if target not in _ALLOWED[current]:
|
|
raise InvalidTransition(
|
|
f"{current.value} -> {target.value} is not allowed"
|
|
)
|
|
podcast.status = target
|
|
|
|
|
|
def _status(podcast: Podcast) -> PodcastStatus:
|
|
return PodcastStatus(podcast.status)
|
|
|
|
|
|
def read_spec(podcast: Podcast) -> PodcastSpec | None:
|
|
"""Deserialize the stored brief, or ``None`` if not yet proposed."""
|
|
return PodcastSpec.model_validate(podcast.spec) if podcast.spec else None
|
|
|
|
|
|
def read_transcript(podcast: Podcast) -> Transcript | None:
|
|
"""Deserialize the stored transcript, or ``None`` if not yet drafted."""
|
|
if not podcast.podcast_transcript:
|
|
return None
|
|
return Transcript.model_validate(podcast.podcast_transcript)
|
|
|
|
|
|
def preferences_from(podcast: Podcast | None) -> tuple[str | None, list[str]]:
|
|
"""Extract reusable (language, voice_ids) defaults from a prior podcast."""
|
|
spec = read_spec(podcast) if podcast is not None else None
|
|
if spec is None:
|
|
return None, []
|
|
return spec.language, [speaker.voice_id for speaker in spec.speakers]
|