feat(podcasts): add revert-regeneration and surface cancel on the live card

This commit is contained in:
CREDO23 2026-06-11 12:31:42 +02:00
parent f0fc660d70
commit aa7f14d94f
9 changed files with 384 additions and 31 deletions

View file

@ -221,6 +221,20 @@ async def regenerate_transcript(
return PodcastDetail.of(podcast)
@router.post("/podcasts/{podcast_id}/regenerate/revert", response_model=PodcastDetail)
async def revert_regeneration(
podcast_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Back out of a regeneration and return to the finished episode."""
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
async with _lifecycle_errors():
await PodcastService(session).revert_regeneration(podcast)
await session.commit()
return PodcastDetail.of(podcast)
@router.post("/podcasts/{podcast_id}/cancel", response_model=PodcastDetail)
async def cancel_podcast(
podcast_id: int,

View file

@ -4,7 +4,9 @@ The status drives a guarded state machine. A podcast is proposed (``PENDING``),
gets a reviewable brief (``AWAITING_BRIEF``), is drafted into a transcript
(``DRAFTING``), then rendered to audio (``RENDERING`` ``READY``). ``FAILED``
and ``CANCELLED`` are terminal; a ``READY`` episode can be sent back to the
brief gate for regeneration. ``AWAITING_REVIEW`` is retained for legacy rows but
brief gate for regeneration, and an in-flight regeneration can be reverted to
``READY`` while the previous audio still exists. ``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.

View file

@ -21,11 +21,23 @@ _ALLOWED: dict[PodcastStatus, frozenset[PodcastStatus]] = {
PodcastStatus.PENDING: frozenset(
{PodcastStatus.AWAITING_BRIEF, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
),
# The READY exits below exist for reverting a regeneration; the audio
# guard for that lives in revert_regeneration.
PodcastStatus.AWAITING_BRIEF: frozenset(
{PodcastStatus.DRAFTING, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
{
PodcastStatus.DRAFTING,
PodcastStatus.READY,
PodcastStatus.FAILED,
PodcastStatus.CANCELLED,
}
),
PodcastStatus.DRAFTING: frozenset(
{PodcastStatus.RENDERING, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
{
PodcastStatus.RENDERING,
PodcastStatus.READY,
PodcastStatus.FAILED,
PodcastStatus.CANCELLED,
}
),
# Never entered anymore (the transcript gate was dropped); kept with exits
# so legacy rows aren't stranded.
@ -140,6 +152,19 @@ class PodcastService:
await self._session.flush()
return podcast
async def revert_regeneration(self, podcast: Podcast) -> Podcast:
"""Back out of a regeneration and fall back to the stored episode.
Regeneration keeps the rendered audio until a new take replaces it, so
any point before that commit is a free change of mind. A fresh podcast
has no regeneration to revert and is rejected.
"""
if not _has_episode(podcast):
raise InvalidTransition("no finished episode to fall back to")
self._transition(podcast, PodcastStatus.READY)
await self._session.flush()
return podcast
async def attach_audio(
self,
podcast: Podcast,
@ -165,7 +190,15 @@ class PodcastService:
return podcast
async def cancel(self, podcast: Podcast) -> Podcast:
"""Cancel a non-terminal podcast at the user's request."""
"""Cancel a podcast that has produced nothing the user could keep.
No user action may destroy playable audio: once an episode exists,
backing out goes through revert_regeneration instead.
"""
if _has_episode(podcast):
raise InvalidTransition(
"a finished episode exists; revert the regeneration instead"
)
self._transition(podcast, PodcastStatus.CANCELLED)
await self._session.flush()
return podcast
@ -183,6 +216,11 @@ def _status(podcast: Podcast) -> PodcastStatus:
return PodcastStatus(podcast.status)
def _has_episode(podcast: Podcast) -> bool:
"""Whether finished audio is stored (``file_location`` covers legacy rows)."""
return bool(podcast.storage_key or podcast.file_location)
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

View file

@ -14,7 +14,12 @@ from pathlib import Path
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.service import (
InvalidTransition,
PodcastService,
read_spec,
read_transcript,
)
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
@ -65,10 +70,16 @@ async def _render_audio(podcast_id: int) -> dict:
podcast_id=podcast_id,
data=rendered.data,
)
await PodcastService(session).attach_audio(
podcast, storage_backend=backend_name, storage_key=key
)
await session.commit()
try:
await PodcastService(session).attach_audio(
podcast, storage_backend=backend_name, storage_key=key
)
await session.commit()
except InvalidTransition:
# A user back-out won the race (e.g. the regeneration was
# reverted): drop the stale render and leave the row alone.
await purge_audio_object(key)
return {"status": "superseded", "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.

View file

@ -1,8 +1,9 @@
"""Cancelling a podcast: allowed while in flight, refused once terminal.
"""Cancelling a podcast: allowed while in flight, refused once an episode exists.
Cancellation is a user escape hatch from any non-terminal state; a podcast that
has already finished (READY) has no exit, so the disallowed transition surfaces
as 409.
Cancellation is the escape hatch for a podcast that has produced nothing yet.
Once a finished episode exists including during a regeneration, whose audio
survives until a new render commits cancel is refused (409): reverting the
regeneration is the way back, and no user action may destroy playable audio.
"""
import pytest
@ -37,3 +38,22 @@ async def test_cancel_from_a_terminal_state_conflicts(
resp = await client.post(f"{BASE}/{podcast.id}/cancel")
assert resp.status_code == 409
async def test_cancel_of_a_regeneration_is_rejected(
client, db_search_space, make_podcast
):
# Cancelling here would destroy a playable episode; reverting the
# regeneration is the way back.
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
)
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
resp = await client.post(f"{BASE}/{podcast.id}/cancel")
assert resp.status_code == 409
# The regeneration is still revertable afterwards.
follow_up = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
assert follow_up.status_code == 200
assert follow_up.json()["status"] == "ready"

View file

@ -2,9 +2,11 @@
A user who dislikes the finished audio sends the episode back to the brief
gate: the saved brief reopens for tweaks (voices, length, focus) and drafting
only restarts on a fresh approval. These pin the READY -> AWAITING_BRIEF ->
DRAFTING round trip and the 409 for regenerating from states that have nothing
to redo.
only restarts on a fresh approval. The whole redo can also be reverted at any
point before the new render commits, falling back to the still-stored episode.
These pin the READY -> AWAITING_BRIEF -> DRAFTING round trip, the revert
fallback, and the 409s for acting from states that have nothing to redo or
revert.
"""
from __future__ import annotations
@ -12,6 +14,9 @@ from __future__ import annotations
import pytest
from app.podcasts.persistence import PodcastStatus
from app.podcasts.service import PodcastService
from .conftest import build_transcript
pytestmark = pytest.mark.integration
@ -78,3 +83,99 @@ async def test_regenerate_from_cancelled_is_rejected(
assert resp.status_code == 409
assert captured_tasks.draft == []
async def test_reverting_a_regeneration_restores_the_ready_episode(
client, db_search_space, make_podcast, captured_tasks
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
)
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ready"
# The episode the user could already play is untouched.
assert body["has_audio"] is True
assert captured_tasks.draft == []
assert captured_tasks.render == []
async def test_reverting_mid_draft_keeps_the_episode(
client, db_search_space, make_podcast
):
# Changing one's mind is allowed even after the reopened brief was
# approved: the episode survives until a new render replaces it.
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
)
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
await client.post(f"{BASE}/{podcast.id}/brief/approve")
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
assert resp.status_code == 200
assert resp.json()["status"] == "ready"
async def test_reverting_mid_render_keeps_the_episode(
client, db_session, db_search_space, make_podcast
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
)
service = PodcastService(db_session)
await service.regenerate(podcast)
await service.begin_drafting(podcast)
await service.attach_transcript(podcast, build_transcript())
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
assert resp.status_code == 200
assert resp.json()["status"] == "ready"
async def test_reverted_episode_can_be_regenerated_again(
client, db_search_space, make_podcast
):
# Reverting must not strand the episode: the user can change their mind
# again immediately.
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
)
await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
resp = await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
assert resp.status_code == 200
assert resp.json()["status"] == "awaiting_brief"
async def test_revert_on_a_fresh_brief_gate_is_rejected(
client, db_search_space, make_podcast
):
# A first-time brief has no regeneration to revert.
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
)
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
assert resp.status_code == 409
assert resp.json()["detail"]
async def test_revert_when_nothing_was_regenerated_is_rejected(
client, db_search_space, make_podcast
):
podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.READY
)
resp = await client.post(f"{BASE}/{podcast.id}/regenerate/revert")
assert resp.status_code == 409

