mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-20 21:18:13 +02:00
Merge pull request #1501 from CREDO23/feat/podcast-brief-duration-seconds
feat(podcasts): short default brief length with seconds and unit picker
This commit is contained in:
commit
284df841ef
17 changed files with 253 additions and 86 deletions
|
|
@ -157,8 +157,8 @@ async def create_podcast(
|
||||||
session,
|
session,
|
||||||
search_space_id=body.search_space_id,
|
search_space_id=body.search_space_id,
|
||||||
speaker_count=body.speaker_count,
|
speaker_count=body.speaker_count,
|
||||||
min_minutes=body.min_minutes,
|
min_seconds=body.min_seconds,
|
||||||
max_minutes=body.max_minutes,
|
max_seconds=body.max_seconds,
|
||||||
focus=body.focus,
|
focus=body.focus,
|
||||||
)
|
)
|
||||||
await service.attach_brief(podcast, spec)
|
await service.attach_brief(podcast, spec)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.podcasts.duration_limits import (
|
||||||
|
DEFAULT_MAX_SECONDS,
|
||||||
|
DEFAULT_MIN_SECONDS,
|
||||||
|
MAX_DURATION_SECONDS,
|
||||||
|
MIN_DURATION_SECONDS,
|
||||||
|
)
|
||||||
from app.podcasts.persistence import Podcast, PodcastStatus
|
from app.podcasts.persistence import Podcast, PodcastStatus
|
||||||
from app.podcasts.schemas import PodcastSpec, Transcript
|
from app.podcasts.schemas import PodcastSpec, Transcript
|
||||||
from app.podcasts.service import has_stored_episode, read_spec, read_transcript
|
from app.podcasts.service import has_stored_episode, read_spec, read_transcript
|
||||||
|
|
@ -18,8 +24,6 @@ from app.podcasts.service import has_stored_episode, read_spec, read_transcript
|
||||||
# Defaults applied when a create request omits brief sizing; the brief gate lets
|
# Defaults applied when a create request omits brief sizing; the brief gate lets
|
||||||
# the user adjust before any cost is incurred.
|
# the user adjust before any cost is incurred.
|
||||||
DEFAULT_SPEAKER_COUNT = 2
|
DEFAULT_SPEAKER_COUNT = 2
|
||||||
DEFAULT_MIN_MINUTES = 10
|
|
||||||
DEFAULT_MAX_MINUTES = 20
|
|
||||||
|
|
||||||
|
|
||||||
class CreatePodcastRequest(BaseModel):
|
class CreatePodcastRequest(BaseModel):
|
||||||
|
|
@ -30,8 +34,16 @@ class CreatePodcastRequest(BaseModel):
|
||||||
source_content: str = Field(..., min_length=1)
|
source_content: str = Field(..., min_length=1)
|
||||||
thread_id: int | None = None
|
thread_id: int | None = None
|
||||||
speaker_count: int = Field(default=DEFAULT_SPEAKER_COUNT, ge=1, le=6)
|
speaker_count: int = Field(default=DEFAULT_SPEAKER_COUNT, ge=1, le=6)
|
||||||
min_minutes: int = Field(default=DEFAULT_MIN_MINUTES, ge=1)
|
min_seconds: int = Field(
|
||||||
max_minutes: int = Field(default=DEFAULT_MAX_MINUTES, ge=1)
|
default=DEFAULT_MIN_SECONDS,
|
||||||
|
ge=MIN_DURATION_SECONDS,
|
||||||
|
le=MAX_DURATION_SECONDS,
|
||||||
|
)
|
||||||
|
max_seconds: int = Field(
|
||||||
|
default=DEFAULT_MAX_SECONDS,
|
||||||
|
ge=MIN_DURATION_SECONDS,
|
||||||
|
le=MAX_DURATION_SECONDS,
|
||||||
|
)
|
||||||
focus: str | None = Field(default=None, max_length=2000)
|
focus: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
6
surfsense_backend/app/podcasts/duration_limits.py
Normal file
6
surfsense_backend/app/podcasts/duration_limits.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""Shared bounds and defaults for podcast target duration."""
|
||||||
|
|
||||||
|
MAX_DURATION_SECONDS = 24 * 60 * 60
|
||||||
|
MIN_DURATION_SECONDS = 15
|
||||||
|
DEFAULT_MIN_SECONDS = 20
|
||||||
|
DEFAULT_MAX_SECONDS = 30
|
||||||
|
|
@ -6,10 +6,13 @@ from dataclasses import dataclass, field, fields
|
||||||
|
|
||||||
from langchain_core.runnables import RunnableConfig
|
from langchain_core.runnables import RunnableConfig
|
||||||
|
|
||||||
|
from app.podcasts.duration_limits import (
|
||||||
|
DEFAULT_MAX_SECONDS,
|
||||||
|
DEFAULT_MIN_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
# Sensible defaults for a fresh brief; the user adjusts the range at the gate.
|
# Sensible defaults for a fresh brief; the user adjusts the range at the gate.
|
||||||
DEFAULT_SPEAKER_COUNT = 2
|
DEFAULT_SPEAKER_COUNT = 2
|
||||||
DEFAULT_MIN_MINUTES = 10
|
|
||||||
DEFAULT_MAX_MINUTES = 20
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
@dataclass(kw_only=True)
|
||||||
|
|
@ -17,8 +20,8 @@ class BriefConfig:
|
||||||
"""Signals used to propose a brief; everything here is non-LLM context."""
|
"""Signals used to propose a brief; everything here is non-LLM context."""
|
||||||
|
|
||||||
speaker_count: int = DEFAULT_SPEAKER_COUNT
|
speaker_count: int = DEFAULT_SPEAKER_COUNT
|
||||||
min_minutes: int = DEFAULT_MIN_MINUTES
|
min_seconds: int = DEFAULT_MIN_SECONDS
|
||||||
max_minutes: int = DEFAULT_MAX_MINUTES
|
max_seconds: int = DEFAULT_MAX_SECONDS
|
||||||
focus: str | None = None
|
focus: str | None = None
|
||||||
last_used_language: str | None = None
|
last_used_language: str | None = None
|
||||||
last_used_voices: list[str] = field(default_factory=list)
|
last_used_voices: list[str] = field(default_factory=list)
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ def propose_spec(state: BriefState, config: RunnableConfig) -> dict[str, Any]:
|
||||||
style=PodcastStyle.CONVERSATIONAL,
|
style=PodcastStyle.CONVERSATIONAL,
|
||||||
speakers=speakers,
|
speakers=speakers,
|
||||||
duration=DurationTarget(
|
duration=DurationTarget(
|
||||||
min_minutes=brief.min_minutes, max_minutes=brief.max_minutes
|
min_seconds=brief.min_seconds, max_seconds=brief.max_seconds
|
||||||
),
|
),
|
||||||
focus=brief.focus,
|
focus=brief.focus,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,12 @@ from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.podcasts.duration_limits import DEFAULT_MAX_SECONDS, DEFAULT_MIN_SECONDS
|
||||||
from app.podcasts.persistence import PodcastRepository
|
from app.podcasts.persistence import PodcastRepository
|
||||||
from app.podcasts.schemas import PodcastSpec
|
from app.podcasts.schemas import PodcastSpec
|
||||||
from app.podcasts.service import preferences_from
|
from app.podcasts.service import preferences_from
|
||||||
|
|
||||||
from .config import DEFAULT_MAX_MINUTES, DEFAULT_MIN_MINUTES, DEFAULT_SPEAKER_COUNT
|
from .config import DEFAULT_SPEAKER_COUNT
|
||||||
from .graph import graph as brief_graph
|
from .graph import graph as brief_graph
|
||||||
from .state import BriefState
|
from .state import BriefState
|
||||||
|
|
||||||
|
|
@ -18,8 +19,8 @@ async def propose_brief(
|
||||||
*,
|
*,
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
speaker_count: int = DEFAULT_SPEAKER_COUNT,
|
speaker_count: int = DEFAULT_SPEAKER_COUNT,
|
||||||
min_minutes: int = DEFAULT_MIN_MINUTES,
|
min_seconds: int = DEFAULT_MIN_SECONDS,
|
||||||
max_minutes: int = DEFAULT_MAX_MINUTES,
|
max_seconds: int = DEFAULT_MAX_SECONDS,
|
||||||
focus: str | None = None,
|
focus: str | None = None,
|
||||||
) -> PodcastSpec:
|
) -> PodcastSpec:
|
||||||
"""Reuse the last-used language and voices, else English; return the spec."""
|
"""Reuse the last-used language and voices, else English; return the spec."""
|
||||||
|
|
@ -29,8 +30,8 @@ async def propose_brief(
|
||||||
config = {
|
config = {
|
||||||
"configurable": {
|
"configurable": {
|
||||||
"speaker_count": speaker_count,
|
"speaker_count": speaker_count,
|
||||||
"min_minutes": min_minutes,
|
"min_seconds": min_seconds,
|
||||||
"max_minutes": max_minutes,
|
"max_seconds": max_seconds,
|
||||||
"focus": focus,
|
"focus": focus,
|
||||||
"last_used_language": last_language,
|
"last_used_language": last_language,
|
||||||
"last_used_voices": last_voices,
|
"last_used_voices": last_voices,
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ async def plan_outline(
|
||||||
tc = TranscriptConfig.from_runnable_config(config)
|
tc = TranscriptConfig.from_runnable_config(config)
|
||||||
llm = await _require_llm(state, tc)
|
llm = await _require_llm(state, tc)
|
||||||
|
|
||||||
target_words = round(tc.spec.duration.midpoint_minutes * _WORDS_PER_MINUTE)
|
target_words = round(tc.spec.duration.midpoint_seconds * _WORDS_PER_MINUTE / 60)
|
||||||
suggested_segments = max(1, round(target_words / _WORDS_PER_SEGMENT))
|
suggested_segments = max(1, round(target_words / _WORDS_PER_SEGMENT))
|
||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,19 @@ from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
from app.podcasts.duration_limits import (
|
||||||
|
MAX_DURATION_SECONDS,
|
||||||
|
MIN_DURATION_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
# A speaker count beyond this is almost never a real podcast and explodes the
|
# A speaker count beyond this is almost never a real podcast and explodes the
|
||||||
# voice/turn-attribution space, so we reject it at the brief gate.
|
# voice/turn-attribution space, so we reject it at the brief gate.
|
||||||
MAX_SPEAKERS = 6
|
MAX_SPEAKERS = 6
|
||||||
|
|
||||||
# Long-form is a goal, but an open-ended upper bound invites runaway TTS bills.
|
|
||||||
# One day of audio is a generous ceiling that still blocks obvious mistakes.
|
|
||||||
MAX_DURATION_MINUTES = 24 * 60
|
|
||||||
|
|
||||||
# BCP-47 primary subtag plus optional region (e.g. ``en``, ``en-US``, ``pt-BR``).
|
# BCP-47 primary subtag plus optional region (e.g. ``en``, ``en-US``, ``pt-BR``).
|
||||||
# Kept deliberately permissive: the voice catalog, not the brief, decides which
|
# Kept deliberately permissive: the voice catalog, not the brief, decides which
|
||||||
# languages can actually be synthesised. Casing is normalised after matching.
|
# languages can actually be synthesised. Casing is normalised after matching.
|
||||||
|
|
@ -91,7 +93,7 @@ class SpeakerSpec(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class DurationTarget(BaseModel):
|
class DurationTarget(BaseModel):
|
||||||
"""The desired finished length as an inclusive minute range.
|
"""The desired finished length as an inclusive second range.
|
||||||
|
|
||||||
Drafting aims for the midpoint and treats the bounds as soft guardrails;
|
Drafting aims for the midpoint and treats the bounds as soft guardrails;
|
||||||
storing a range (rather than a point) keeps long-form expectations honest
|
storing a range (rather than a point) keeps long-form expectations honest
|
||||||
|
|
@ -100,19 +102,38 @@ class DurationTarget(BaseModel):
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
min_minutes: int = Field(..., ge=1, le=MAX_DURATION_MINUTES)
|
min_seconds: int = Field(..., ge=MIN_DURATION_SECONDS, le=MAX_DURATION_SECONDS)
|
||||||
max_minutes: int = Field(..., ge=1, le=MAX_DURATION_MINUTES)
|
max_seconds: int = Field(..., ge=MIN_DURATION_SECONDS, le=MAX_DURATION_SECONDS)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce_legacy_minutes(cls, data: Any) -> Any:
|
||||||
|
"""Rows stored before seconds-based briefs still load from JSONB."""
|
||||||
|
if (
|
||||||
|
isinstance(data, dict)
|
||||||
|
and "min_seconds" not in data
|
||||||
|
and "min_minutes" in data
|
||||||
|
):
|
||||||
|
migrated = dict(data)
|
||||||
|
migrated["min_seconds"] = int(migrated.pop("min_minutes")) * 60
|
||||||
|
migrated["max_seconds"] = int(migrated.pop("max_minutes")) * 60
|
||||||
|
return migrated
|
||||||
|
return data
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check_order(self) -> DurationTarget:
|
def _check_order(self) -> DurationTarget:
|
||||||
if self.max_minutes < self.min_minutes:
|
if self.max_seconds < self.min_seconds:
|
||||||
raise ValueError("max_minutes must be >= min_minutes")
|
raise ValueError("max_seconds must be >= min_seconds")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def midpoint_minutes(self) -> float:
|
def midpoint_seconds(self) -> float:
|
||||||
"""The runtime drafting should aim for within the range."""
|
"""The runtime drafting should aim for within the range."""
|
||||||
return (self.min_minutes + self.max_minutes) / 2
|
return (self.min_seconds + self.max_seconds) / 2
|
||||||
|
|
||||||
|
@property
|
||||||
|
def midpoint_minutes(self) -> float:
|
||||||
|
return self.midpoint_seconds / 60
|
||||||
|
|
||||||
|
|
||||||
class PodcastSpec(BaseModel):
|
class PodcastSpec(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,7 @@ def build_spec(
|
||||||
slot=1, name="Guest", role=SpeakerRole.GUEST, voice_id=voice_ids[1]
|
slot=1, name="Guest", role=SpeakerRole.GUEST, voice_id=voice_ids[1]
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
duration=DurationTarget(min_minutes=10, max_minutes=20),
|
duration=DurationTarget(min_seconds=600, max_seconds=1200),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,7 @@ async def test_quota_denial_fails_the_podcast_without_a_transcript(
|
||||||
async def _deny(**_kwargs):
|
async def _deny(**_kwargs):
|
||||||
raise QuotaInsufficientError(
|
raise QuotaInsufficientError(
|
||||||
usage_type="podcast_generation",
|
usage_type="podcast_generation",
|
||||||
used_micros=5_000_000,
|
balance_micros=5_000_000,
|
||||||
limit_micros=5_000_000,
|
|
||||||
remaining_micros=0,
|
remaining_micros=0,
|
||||||
)
|
)
|
||||||
yield # pragma: no cover - unreachable, satisfies the CM protocol
|
yield # pragma: no cover - unreachable, satisfies the CM protocol
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ def make_spec():
|
||||||
language: str = "en",
|
language: str = "en",
|
||||||
style: PodcastStyle = PodcastStyle.CONVERSATIONAL,
|
style: PodcastStyle = PodcastStyle.CONVERSATIONAL,
|
||||||
speakers: list[SpeakerSpec] | None = None,
|
speakers: list[SpeakerSpec] | None = None,
|
||||||
min_minutes: int = 10,
|
min_seconds: int = 600,
|
||||||
max_minutes: int = 20,
|
max_seconds: int = 1200,
|
||||||
focus: str | None = None,
|
focus: str | None = None,
|
||||||
) -> PodcastSpec:
|
) -> PodcastSpec:
|
||||||
if speakers is None:
|
if speakers is None:
|
||||||
|
|
@ -54,7 +54,7 @@ def make_spec():
|
||||||
language=language,
|
language=language,
|
||||||
style=style,
|
style=style,
|
||||||
speakers=speakers,
|
speakers=speakers,
|
||||||
duration=DurationTarget(min_minutes=min_minutes, max_minutes=max_minutes),
|
duration=DurationTarget(min_seconds=min_seconds, max_seconds=max_seconds),
|
||||||
focus=focus,
|
focus=focus,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ def _spec(voice_id: str) -> PodcastSpec:
|
||||||
speakers=[
|
speakers=[
|
||||||
SpeakerSpec(slot=0, name="Host", role=SpeakerRole.HOST, voice_id=voice_id)
|
SpeakerSpec(slot=0, name="Host", role=SpeakerRole.HOST, voice_id=voice_id)
|
||||||
],
|
],
|
||||||
duration=DurationTarget(min_minutes=5, max_minutes=10),
|
duration=DurationTarget(min_seconds=300, max_seconds=600),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ def test_spec_normalizes_its_language_on_construction():
|
||||||
spec = PodcastSpec(
|
spec = PodcastSpec(
|
||||||
language="EN-us",
|
language="EN-us",
|
||||||
speakers=[_speaker(0)],
|
speakers=[_speaker(0)],
|
||||||
duration=DurationTarget(min_minutes=5, max_minutes=10),
|
duration=DurationTarget(min_seconds=300, max_seconds=600),
|
||||||
)
|
)
|
||||||
assert spec.language == "en-us"
|
assert spec.language == "en-us"
|
||||||
|
|
||||||
|
|
@ -68,7 +68,7 @@ def test_speakers_must_have_unique_slots():
|
||||||
PodcastSpec(
|
PodcastSpec(
|
||||||
language="en",
|
language="en",
|
||||||
speakers=[_speaker(0), _speaker(0, voice_id="kokoro:af_bella")],
|
speakers=[_speaker(0), _speaker(0, voice_id="kokoro:af_bella")],
|
||||||
duration=DurationTarget(min_minutes=5, max_minutes=10),
|
duration=DurationTarget(min_seconds=300, max_seconds=600),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -77,7 +77,7 @@ def test_a_brief_needs_at_least_one_speaker():
|
||||||
PodcastSpec(
|
PodcastSpec(
|
||||||
language="en",
|
language="en",
|
||||||
speakers=[],
|
speakers=[],
|
||||||
duration=DurationTarget(min_minutes=5, max_minutes=10),
|
duration=DurationTarget(min_seconds=300, max_seconds=600),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -86,7 +86,7 @@ def test_a_monologue_brief_carries_exactly_one_speaker():
|
||||||
language="en",
|
language="en",
|
||||||
style=PodcastStyle.MONOLOGUE,
|
style=PodcastStyle.MONOLOGUE,
|
||||||
speakers=[_speaker(0)],
|
speakers=[_speaker(0)],
|
||||||
duration=DurationTarget(min_minutes=5, max_minutes=10),
|
duration=DurationTarget(min_seconds=300, max_seconds=600),
|
||||||
)
|
)
|
||||||
assert spec.style is PodcastStyle.MONOLOGUE
|
assert spec.style is PodcastStyle.MONOLOGUE
|
||||||
|
|
||||||
|
|
@ -98,18 +98,25 @@ def test_a_monologue_brief_rejects_multiple_speakers():
|
||||||
language="en",
|
language="en",
|
||||||
style=PodcastStyle.MONOLOGUE,
|
style=PodcastStyle.MONOLOGUE,
|
||||||
speakers=[_speaker(0), _speaker(1, voice_id="kokoro:af_bella")],
|
speakers=[_speaker(0), _speaker(1, voice_id="kokoro:af_bella")],
|
||||||
duration=DurationTarget(min_minutes=5, max_minutes=10),
|
duration=DurationTarget(min_seconds=300, max_seconds=600),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
DurationTarget(min_minutes=20, max_minutes=10)
|
DurationTarget(min_seconds=1200, max_seconds=600)
|
||||||
|
|
||||||
|
|
||||||
def test_duration_midpoint_is_where_drafting_aims():
|
def test_duration_midpoint_is_where_drafting_aims():
|
||||||
assert DurationTarget(min_minutes=10, max_minutes=20).midpoint_minutes == 15
|
assert DurationTarget(min_seconds=600, max_seconds=1200).midpoint_seconds == 900
|
||||||
|
assert DurationTarget(min_seconds=600, max_seconds=1200).midpoint_minutes == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_duration_loads_legacy_minute_fields_from_json():
|
||||||
|
duration = DurationTarget.model_validate({"min_minutes": 10, "max_minutes": 20})
|
||||||
|
assert duration.min_seconds == 600
|
||||||
|
assert duration.max_seconds == 1200
|
||||||
|
|
||||||
|
|
||||||
def test_blank_focus_becomes_absent():
|
def test_blank_focus_becomes_absent():
|
||||||
|
|
@ -117,7 +124,7 @@ def test_blank_focus_becomes_absent():
|
||||||
spec = PodcastSpec(
|
spec = PodcastSpec(
|
||||||
language="en",
|
language="en",
|
||||||
speakers=[_speaker(0)],
|
speakers=[_speaker(0)],
|
||||||
duration=DurationTarget(min_minutes=5, max_minutes=10),
|
duration=DurationTarget(min_seconds=300, max_seconds=600),
|
||||||
focus=" ",
|
focus=" ",
|
||||||
)
|
)
|
||||||
assert spec.focus is None
|
assert spec.focus is None
|
||||||
|
|
@ -127,7 +134,7 @@ def test_speaker_for_returns_the_speaker_bound_to_a_slot():
|
||||||
spec = PodcastSpec(
|
spec = PodcastSpec(
|
||||||
language="en",
|
language="en",
|
||||||
speakers=[_speaker(0), _speaker(1, voice_id="kokoro:af_bella")],
|
speakers=[_speaker(0), _speaker(1, voice_id="kokoro:af_bella")],
|
||||||
duration=DurationTarget(min_minutes=5, max_minutes=10),
|
duration=DurationTarget(min_seconds=300, max_seconds=600),
|
||||||
)
|
)
|
||||||
assert spec.speaker_for(1).voice_id == "kokoro:af_bella"
|
assert spec.speaker_for(1).voice_id == "kokoro:af_bella"
|
||||||
|
|
||||||
|
|
@ -136,7 +143,7 @@ def test_speaker_for_raises_when_no_speaker_matches():
|
||||||
spec = PodcastSpec(
|
spec = PodcastSpec(
|
||||||
language="en",
|
language="en",
|
||||||
speakers=[_speaker(0)],
|
speakers=[_speaker(0)],
|
||||||
duration=DurationTarget(min_minutes=5, max_minutes=10),
|
duration=DurationTarget(min_seconds=300, max_seconds=600),
|
||||||
)
|
)
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
spec.speaker_for(99)
|
spec.speaker_for(99)
|
||||||
|
|
|
||||||
|
|
@ -105,8 +105,7 @@ async def test_ainvoke_propagates_quota_insufficient_error(monkeypatch):
|
||||||
async def _denying_billable_call(**_kwargs):
|
async def _denying_billable_call(**_kwargs):
|
||||||
raise QuotaInsufficientError(
|
raise QuotaInsufficientError(
|
||||||
usage_type="vision_extraction",
|
usage_type="vision_extraction",
|
||||||
used_micros=5_000_000,
|
balance_micros=5_000_000,
|
||||||
limit_micros=5_000_000,
|
|
||||||
remaining_micros=0,
|
remaining_micros=0,
|
||||||
)
|
)
|
||||||
yield # unreachable but required for asynccontextmanager type
|
yield # unreachable but required for asynccontextmanager type
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,7 @@ async def _denying_billable_call(**kwargs):
|
||||||
_CALL_LOG.append(kwargs)
|
_CALL_LOG.append(kwargs)
|
||||||
raise QuotaInsufficientError(
|
raise QuotaInsufficientError(
|
||||||
usage_type=kwargs.get("usage_type", "?"),
|
usage_type=kwargs.get("usage_type", "?"),
|
||||||
used_micros=5_000_000,
|
balance_micros=5_000_000,
|
||||||
limit_micros=5_000_000,
|
|
||||||
remaining_micros=0,
|
remaining_micros=0,
|
||||||
)
|
)
|
||||||
yield SimpleNamespace() # pragma: no cover
|
yield SimpleNamespace() # pragma: no cover
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ import {
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
|
MAX_DURATION_SECONDS,
|
||||||
MAX_SPEAKERS,
|
MAX_SPEAKERS,
|
||||||
|
MIN_DURATION_SECONDS,
|
||||||
type PodcastSpec,
|
type PodcastSpec,
|
||||||
type PodcastStyle,
|
type PodcastStyle,
|
||||||
podcastStyle,
|
podcastStyle,
|
||||||
|
|
@ -55,6 +57,9 @@ interface BriefReviewProps {
|
||||||
*/
|
*/
|
||||||
export function BriefReview({ podcast, spec }: BriefReviewProps) {
|
export function BriefReview({ podcast, spec }: BriefReviewProps) {
|
||||||
const [draft, setDraft] = useState<PodcastSpec>(spec);
|
const [draft, setDraft] = useState<PodcastSpec>(spec);
|
||||||
|
const [durationUnit, setDurationUnit] = useState<DurationUnit>(() =>
|
||||||
|
defaultDurationUnit(spec.duration.max_seconds),
|
||||||
|
);
|
||||||
const [voices, setVoices] = useState<VoiceOption[] | null>(null);
|
const [voices, setVoices] = useState<VoiceOption[] | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
|
@ -63,6 +68,7 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset only when the server version moves
|
// biome-ignore lint/correctness/useExhaustiveDependencies: reset only when the server version moves
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft(spec);
|
setDraft(spec);
|
||||||
|
setDurationUnit(defaultDurationUnit(spec.duration.max_seconds));
|
||||||
}, [podcast.specVersion]);
|
}, [podcast.specVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -304,39 +310,72 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Label htmlFor="podcast-min-minutes">Min length (minutes)</Label>
|
<Label>Target length</Label>
|
||||||
<Input
|
<Select
|
||||||
id="podcast-min-minutes"
|
value={durationUnit}
|
||||||
type="number"
|
onValueChange={(value) => setDurationUnit(value as DurationUnit)}
|
||||||
min={1}
|
>
|
||||||
value={draft.duration.min_minutes}
|
<SelectTrigger className="w-[7.5rem]" aria-label="Length unit">
|
||||||
onChange={(e) =>
|
<SelectValue />
|
||||||
setDraft((current) => ({
|
</SelectTrigger>
|
||||||
...current,
|
<SelectContent>
|
||||||
duration: { ...current.duration, min_minutes: Number(e.target.value) || 1 },
|
<SelectItem value="seconds">Seconds</SelectItem>
|
||||||
}))
|
<SelectItem value="minutes">Minutes</SelectItem>
|
||||||
}
|
<SelectItem value="hours">Hours</SelectItem>
|
||||||
/>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Label htmlFor="podcast-max-minutes">Max length (minutes)</Label>
|
<div className="flex flex-col gap-2">
|
||||||
<Input
|
<Label htmlFor="podcast-min-length">Min</Label>
|
||||||
id="podcast-max-minutes"
|
<Input
|
||||||
type="number"
|
id="podcast-min-length"
|
||||||
min={draft.duration.min_minutes}
|
type="number"
|
||||||
value={draft.duration.max_minutes}
|
min={durationUnitBounds(durationUnit).min}
|
||||||
onChange={(e) =>
|
max={durationUnitBounds(durationUnit).max}
|
||||||
setDraft((current) => ({
|
step={durationInputStep(durationUnit)}
|
||||||
...current,
|
value={formatDurationForUnit(draft.duration.min_seconds, durationUnit)}
|
||||||
duration: {
|
onChange={(e) => {
|
||||||
...current.duration,
|
const seconds = clampDurationSeconds(
|
||||||
max_minutes: Number(e.target.value) || current.duration.min_minutes,
|
fromUnitValue(Number(e.target.value), durationUnit),
|
||||||
},
|
);
|
||||||
}))
|
setDraft((current) => ({
|
||||||
}
|
...current,
|
||||||
/>
|
duration: { ...current.duration, min_seconds: seconds },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="podcast-max-length">Max</Label>
|
||||||
|
<Input
|
||||||
|
id="podcast-max-length"
|
||||||
|
type="number"
|
||||||
|
min={secondsToUnitValue(draft.duration.min_seconds, durationUnit)}
|
||||||
|
max={durationUnitBounds(durationUnit).max}
|
||||||
|
step={durationInputStep(durationUnit)}
|
||||||
|
value={formatDurationForUnit(draft.duration.max_seconds, durationUnit)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const parsed = Number(e.target.value);
|
||||||
|
const fallback = secondsToUnitValue(
|
||||||
|
draft.duration.min_seconds,
|
||||||
|
durationUnit,
|
||||||
|
);
|
||||||
|
const seconds = clampDurationSeconds(
|
||||||
|
fromUnitValue(
|
||||||
|
Number.isFinite(parsed) ? parsed : fallback,
|
||||||
|
durationUnit,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
duration: { ...current.duration, max_seconds: seconds },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -365,7 +404,9 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleApprove}
|
onClick={handleApprove}
|
||||||
disabled={isSubmitting || draft.duration.max_minutes < draft.duration.min_minutes}
|
disabled={
|
||||||
|
isSubmitting || draft.duration.max_seconds < draft.duration.min_seconds
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||||
{isDirty ? "Approve changes & draft transcript" : "Approve & draft transcript"}
|
{isDirty ? "Approve changes & draft transcript" : "Approve & draft transcript"}
|
||||||
|
|
@ -377,6 +418,50 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
|
||||||
|
|
||||||
/** The current selection stays listed even when it no longer matches the
|
/** The current selection stays listed even when it no longer matches the
|
||||||
* language filter, so the Select never renders an orphaned value. */
|
* language filter, so the Select never renders an orphaned value. */
|
||||||
|
type DurationUnit = "seconds" | "minutes" | "hours";
|
||||||
|
|
||||||
|
function defaultDurationUnit(maxSeconds: number): DurationUnit {
|
||||||
|
if (maxSeconds >= 3600) return "hours";
|
||||||
|
if (maxSeconds >= 60) return "minutes";
|
||||||
|
return "seconds";
|
||||||
|
}
|
||||||
|
|
||||||
|
function secondsToUnitValue(seconds: number, unit: DurationUnit): number {
|
||||||
|
if (unit === "minutes") return seconds / 60;
|
||||||
|
if (unit === "hours") return seconds / 3600;
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromUnitValue(value: number, unit: DurationUnit): number {
|
||||||
|
if (!Number.isFinite(value)) return MIN_DURATION_SECONDS;
|
||||||
|
if (unit === "minutes") return value * 60;
|
||||||
|
if (unit === "hours") return value * 3600;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDurationForUnit(seconds: number, unit: DurationUnit): number {
|
||||||
|
const raw = secondsToUnitValue(seconds, unit);
|
||||||
|
if (unit === "seconds") return Math.round(raw);
|
||||||
|
return Math.round(raw * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationInputStep(unit: DurationUnit): number {
|
||||||
|
if (unit === "hours") return 0.1;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationUnitBounds(unit: DurationUnit): { min: number; max: number } {
|
||||||
|
return {
|
||||||
|
min: formatDurationForUnit(MIN_DURATION_SECONDS, unit),
|
||||||
|
max: formatDurationForUnit(MAX_DURATION_SECONDS, unit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampDurationSeconds(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return MIN_DURATION_SECONDS;
|
||||||
|
return Math.min(MAX_DURATION_SECONDS, Math.max(MIN_DURATION_SECONDS, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
function voiceItems(candidates: VoiceOption[], selectedId: string): VoiceOption[] {
|
function voiceItems(candidates: VoiceOption[], selectedId: string): VoiceOption[] {
|
||||||
if (candidates.some((voice) => voice.voice_id === selectedId)) return candidates;
|
if (candidates.some((voice) => voice.voice_id === selectedId)) return candidates;
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@ export type PodcastStyle = z.infer<typeof podcastStyle>;
|
||||||
|
|
||||||
export const MAX_SPEAKERS = 6;
|
export const MAX_SPEAKERS = 6;
|
||||||
|
|
||||||
|
export const MAX_DURATION_SECONDS = 24 * 60 * 60;
|
||||||
|
export const MIN_DURATION_SECONDS = 15;
|
||||||
|
export const DEFAULT_MIN_SECONDS = 20;
|
||||||
|
export const DEFAULT_MAX_SECONDS = 30;
|
||||||
|
|
||||||
export const speakerSpec = z.object({
|
export const speakerSpec = z.object({
|
||||||
slot: z.number().int().min(0),
|
slot: z.number().int().min(0),
|
||||||
name: z.string().min(1).max(120),
|
name: z.string().min(1).max(120),
|
||||||
|
|
@ -55,10 +60,40 @@ export const speakerSpec = z.object({
|
||||||
});
|
});
|
||||||
export type SpeakerSpec = z.infer<typeof speakerSpec>;
|
export type SpeakerSpec = z.infer<typeof speakerSpec>;
|
||||||
|
|
||||||
export const durationTarget = z.object({
|
export const durationTarget = z.preprocess(
|
||||||
min_minutes: z.number().int().min(1),
|
(raw) => {
|
||||||
max_minutes: z.number().int().min(1),
|
if (
|
||||||
});
|
raw &&
|
||||||
|
typeof raw === "object" &&
|
||||||
|
"min_minutes" in raw &&
|
||||||
|
!("min_seconds" in raw)
|
||||||
|
) {
|
||||||
|
const legacy = raw as { min_minutes: number; max_minutes: number };
|
||||||
|
return {
|
||||||
|
min_seconds: legacy.min_minutes * 60,
|
||||||
|
max_seconds: legacy.max_minutes * 60,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
},
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
min_seconds: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(MIN_DURATION_SECONDS)
|
||||||
|
.max(MAX_DURATION_SECONDS),
|
||||||
|
max_seconds: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(MIN_DURATION_SECONDS)
|
||||||
|
.max(MAX_DURATION_SECONDS),
|
||||||
|
})
|
||||||
|
.refine((duration) => duration.max_seconds >= duration.min_seconds, {
|
||||||
|
message: "Max length must be at least min length",
|
||||||
|
path: ["max_seconds"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
export type DurationTarget = z.infer<typeof durationTarget>;
|
export type DurationTarget = z.infer<typeof durationTarget>;
|
||||||
|
|
||||||
export const podcastSpec = z
|
export const podcastSpec = z
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue