From 3eb7cdb2d8588ce75f9c5d932904e3d29b940444 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 10 Jun 2026 21:44:50 +0200 Subject: [PATCH] refactor(podcasts): gate chat-triggered podcast on brief review --- .../builtins/deliverables/tools/podcast.py | 119 ++++++------------ .../deliverables/generate_podcast/emission.py | 20 +-- .../deliverables/generate_podcast/thinking.py | 24 ++-- 3 files changed, 59 insertions(+), 104 deletions(-) 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 bfa3cc100..5b70eee81 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,11 +1,9 @@ """Factory for a podcast-generation tool. -Dispatches the heavy generation to Celery and then polls the podcast row -until it reaches a terminal status (READY/FAILED). The tool always -returns a real terminal ``Receipt`` — never a pending one. The wait is -bounded by the existing per-invocation safety net -(``SURFSENSE_SUBAGENT_INVOKE_TIMEOUT_SECONDS`` in multi-agent mode, -HTTP / process lifetime in single-agent mode). +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. """ import logging @@ -18,13 +16,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.agents.chat.multi_agent_chat.shared.receipts.command import with_receipt from app.agents.chat.multi_agent_chat.shared.receipts.receipt import make_receipt -from app.agents.chat.multi_agent_chat.subagents.builtins.deliverables.deliverable_wait import ( - wait_for_deliverable, -) from app.agents.chat.multi_agent_chat.subagents.builtins.deliverables.tools.thread_resolver import ( resolve_root_thread_id, ) -from app.db import Podcast, PodcastStatus, shielded_async_session +from app.db import PodcastStatus, shielded_async_session +from app.podcasts.generation.brief import propose_brief +from app.podcasts.service import PodcastService logger = logging.getLogger(__name__) @@ -45,7 +42,7 @@ def create_generate_podcast_tool( user_prompt: str | None = None, ) -> Command: """ - Generate a podcast from the provided content. + Prepare a podcast from the provided content for the user to review. Use this tool when the user asks to create, generate, or make a podcast. Common triggers include phrases like: @@ -55,100 +52,55 @@ def create_generate_podcast_tool( - "Make a podcast about..." - "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. + Args: source_content: The text content to convert into a podcast. podcast_title: Title for the podcast (default: "SurfSense Podcast") - user_prompt: Optional instructions for podcast style, tone, or format. + user_prompt: Optional steer for what the episode should focus on. Returns: A dictionary containing: - - status: PodcastStatus value (pending, generating, or failed) - - podcast_id: The podcast ID for polling (when status is pending or generating) - - title: The podcast title - - message: Status message (or "error" field if status is failed) + - status: the podcast lifecycle status (awaiting_brief on success) + - podcast_id: the podcast ID to review in the panel + - title: the podcast title + - message: what the user should do next (or "error" when failed) """ try: # One DB session per tool call so parallel invocations never share an AsyncSession. async with shielded_async_session() as session: - podcast = Podcast( + service = PodcastService(session) + podcast = await service.create( title=podcast_title, - status=PodcastStatus.PENDING, search_space_id=search_space_id, thread_id=resolve_root_thread_id(runtime, thread_id), ) - session.add(podcast) + podcast.source_content = source_content + spec = await propose_brief( + session, + search_space_id=search_space_id, + focus=user_prompt, + ) + await service.attach_brief(podcast, spec) await session.commit() - await session.refresh(podcast) podcast_id = podcast.id - from app.tasks.celery_tasks.podcast_tasks import ( - generate_content_podcast_task, - ) - - task = generate_content_podcast_task.delay( - podcast_id=podcast_id, - source_content=source_content, - search_space_id=search_space_id, - user_prompt=user_prompt, - ) - logger.info( - "[generate_podcast] Created podcast %s, task: %s", + "[generate_podcast] Prepared podcast %s awaiting brief review", podcast_id, - task.id, ) - # Wait until the Celery worker flips the row to a terminal - # state. The wait is bounded only by the subagent invoke - # timeout (multi-agent) or HTTP lifetime (single-agent) — - # see app.agents.chat.multi_agent_chat.subagents.builtins.deliverables.deliverable_wait for details. - terminal_status, columns, elapsed = await wait_for_deliverable( - model=Podcast, - row_id=podcast_id, - columns=[Podcast.status, Podcast.file_location], - terminal_statuses={PodcastStatus.READY, PodcastStatus.FAILED}, - ) - - if terminal_status == PodcastStatus.READY: - file_location = columns[1] if columns else None - logger.info( - "[generate_podcast] Podcast %s READY in %.2fs (file=%s)", - podcast_id, - elapsed, - file_location, - ) - payload: dict[str, Any] = { - "status": PodcastStatus.READY.value, - "podcast_id": podcast_id, - "title": podcast_title, - "file_location": file_location, - "message": ("Podcast generated and saved to your podcast panel."), - } - return with_receipt( - payload=payload, - receipt=make_receipt( - route="deliverables", - type="podcast", - operation="generate", - status="success", - external_id=str(podcast_id), - preview=podcast_title, - ), - tool_call_id=runtime.tool_call_id, - ) - - # Only other terminal state is FAILED. - logger.warning( - "[generate_podcast] Podcast %s FAILED in %.2fs", - podcast_id, - elapsed, - ) - err = "Background worker reported FAILED status for this podcast." - payload = { - "status": PodcastStatus.FAILED.value, + payload: dict[str, Any] = { + "status": PodcastStatus.AWAITING_BRIEF.value, "podcast_id": podcast_id, "title": podcast_title, - "error": err, + "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." + ), } return with_receipt( payload=payload, @@ -156,10 +108,9 @@ def create_generate_podcast_tool( route="deliverables", type="podcast", operation="generate", - status="failed", + status="success", external_id=str(podcast_id), preview=podcast_title, - error=err, ), tool_call_id=runtime.tool_call_id, ) 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 f1a1e9c37..84f6ac4fc 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 @@ -15,22 +15,26 @@ def iter_completion_emission_frames( out = ctx.tool_output payload = out if isinstance(out, dict) else {"result": out} yield ctx.emit_tool_output_card(payload) - if isinstance(out, dict) and out.get("status") in ( + status = out.get("status") if isinstance(out, dict) else None + title = out.get("title", "Podcast") if isinstance(out, dict) else "Podcast" + if status in ( + "awaiting_brief", + "awaiting_review", "pending", - "generating", - "processing", + "drafting", + "rendering", ): yield ctx.streaming_service.format_terminal_info( - f"Podcast queued: {out.get('title', 'Podcast')}", + f"Podcast brief ready to review: {title}", "success", ) - elif isinstance(out, dict) and out.get("status") in ("ready", "success"): + elif status in ("ready", "success"): yield ctx.streaming_service.format_terminal_info( - f"Podcast generated successfully: {out.get('title', 'Podcast')}", + f"Podcast generated successfully: {title}", "success", ) - elif isinstance(out, dict) and out.get("status") in ("failed", "error"): - error_msg = out.get("error", "Unknown error") + elif status in ("failed", "error"): + error_msg = out.get("error", "Unknown error") if isinstance(out, dict) else "Unknown error" yield ctx.streaming_service.format_terminal_info( f"Podcast generation failed: {error_msg}", "error", 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 5cf78ea72..06dfc656b 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 @@ -24,11 +24,11 @@ def resolve_start_thinking(tool_name: str, tool_input: Any) -> ToolStartThinking d.get("source_content", "") if isinstance(tool_input, dict) else "" ) return ToolStartThinking( - title="Generating podcast", + title="Preparing podcast", items=[ f"Title: {podcast_title}", f"Content: {content_len:,} characters", - "Preparing audio generation...", + "Proposing brief (language, voices, length)...", ], ) @@ -50,17 +50,17 @@ def resolve_completed_thinking( if isinstance(tool_output, dict) else "Podcast" ) - if podcast_status in ("pending", "generating", "processing"): + if podcast_status in ( + "awaiting_brief", + "awaiting_review", + "pending", + "drafting", + "rendering", + ): completed = [ f"Title: {podcast_title}", - "Podcast generation started", - "Processing in background...", - ] - elif podcast_status == "already_generating": - completed = [ - f"Title: {podcast_title}", - "Podcast already in progress", - "Please wait for it to complete", + "Brief ready for review", + "Approve it in the podcast panel to generate", ] elif podcast_status in ("failed", "error"): error_msg = ( @@ -79,4 +79,4 @@ def resolve_completed_thinking( ] else: completed = items - return ("Generating podcast", completed) + return ("Preparing podcast", completed)