refactor(podcasts): regenerate via brief gate, render brief inline in chat

This commit is contained in:
CREDO23 2026-06-11 11:45:17 +02:00
parent 11a6b178a0
commit eb56acc407
13 changed files with 116 additions and 168 deletions

View file

@ -126,23 +126,25 @@ user: "Create issues in Linear for each of these five bugs: <list>"
<example>
user: "Make a 30-second podcast of this conversation."
Celery-backed deliverable. The `deliverables` subagent dispatches the
Celery job and then **waits for it to finish** before returning. The
call may take 10-60 seconds (or longer for video presentations) —
that is intentional, not a hang. You always get back one of two
Receipt shapes:
Podcast deliverable. The `deliverables` subagent sets the podcast up and
returns **immediately** — generation does not happen during the call. A
live card in the chat takes over from there: the user reviews the brief
(language, voices, length) on the card, and the episode drafts and
renders automatically after they approve.
task(deliverables, "Generate a podcast titled '<title>' from the
following content. Use a 30-second style brief. Return the podcast
id and title.\n\n<source content>")
following content. Aim for a 30-second style brief. Return the
podcast id and title.\n\n<source content>")
Outcomes:
- **`status="success"`**: the audio is saved. Tell the user the
podcast is **ready** and quote the `external_id` / `preview` so
they can find it in the podcast panel.
- **`status="success"`**: the podcast is set up. Do NOT describe its
current status or promise it is ready — the card tracks progress
live and will outlive whatever you say. Just point the user at the
card in the chat.
- **`status="failed"`**: surface the Receipt's `error` field
verbatim. Do NOT silently re-dispatch — the backend already tried
and reported a real error.
Same two-way pattern applies to video presentations (which take
longer to render, but still return a terminal status). If a
Video presentations differ: that Celery-backed call **waits for the
render to finish** before returning (possibly minutes — intentional,
not a hang) and ends with a terminal status. If a
`task(deliverables, ...)` invocation itself times out at the subagent
layer (separate from the Receipt), that's an operator-side problem
with the subagent invoke timeout, not a deliverable failure — pass

View file

@ -1,9 +1,10 @@
"""Factory for a podcast-generation tool.
Creates the podcast and proposes its brief (language, voices, length) inline,
then returns immediately with the row awaiting review. The user approves the
brief and the drafted transcript in the podcast panel before any audio is
rendered, so this tool never blocks on generation.
then returns immediately with the row awaiting review. Everything after
brief approval, drafting, rendering happens on the live podcast card, so
this tool never blocks on generation and the chat text must not describe a
status that the card will outgrow.
"""
import logging
@ -53,8 +54,10 @@ def create_generate_podcast_tool(
- "Turn this into a podcast"
This sets up the podcast and proposes its brief (language, voices,
length). The user then reviews and approves the brief and transcript in
the podcast panel to produce the audio generation does not start here.
length). The user reviews the brief on the live podcast card in the
chat; after approval the episode drafts and renders automatically.
Generation does not start here, and the card tracks all progress do
not describe the podcast's current status in your reply.
Args:
source_content: The text content to convert into a podcast.
@ -97,9 +100,11 @@ def create_generate_podcast_tool(
"podcast_id": podcast_id,
"title": podcast_title,
"message": (
"I've prepared a podcast brief — review the language, "
"voices, and length in the podcast panel, then approve it "
"to draft and generate the episode."
"Podcast set up. The card in the chat handles the rest: "
"the user reviews the brief (language, voices, length) "
"there, and the episode drafts and renders automatically "
"after approval. The card tracks progress live, so do not "
"state the podcast's current status in your reply."
),
}
return with_receipt(

View file

@ -213,12 +213,11 @@ async def regenerate_transcript(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Send a finished episode back to drafting for a fresh take."""
"""Reopen the brief gate for a fresh take; drafting waits for re-approval."""
podcast = await _load(session, user, podcast_id, Permission.PODCASTS_UPDATE)
async with _lifecycle_errors():
await PodcastService(session).regenerate(podcast)
await session.commit()
draft_transcript_task.delay(podcast.id, podcast.search_space_id)
return PodcastDetail.of(podcast)

View file

@ -3,8 +3,8 @@
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
drafting for regeneration. ``AWAITING_REVIEW`` is retained for legacy rows but
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
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

@ -30,13 +30,14 @@ _ALLOWED: dict[PodcastStatus, frozenset[PodcastStatus]] = {
# Never entered anymore (the transcript gate was dropped); kept with exits
# so legacy rows aren't stranded.
PodcastStatus.AWAITING_REVIEW: frozenset(
{PodcastStatus.DRAFTING, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
{PodcastStatus.AWAITING_BRIEF, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
),
PodcastStatus.RENDERING: frozenset(
{PodcastStatus.READY, PodcastStatus.FAILED, PodcastStatus.CANCELLED}
),
# Not terminal: regeneration is decided by listening to the finished episode.
PodcastStatus.READY: frozenset({PodcastStatus.DRAFTING}),
# Not terminal: regeneration reopens the brief gate so the user can tweak
# the spec before a new take is drafted.
PodcastStatus.READY: frozenset({PodcastStatus.AWAITING_BRIEF}),
PodcastStatus.FAILED: frozenset(),
PodcastStatus.CANCELLED: frozenset(),
}
@ -125,17 +126,17 @@ class PodcastService:
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.
# Guards regenerate beyond the transition table: from PENDING the
# AWAITING_BRIEF target is also legal, but there it means attaching a brief.
_REGENERABLE = frozenset({PodcastStatus.READY, PodcastStatus.AWAITING_REVIEW})
async def regenerate(self, podcast: Podcast) -> Podcast:
"""Send the episode back to drafting for a fresh transcript and render."""
"""Reopen the brief gate; the saved spec becomes the new starting point."""
if _status(podcast) not in self._REGENERABLE:
raise InvalidTransition(
f"nothing to regenerate from {_status(podcast).value}"
)
self._transition(podcast, PodcastStatus.DRAFTING)
self._transition(podcast, PodcastStatus.AWAITING_BRIEF)
await self._session.flush()
return podcast

View file

@ -24,8 +24,10 @@ def iter_completion_emission_frames(
"drafting",
"rendering",
):
# This line is persisted with the chat while the podcast keeps moving,
# so it must stay true after the lifecycle outgrows today's status.
yield ctx.streaming_service.format_terminal_info(
f"Podcast brief ready to review: {title}",
f"Podcast created: {title}",
"success",
)
elif status in ("ready", "success"):

View file

@ -57,10 +57,12 @@ def resolve_completed_thinking(
"drafting",
"rendering",
):
# Persisted with the chat while the podcast keeps moving, so the copy
# must stay true after the lifecycle outgrows today's status.
completed = [
f"Title: {podcast_title}",
"Brief ready for review",
"Approve it in the podcast panel to generate",
"Podcast created",
"Review and progress continue on the podcast card",
]
elif podcast_status in ("failed", "error"):
error_msg = (

View file

@ -1,9 +1,10 @@
"""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.
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.
"""
from __future__ import annotations
@ -17,7 +18,7 @@ pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts"
async def test_regenerate_from_ready_returns_to_drafting_and_enqueues_draft(
async def test_regenerate_from_ready_reopens_the_brief_gate(
client, db_search_space, make_podcast, captured_tasks
):
podcast = await make_podcast(
@ -26,10 +27,29 @@ async def test_regenerate_from_ready_returns_to_drafting_and_enqueues_draft(
resp = await client.post(f"{BASE}/{podcast.id}/transcript/regenerate")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "awaiting_brief"
# The prior brief is kept as the starting point for the new take.
assert body["spec"] is not None
# Nothing drafts until the user approves the reopened brief.
assert captured_tasks.draft == []
assert captured_tasks.render == []
async def test_approving_the_reopened_brief_starts_a_fresh_draft(
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}/brief/approve")
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(

View file

@ -54,6 +54,7 @@ async def test_rerender_replaces_audio_and_purges_the_old_object(
service = PodcastService(db_session)
await service.regenerate(podcast)
await service.begin_drafting(podcast)
await service.attach_transcript(podcast, build_transcript())
result = await render._render_audio(podcast.id)

View file

@ -44,15 +44,16 @@ function primary(language: string): string {
interface BriefReviewProps {
podcast: LivePodcast;
spec: PodcastSpec;
onApproved: () => void;
}
/**
* Gate 1: the pre-filled brief as a near-confirmation. One-click approve is
* the easy path; every field stays overridable and saves through the
* version-guarded PATCH so concurrent edits surface instead of clobbering.
* The brief gate, rendered inline in the chat card: a pre-filled
* near-confirmation. One-click approve is the easy path; every field stays
* overridable and saves through the version-guarded PATCH so concurrent edits
* surface instead of clobbering. Approval needs no local follow-up the
* pushed status flips the card to its drafting state.
*/
export function BriefReview({ podcast, spec, onApproved }: BriefReviewProps) {
export function BriefReview({ podcast, spec }: BriefReviewProps) {
const [draft, setDraft] = useState<PodcastSpec>(spec);
const [voices, setVoices] = useState<VoiceOption[] | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
@ -165,23 +166,11 @@ export function BriefReview({ podcast, spec, onApproved }: BriefReviewProps) {
}
};
const handleSave = async () => {
setIsSubmitting(true);
try {
if (await saveIfDirty()) {
toast.success("Brief saved.");
}
} finally {
setIsSubmitting(false);
}
};
const handleApprove = async () => {
setIsSubmitting(true);
try {
if (!(await saveIfDirty())) return;
await podcastsApiService.approveBrief(podcast.id);
onApproved();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to approve the brief");
} finally {
@ -359,8 +348,13 @@ export function BriefReview({ podcast, spec, onApproved }: BriefReviewProps) {
<div className="flex justify-end gap-2">
{isDirty ? (
<Button type="button" variant="outline" onClick={handleSave} disabled={isSubmitting}>
Save changes
<Button
type="button"
variant="ghost"
onClick={() => setDraft(spec)}
disabled={isSubmitting}
>
Discard
</Button>
) : null}
<Button
@ -369,7 +363,7 @@ export function BriefReview({ podcast, spec, onApproved }: BriefReviewProps) {
disabled={isSubmitting || draft.duration.max_minutes < draft.duration.min_minutes}
>
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
Approve &amp; draft transcript
{isDirty ? "Approve changes & draft transcript" : "Approve & draft transcript"}
</Button>
</div>
</div>

View file

@ -7,11 +7,10 @@ import { useState } from "react";
import { toast } from "sonner";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import type { PodcastSpec } from "@/contracts/types/podcast.types";
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 { PodcastReviewSheet } from "./review-sheet";
import type { GeneratePodcastArgs, GeneratePodcastResult } from "./schema";
function WorkingState({ title, label }: { title: string; label: string }) {
@ -36,45 +35,9 @@ function NoticeState({ title, message }: { title: string; message: string }) {
);
}
function briefSummary(spec: PodcastSpec | null): string | null {
if (!spec) return null;
const speakers = spec.speakers.length === 1 ? "1 speaker" : `${spec.speakers.length} speakers`;
return `${spec.language} · ${speakers} · ${spec.duration.min_minutes}${spec.duration.max_minutes} min`;
}
function ReviewGateCard({
title,
heading,
summary,
buttonLabel,
onReview,
}: {
title: string;
heading: string;
summary: string | null;
buttonLabel: string;
onReview: () => void;
}) {
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>
<p className="text-xs text-muted-foreground mt-0.5">{heading}</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="flex items-center justify-between gap-3 px-5 py-4">
<p className="text-xs text-muted-foreground">{summary}</p>
<Button type="button" size="sm" onClick={onReview}>
{buttonLabel}
</Button>
</div>
</div>
);
}
/**
* Regenerating discards the current audio, so a stray click is guarded by an
* inline confirm step.
* Regenerating reopens the brief and ultimately replaces the current audio,
* so a stray click is guarded by an inline confirm step.
*/
function RegenerateButton({ podcast }: { podcast: LivePodcast }) {
const [confirming, setConfirming] = useState(false);
@ -108,7 +71,9 @@ function RegenerateButton({ podcast }: { podcast: LivePodcast }) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Replace this episode with a new take?</span>
<span className="text-xs text-muted-foreground">
Reopen the brief and replace this episode?
</span>
<Button
type="button"
variant="ghost"
@ -141,7 +106,6 @@ function LivePodcastCard({
fallbackTitle: string;
}) {
const { podcast, isLoading } = usePodcastLive(podcastId);
const [reviewOpen, setReviewOpen] = useState(false);
if (!podcast) {
if (isLoading) {
@ -165,21 +129,29 @@ function LivePodcastCard({
case "rendering":
return <WorkingState title={title} label="Rendering audio" />;
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.
if (!podcast.spec) {
return <WorkingState title={title} label="Preparing brief" />;
}
return (
<>
<ReviewGateCard
title={title}
heading="Brief ready for your review"
summary={briefSummary(podcast.spec)}
buttonLabel="Review brief"
onReview={() => setReviewOpen(true)}
/>
<PodcastReviewSheet podcast={podcast} open={reviewOpen} onOpenChange={setReviewOpen} />
</>
<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>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<BriefReview podcast={podcast} spec={podcast.spec} />
</div>
</div>
);
case "awaiting_review":
// Legacy rows parked at the removed transcript gate; the only way
// forward is a fresh draft.
// forward is regenerating through the brief gate.
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">
@ -217,9 +189,9 @@ function LivePodcastCard({
/**
* Tool UI for `generate_podcast`. The tool only prepares the podcast (it
* returns with the brief awaiting review), so this card follows the lifecycle
* by Zero push and opens the review panel at each gate. Public shared chats
* have no Zero session; their snapshots only ever contain finished episodes,
* so the player renders directly against the share-token endpoints.
* by Zero push, rendering the brief form inline at the gate. Public shared
* chats have no Zero session; their snapshots only ever contain finished
* episodes, so the player renders directly against the share-token endpoints.
*/
export const GeneratePodcastToolUI = ({
args,

View file

@ -1,51 +0,0 @@
"use client";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import type { LivePodcast } from "@/hooks/use-podcast-live";
import { BriefReview } from "./brief-review";
interface PodcastReviewSheetProps {
podcast: LivePodcast;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* The podcast panel: hosts the brief gate, the only approval in the lifecycle
* after it the episode generates unattended.
*/
export function PodcastReviewSheet({ podcast, open, onOpenChange }: PodcastReviewSheetProps) {
const close = () => onOpenChange(false);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-full flex-col sm:max-w-xl">
{podcast.status === "awaiting_brief" && podcast.spec ? (
<>
<SheetHeader>
<SheetTitle>Review podcast brief</SheetTitle>
<SheetDescription>
Confirm the language, voices, and length the episode generates unattended after
this.
</SheetDescription>
</SheetHeader>
<div className="overflow-y-auto px-4 pb-4">
<BriefReview podcast={podcast} spec={podcast.spec} onApproved={close} />
</div>
</>
) : (
<SheetHeader>
<SheetTitle>{podcast.title}</SheetTitle>
<SheetDescription>Nothing is awaiting review right now.</SheetDescription>
</SheetHeader>
)}
</SheetContent>
</Sheet>
);
}

View file

@ -37,7 +37,8 @@ class PodcastsApiService {
return baseApiService.post(`${BASE}/${podcastId}/brief/approve`, podcastDetail);
};
// Destructive: the current transcript and audio are replaced by a fresh take.
// Reopens the brief gate; the transcript and audio are replaced once the
// user re-approves.
regenerate = async (podcastId: number) => {
return baseApiService.post(`${BASE}/${podcastId}/transcript/regenerate`, podcastDetail);
};