feat(podcasts): constrain monologue briefs to a single speaker

This commit is contained in:
CREDO23 2026-06-11 11:56:57 +02:00
parent eb56acc407
commit f0fc660d70
4 changed files with 55 additions and 14 deletions

View file

@ -148,6 +148,14 @@ class PodcastSpec(BaseModel):
raise ValueError("speaker slots must be unique") raise ValueError("speaker slots must be unique")
return self 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: def speaker_for(self, slot: int) -> SpeakerSpec:
"""Return the speaker bound to ``slot`` or raise if none matches.""" """Return the speaker bound to ``slot`` or raise if none matches."""
for speaker in self.speakers: for speaker in self.speakers:

View file

@ -14,6 +14,7 @@ from pydantic import ValidationError
from app.podcasts.schemas import ( from app.podcasts.schemas import (
DurationTarget, DurationTarget,
PodcastSpec, PodcastSpec,
PodcastStyle,
SpeakerRole, SpeakerRole,
SpeakerSpec, SpeakerSpec,
Transcript, 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(): def test_duration_rejects_an_inverted_range():
"""A max below the min is a user error caught at the brief gate.""" """A max below the min is a user error caught at the brief gate."""
with pytest.raises(ValidationError): with pytest.raises(ValidationError):

View file

@ -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<PodcastSpec["speakers"][number]>) => { const updateSpeaker = (slot: number, change: Partial<PodcastSpec["speakers"][number]>) => {
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
@ -198,12 +208,7 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="podcast-style">Style</Label> <Label htmlFor="podcast-style">Style</Label>
<Select <Select value={draft.style} onValueChange={(value) => setStyle(value as PodcastStyle)}>
value={draft.style}
onValueChange={(value) =>
setDraft((current) => ({ ...current, style: value as PodcastStyle }))
}
>
<SelectTrigger id="podcast-style"> <SelectTrigger id="podcast-style">
<SelectValue placeholder="Style" /> <SelectValue placeholder="Style" />
</SelectTrigger> </SelectTrigger>
@ -226,7 +231,7 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={addSpeaker} onClick={addSpeaker}
disabled={draft.speakers.length >= MAX_SPEAKERS} disabled={draft.style === "monologue" || draft.speakers.length >= MAX_SPEAKERS}
> >
<Plus className="size-4" /> Add speaker <Plus className="size-4" /> Add speaker
</Button> </Button>

View file

@ -61,13 +61,19 @@ export const durationTarget = z.object({
}); });
export type DurationTarget = z.infer<typeof durationTarget>; export type DurationTarget = z.infer<typeof durationTarget>;
export const podcastSpec = z.object({ export const podcastSpec = z
language: z.string().min(2), .object({
style: podcastStyle, language: z.string().min(2),
speakers: z.array(speakerSpec).min(1).max(MAX_SPEAKERS), style: podcastStyle,
duration: durationTarget, speakers: z.array(speakerSpec).min(1).max(MAX_SPEAKERS),
focus: z.string().max(2000).nullable().optional(), 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<typeof podcastSpec>; export type PodcastSpec = z.infer<typeof podcastSpec>;
// ============================================================================= // =============================================================================