From f0fc660d7047ff22c6cb442297d0e827409e0ba9 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 11 Jun 2026 11:56:57 +0200 Subject: [PATCH] feat(podcasts): constrain monologue briefs to a single speaker --- .../app/podcasts/schemas/spec.py | 8 +++++++ .../tests/unit/podcasts/test_spec.py | 22 +++++++++++++++++++ .../tool-ui/podcast/brief-review.tsx | 19 ++++++++++------ .../contracts/types/podcast.types.ts | 20 +++++++++++------ 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/surfsense_backend/app/podcasts/schemas/spec.py b/surfsense_backend/app/podcasts/schemas/spec.py index 973e26167..a2c68e359 100644 --- a/surfsense_backend/app/podcasts/schemas/spec.py +++ b/surfsense_backend/app/podcasts/schemas/spec.py @@ -148,6 +148,14 @@ class PodcastSpec(BaseModel): raise ValueError("speaker slots must be unique") return self + @model_validator(mode="after") + def _check_style_speakers(self) -> PodcastSpec: + # One voice is what "monologue" means; letting extra speakers through + # would force drafting to silently pick a winner. + if self.style is PodcastStyle.MONOLOGUE and len(self.speakers) != 1: + raise ValueError("a monologue has exactly one speaker") + return self + def speaker_for(self, slot: int) -> SpeakerSpec: """Return the speaker bound to ``slot`` or raise if none matches.""" for speaker in self.speakers: diff --git a/surfsense_backend/tests/unit/podcasts/test_spec.py b/surfsense_backend/tests/unit/podcasts/test_spec.py index 938515988..4efd530e9 100644 --- a/surfsense_backend/tests/unit/podcasts/test_spec.py +++ b/surfsense_backend/tests/unit/podcasts/test_spec.py @@ -14,6 +14,7 @@ from pydantic import ValidationError from app.podcasts.schemas import ( DurationTarget, PodcastSpec, + PodcastStyle, SpeakerRole, SpeakerSpec, Transcript, @@ -80,6 +81,27 @@ def test_a_brief_needs_at_least_one_speaker(): ) +def test_a_monologue_brief_carries_exactly_one_speaker(): + spec = PodcastSpec( + language="en", + style=PodcastStyle.MONOLOGUE, + speakers=[_speaker(0)], + duration=DurationTarget(min_minutes=5, max_minutes=10), + ) + assert spec.style is PodcastStyle.MONOLOGUE + + +def test_a_monologue_brief_rejects_multiple_speakers(): + """One voice is what 'monologue' means; a second speaker is a user error.""" + with pytest.raises(ValidationError): + PodcastSpec( + language="en", + style=PodcastStyle.MONOLOGUE, + speakers=[_speaker(0), _speaker(1, voice_id="kokoro:af_bella")], + duration=DurationTarget(min_minutes=5, max_minutes=10), + ) + + def test_duration_rejects_an_inverted_range(): """A max below the min is a user error caught at the brief gate.""" with pytest.raises(ValidationError): diff --git a/surfsense_web/components/tool-ui/podcast/brief-review.tsx b/surfsense_web/components/tool-ui/podcast/brief-review.tsx index e81c4cc45..3473b64d6 100644 --- a/surfsense_web/components/tool-ui/podcast/brief-review.tsx +++ b/surfsense_web/components/tool-ui/podcast/brief-review.tsx @@ -110,6 +110,16 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) { }); }; + const setStyle = (style: PodcastStyle) => { + setDraft((current) => ({ + ...current, + style, + // A monologue has exactly one speaker, so extra speakers are dropped + // rather than letting approval fail validation. + speakers: style === "monologue" ? current.speakers.slice(0, 1) : current.speakers, + })); + }; + const updateSpeaker = (slot: number, change: Partial) => { setDraft((current) => ({ ...current, @@ -198,12 +208,7 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
- setStyle(value as PodcastStyle)}> @@ -226,7 +231,7 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) { variant="ghost" size="sm" onClick={addSpeaker} - disabled={draft.speakers.length >= MAX_SPEAKERS} + disabled={draft.style === "monologue" || draft.speakers.length >= MAX_SPEAKERS} > Add speaker diff --git a/surfsense_web/contracts/types/podcast.types.ts b/surfsense_web/contracts/types/podcast.types.ts index 7cf12a6dd..e6332d5b2 100644 --- a/surfsense_web/contracts/types/podcast.types.ts +++ b/surfsense_web/contracts/types/podcast.types.ts @@ -61,13 +61,19 @@ export const durationTarget = z.object({ }); export type DurationTarget = z.infer; -export const podcastSpec = z.object({ - language: z.string().min(2), - style: podcastStyle, - speakers: z.array(speakerSpec).min(1).max(MAX_SPEAKERS), - duration: durationTarget, - focus: z.string().max(2000).nullable().optional(), -}); +export const podcastSpec = z + .object({ + language: z.string().min(2), + style: podcastStyle, + speakers: z.array(speakerSpec).min(1).max(MAX_SPEAKERS), + duration: durationTarget, + focus: z.string().max(2000).nullable().optional(), + }) + // Mirrors the backend invariant: one voice is what "monologue" means. + .refine((spec) => spec.style !== "monologue" || spec.speakers.length === 1, { + message: "A monologue has exactly one speaker", + path: ["speakers"], + }); export type PodcastSpec = z.infer; // =============================================================================