View file

@ -64,3 +64,37 @@ async def test_rerender_replaces_audio_and_purges_the_old_object(
assert podcast.storage_key != old_key
assert fake_storage.objects[podcast.storage_key] == b"merged-audio"
assert old_key in fake_storage.deleted
async def test_render_losing_to_a_user_revert_keeps_the_episode_and_leaks_nothing(
db_session,
db_search_space,
make_podcast,
bind_task_session,
fake_tts,
fake_merge,
fake_storage,
):
# The user reverts the regeneration while the render is in flight: the
# stale render must neither resurrect the redo nor leak the object it
# already stored.
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.begin_drafting(podcast)
await service.attach_transcript(podcast, build_transcript())
await service.revert_regeneration(podcast)
result = await render._render_audio(podcast.id)
assert result["status"] == "superseded"
assert podcast.status == PodcastStatus.READY
assert podcast.storage_key == old_key
assert old_key not in fake_storage.deleted
stale_keys = [key for key in fake_storage.objects if key != old_key]
assert all(key in fake_storage.deleted for key in stale_keys)

View file

@ -1,24 +1,46 @@
"use client";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { Loader2, RotateCcw } from "lucide-react";
import { Loader2, RotateCcw, Undo2, X } from "lucide-react";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { type ReactNode, useEffect, useState } from "react";
import { toast } from "sonner";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button, buttonVariants } from "@/components/ui/button";
import { type LivePodcast, usePodcastLive } from "@/hooks/use-podcast-live";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
import { BriefReview } from "./brief-review";
import { PodcastErrorState, PodcastPlayer } from "./player";
import type { GeneratePodcastArgs, GeneratePodcastResult } from "./schema";
function WorkingState({ title, label }: { title: string; label: string }) {
function WorkingState({
title,
label,
action,
}: {
title: string;
label: string;
action?: ReactNode;
}) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-foreground line-clamp-2">{title}</p>
<TextShimmerLoader text={label} size="sm" />
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-4">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground line-clamp-2">{title}</p>
<TextShimmerLoader text={label} size="sm" />
</div>
{action}
</div>
</div>
);
@ -97,6 +119,84 @@ function RegenerateButton({ podcast }: { podcast: LivePodcast }) {
);
}
/**
* The way out of an in-flight generation depends on what already exists:
* a regeneration is reverted (the stored episode survives, so no confirm),
* while a first-time generation is cancelled (destructive, so confirmed via a
* dialog the card header is too cramped to host a confirmation row).
*/
function BackOutButton({ podcastId, hasEpisode }: { podcastId: number; hasEpisode: boolean }) {
const [isSubmitting, setIsSubmitting] = useState(false);
const run = async (call: (id: number) => Promise<unknown>, failure: string) => {
setIsSubmitting(true);
try {
await call(podcastId);
} catch (error) {
toast.error(error instanceof Error ? error.message : failure);
} finally {
setIsSubmitting(false);
}
};
if (hasEpisode) {
return (
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-muted-foreground"
disabled={isSubmitting}
onClick={() =>
run(podcastsApiService.revertRegeneration, "Failed to restore the current episode")
}
>
{isSubmitting ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Undo2 className="size-3.5" />
)}
Keep current episode
</Button>
);
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-muted-foreground"
disabled={isSubmitting}
>
<X className="size-3.5" /> Cancel
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel this podcast?</AlertDialogTitle>
<AlertDialogDescription>
Generation stops and the podcast is discarded. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep going</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={() => run(podcastsApiService.cancel, "Failed to cancel the podcast")}
>
Cancel podcast
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
const BACK_OUT_STATUSES = new Set(["awaiting_brief", "drafting", "rendering"]);
/** Status-driven card for an authenticated viewer, fed by Zero push. */
function LivePodcastCard({
podcastId,
@ -107,6 +207,26 @@ function LivePodcastCard({
}) {
const { podcast, isLoading } = usePodcastLive(podcastId);
// Whether a finished episode exists decides revert-vs-cancel, and Zero
// doesn't publish audio fields — so the in-flight states check over REST,
// re-checking on each status change (a fresh podcast gains its episode,
// a regeneration starts with one).
const status = podcast?.status;
const [hasEpisode, setHasEpisode] = useState(false);
useEffect(() => {
if (!status || !BACK_OUT_STATUSES.has(status)) return;
let stale = false;
podcastsApiService
.getDetail(podcastId)
.then((detail) => {
if (!stale) setHasEpisode(detail.has_audio);
})
.catch(() => {});
return () => {
stale = true;
};
}, [podcastId, status]);
if (!podcast) {
if (isLoading) {
return <WorkingState title={fallbackTitle} label="Loading podcast" />;
@ -121,13 +241,15 @@ function LivePodcastCard({
const title = podcast.title || fallbackTitle;
const backOut = <BackOutButton podcastId={podcast.id} hasEpisode={hasEpisode} />;
switch (podcast.status) {
case "pending":
return <WorkingState title={title} label="Preparing brief" />;
case "drafting":
return <WorkingState title={title} label="Drafting transcript" />;
return <WorkingState title={title} label="Drafting transcript" action={backOut} />;
case "rendering":
return <WorkingState title={title} label="Rendering audio" />;
return <WorkingState title={title} label="Rendering audio" action={backOut} />;
case "awaiting_brief":
// The gate lives right in the chat: the form is the card, so there
// is nothing to open and nothing to dismiss.
@ -136,12 +258,15 @@ function LivePodcastCard({
}
return (
<div className="my-4 max-w-xl overflow-hidden rounded-2xl border bg-muted/30">
<div className="px-5 pt-5 pb-3 select-none">
<p className="text-sm font-semibold text-foreground line-clamp-2">{title}</p>
<p className="text-xs text-muted-foreground mt-0.5">
Confirm the language, voices, and length the episode generates automatically after
you approve.
</p>
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-3 select-none">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground line-clamp-2">{title}</p>
<p className="text-xs text-muted-foreground mt-0.5">
Confirm the language, voices, and length the episode generates automatically after
you approve.
</p>
</div>
{backOut}
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">

View file

@ -43,6 +43,14 @@ class PodcastsApiService {
return baseApiService.post(`${BASE}/${podcastId}/transcript/regenerate`, podcastDetail);
};
// Backs out of a regeneration: the podcast returns to ready with its
// existing audio untouched. 409 when there is no episode to fall back to.
revertRegeneration = async (podcastId: number) => {
return baseApiService.post(`${BASE}/${podcastId}/regenerate/revert`, podcastDetail);
};
// Only for podcasts that have produced nothing yet; once an episode
// exists the backend refuses (409) and revertRegeneration is the way back.
cancel = async (podcastId: number) => {
return baseApiService.post(`${BASE}/${podcastId}/cancel`, podcastDetail);
};