From eb56acc407058422794e0e3f7f157ebb7131f5c0 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 11 Jun 2026 11:45:17 +0200 Subject: [PATCH] refactor(podcasts): regenerate via brief gate, render brief inline in chat --- .../system_prompt/prompts/routing.md | 26 +++--- .../builtins/deliverables/tools/podcast.py | 21 +++-- surfsense_backend/app/podcasts/api/routes.py | 3 +- .../persistence/enums/podcast_status.py | 4 +- surfsense_backend/app/podcasts/service.py | 15 ++-- .../deliverables/generate_podcast/emission.py | 4 +- .../deliverables/generate_podcast/thinking.py | 6 +- .../integration/podcasts/test_regeneration.py | 32 +++++-- .../integration/podcasts/test_render_task.py | 1 + .../tool-ui/podcast/brief-review.tsx | 34 ++++---- .../tool-ui/podcast/generate-podcast.tsx | 84 +++++++------------ .../tool-ui/podcast/review-sheet.tsx | 51 ----------- .../lib/apis/podcasts-api.service.ts | 3 +- 13 files changed, 116 insertions(+), 168 deletions(-) delete mode 100644 surfsense_web/components/tool-ui/podcast/review-sheet.tsx diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/routing.md b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/routing.md index 28cf0ac63..aa6217041 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/routing.md +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/main_agent/system_prompt/prompts/routing.md @@ -126,23 +126,25 @@ user: "Create issues in Linear for each of these five bugs: " 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 '' 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 diff --git a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py index 5b70eee81..d8d28ceb1 100644 --- a/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py +++ b/surfsense_backend/app/agents/chat/multi_agent_chat/subagents/builtins/deliverables/tools/podcast.py @@ -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( diff --git a/surfsense_backend/app/podcasts/api/routes.py b/surfsense_backend/app/podcasts/api/routes.py index af378e1c2..e08ad562a 100644 --- a/surfsense_backend/app/podcasts/api/routes.py +++ b/surfsense_backend/app/podcasts/api/routes.py @@ -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) diff --git a/surfsense_backend/app/podcasts/persistence/enums/podcast_status.py b/surfsense_backend/app/podcasts/persistence/enums/podcast_status.py index 347c0422a..61b0d72b3 100644 --- a/surfsense_backend/app/podcasts/persistence/enums/podcast_status.py +++ b/surfsense_backend/app/podcasts/persistence/enums/podcast_status.py @@ -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. diff --git a/surfsense_backend/app/podcasts/service.py b/surfsense_backend/app/podcasts/service.py index 9afb0ab86..89b36a2a8 100644 --- a/surfsense_backend/app/podcasts/service.py +++ b/surfsense_backend/app/podcasts/service.py @@ -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 diff --git a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/deliverables/generate_podcast/emission.py b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/deliverables/generate_podcast/emission.py index 84f6ac4fc..86f453bc7 100644 --- a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/deliverables/generate_podcast/emission.py +++ b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/deliverables/generate_podcast/emission.py @@ -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"): diff --git a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/deliverables/generate_podcast/thinking.py b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/deliverables/generate_podcast/thinking.py index 06dfc656b..fe8f9cfb7 100644 --- a/surfsense_backend/app/tasks/chat/streaming/handlers/tools/deliverables/generate_podcast/thinking.py +++ b/surfsense_backend/app/tasks/chat/streaming/handlers/tools/deliverables/generate_podcast/thinking.py @@ -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 = ( diff --git a/surfsense_backend/tests/integration/podcasts/test_regeneration.py b/surfsense_backend/tests/integration/podcasts/test_regeneration.py index 9874fe0f2..7c2118dfb 100644 --- a/surfsense_backend/tests/integration/podcasts/test_regeneration.py +++ b/surfsense_backend/tests/integration/podcasts/test_regeneration.py @@ -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( diff --git a/surfsense_backend/tests/integration/podcasts/test_render_task.py b/surfsense_backend/tests/integration/podcasts/test_render_task.py index 7fa542ed5..c93314aa4 100644 --- a/surfsense_backend/tests/integration/podcasts/test_render_task.py +++ b/surfsense_backend/tests/integration/podcasts/test_render_task.py @@ -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) diff --git a/surfsense_web/components/tool-ui/podcast/brief-review.tsx b/surfsense_web/components/tool-ui/podcast/brief-review.tsx index 9d244d191..e81c4cc45 100644 --- a/surfsense_web/components/tool-ui/podcast/brief-review.tsx +++ b/surfsense_web/components/tool-ui/podcast/brief-review.tsx @@ -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 & draft transcript + {isDirty ? "Approve changes & draft transcript" : "Approve & draft transcript"} </Button> </div> </div> diff --git a/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx b/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx index 36e8b8735..41fc34bb4 100644 --- a/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx +++ b/surfsense_web/components/tool-ui/podcast/generate-podcast.tsx @@ -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, diff --git a/surfsense_web/components/tool-ui/podcast/review-sheet.tsx b/surfsense_web/components/tool-ui/podcast/review-sheet.tsx deleted file mode 100644 index 3253e3156..000000000 --- a/surfsense_web/components/tool-ui/podcast/review-sheet.tsx +++ /dev/null @@ -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> - ); -} diff --git a/surfsense_web/lib/apis/podcasts-api.service.ts b/surfsense_web/lib/apis/podcasts-api.service.ts index df9fb0260..7269e216f 100644 --- a/surfsense_web/lib/apis/podcasts-api.service.ts +++ b/surfsense_web/lib/apis/podcasts-api.service.ts @@ -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); };