mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-12 20:45:20 +02:00
refactor(podcasts): regenerate via brief gate, render brief inline in chat
This commit is contained in:
parent
11a6b178a0
commit
eb56acc407
13 changed files with 116 additions and 168 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue