mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-12 20:45:20 +02:00
feat(podcasts): constrain monologue briefs to a single speaker
This commit is contained in:
parent
eb56acc407
commit
f0fc660d70
4 changed files with 55 additions and 14 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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]>) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
|
|
@ -198,12 +208,7 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
|
|||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="podcast-style">Style</Label>
|
||||
<Select
|
||||
value={draft.style}
|
||||
onValueChange={(value) =>
|
||||
setDraft((current) => ({ ...current, style: value as PodcastStyle }))
|
||||
}
|
||||
>
|
||||
<Select value={draft.style} onValueChange={(value) => setStyle(value as PodcastStyle)}>
|
||||
<SelectTrigger id="podcast-style">
|
||||
<SelectValue placeholder="Style" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -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}
|
||||
>
|
||||
<Plus className="size-4" /> Add speaker
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -61,13 +61,19 @@ export const durationTarget = z.object({
|
|||
});
|
||||
export type DurationTarget = z.infer<typeof durationTarget>;
|
||||
|
||||
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<typeof podcastSpec>;
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue