chore: linting

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-06-11 15:31:43 -07:00
parent 27218304ae
commit 05190da0a9
30 changed files with 148 additions and 123 deletions

View file

@ -52,7 +52,9 @@ def upgrade() -> None:
"ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS storage_backend VARCHAR(32);" "ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS storage_backend VARCHAR(32);"
) )
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS storage_key TEXT;") op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS storage_key TEXT;")
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;") op.execute(
"ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;"
)
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS error TEXT;") op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS error TEXT;")

View file

@ -9,6 +9,8 @@ then enqueues the matching Celery task; lifecycle errors map to 409/422.
from __future__ import annotations from __future__ import annotations
import os import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException, Response
@ -27,10 +29,10 @@ from app.db import (
from app.podcasts.generation.brief import propose_brief from app.podcasts.generation.brief import propose_brief
from app.podcasts.persistence import Podcast, PodcastRepository from app.podcasts.persistence import Podcast, PodcastRepository
from app.podcasts.service import ( from app.podcasts.service import (
InvalidTransition, InvalidTransitionError,
PodcastService, PodcastService,
PreconditionFailed, PreconditionFailedError,
SpecConflict, SpecConflictError,
) )
from app.podcasts.storage import open_audio_stream, purge_audio from app.podcasts.storage import open_audio_stream, purge_audio
from app.podcasts.tasks import draft_transcript_task from app.podcasts.tasks import draft_transcript_task
@ -324,19 +326,12 @@ async def _load(
return podcast return podcast
class _lifecycle_errors: @asynccontextmanager
async def _lifecycle_errors() -> AsyncIterator[None]:
"""Map service lifecycle errors onto HTTP responses.""" """Map service lifecycle errors onto HTTP responses."""
try:
async def __aenter__(self) -> None: yield
return None except (SpecConflictError, InvalidTransitionError) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
async def __aexit__(self, exc_type, exc, tb) -> bool: except PreconditionFailedError as exc:
if exc is None: raise HTTPException(status_code=422, detail=str(exc)) from exc
return False
if isinstance(exc, SpecConflict):
raise HTTPException(status_code=409, detail=str(exc)) from exc
if isinstance(exc, InvalidTransition):
raise HTTPException(status_code=409, detail=str(exc)) from exc
if isinstance(exc, PreconditionFailed):
raise HTTPException(status_code=422, detail=str(exc)) from exc
return False

View file

@ -23,7 +23,9 @@ class StructuredOutputError(RuntimeError):
"""The model reply could not be parsed into the expected shape.""" """The model reply could not be parsed into the expected shape."""
async def invoke_json(llm, messages: list[BaseMessage], model: type[T]) -> T: async def invoke_json[T: BaseModel](
llm, messages: list[BaseMessage], model: type[T]
) -> T:
"""Invoke ``llm`` and validate its reply as ``model``.""" """Invoke ``llm`` and validate its reply as ``model``."""
response = await llm.ainvoke(messages) response = await llm.ainvoke(messages)
content = strip_markdown_fences(extract_text_content(response.content)) content = strip_markdown_fences(extract_text_content(response.content))

View file

@ -18,7 +18,7 @@ from app.services.llm_service import get_agent_llm
from ..prompts import draft_segment_prompt, plan_outline_prompt from ..prompts import draft_segment_prompt, plan_outline_prompt
from ..structured import invoke_json from ..structured import invoke_json
from .config import TranscriptConfig from .config import TranscriptConfig
from .planning import Outline, OutlineSegment, SegmentDraft from .planning import Outline, SegmentDraft
from .state import TranscriptState from .state import TranscriptState
# Average speaking rate; converts target minutes to a target word count. # Average speaking rate; converts target minutes to a target word count.

View file

@ -32,7 +32,7 @@ async def concat_to_mp3(segment_paths: list[Path], output_path: Path) -> None:
.output(str(output_path), {"c:a": "libmp3lame"}) .output(str(output_path), {"c:a": "libmp3lame"})
) )
await ffmpeg.execute() await ffmpeg.execute()
except Exception as exc: # noqa: BLE001 - normalise ffmpeg failures except Exception as exc:
raise RenderError(f"audio merge failed: {exc}") from exc raise RenderError(f"audio merge failed: {exc}") from exc
finally: finally:
list_file.unlink(missing_ok=True) list_file.unlink(missing_ok=True)

View file

@ -77,9 +77,7 @@ class PodcastRenderer:
await concat_to_mp3(list(segment_paths), output_path) await concat_to_mp3(list(segment_paths), output_path)
return RenderedPodcast(data=output_path.read_bytes(), container="mp3") return RenderedPodcast(data=output_path.read_bytes(), container="mp3")
def _request_for( def _request_for(self, spec: PodcastSpec, turn: TranscriptTurn) -> SynthesisRequest:
self, spec: PodcastSpec, turn: TranscriptTurn
) -> SynthesisRequest:
try: try:
speaker = spec.speaker_for(turn.speaker) speaker = spec.speaker_for(turn.speaker)
except KeyError as exc: except KeyError as exc:
@ -132,7 +130,7 @@ class _SegmentSynthesizer:
if owner: if owner:
try: try:
path = await self._synthesize(request, key) path = await self._synthesize(request, key)
except BaseException as exc: # noqa: BLE001 - relayed to all waiters except BaseException as exc:
future.set_exception(exc) future.set_exception(exc)
else: else:
future.set_result(path) future.set_result(path)

View file

@ -70,7 +70,9 @@ class SpeakerSpec(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
slot: int = Field(..., ge=0, description="Stable index a transcript turn references") slot: int = Field(
..., ge=0, description="Stable index a transcript turn references"
)
name: str = Field(..., min_length=1, max_length=120) name: str = Field(..., min_length=1, max_length=120)
role: SpeakerRole role: SpeakerRole
voice_id: str = Field( voice_id: str = Field(

View file

@ -59,11 +59,11 @@ class PodcastError(RuntimeError):
"""Base class for lifecycle errors.""" """Base class for lifecycle errors."""
class InvalidTransition(PodcastError): class InvalidTransitionError(PodcastError):
"""A requested status change is not permitted from the current state.""" """A requested status change is not permitted from the current state."""
class SpecConflict(PodcastError): class SpecConflictError(PodcastError):
"""A spec edit raced another: the expected version is stale.""" """A spec edit raced another: the expected version is stale."""
def __init__(self, expected: int, actual: int) -> None: def __init__(self, expected: int, actual: int) -> None:
@ -74,7 +74,7 @@ class SpecConflict(PodcastError):
self.actual = actual self.actual = actual
class PreconditionFailed(PodcastError): class PreconditionFailedError(PodcastError):
"""A transition's data precondition (brief/transcript present) is unmet.""" """A transition's data precondition (brief/transcript present) is unmet."""
@ -110,12 +110,12 @@ class PodcastService:
) -> Podcast: ) -> Podcast:
"""Edit the brief at the gate, guarded by optimistic concurrency.""" """Edit the brief at the gate, guarded by optimistic concurrency."""
if _status(podcast) is not PodcastStatus.AWAITING_BRIEF: if _status(podcast) is not PodcastStatus.AWAITING_BRIEF:
raise InvalidTransition( raise InvalidTransitionError(
f"the brief can only be edited while awaiting_brief, " f"the brief can only be edited while awaiting_brief, "
f"not {_status(podcast).value}" f"not {_status(podcast).value}"
) )
if expected_version != podcast.spec_version: if expected_version != podcast.spec_version:
raise SpecConflict(expected_version, podcast.spec_version) raise SpecConflictError(expected_version, podcast.spec_version)
podcast.spec = spec.model_dump(mode="json") podcast.spec = spec.model_dump(mode="json")
podcast.spec_version += 1 podcast.spec_version += 1
await self._session.flush() await self._session.flush()
@ -124,7 +124,7 @@ class PodcastService:
async def begin_drafting(self, podcast: Podcast) -> Podcast: async def begin_drafting(self, podcast: Podcast) -> Podcast:
"""Approve the brief and start transcript drafting.""" """Approve the brief and start transcript drafting."""
if podcast.spec is None: if podcast.spec is None:
raise PreconditionFailed("cannot draft without a brief") raise PreconditionFailedError("cannot draft without a brief")
self._transition(podcast, PodcastStatus.DRAFTING) self._transition(podcast, PodcastStatus.DRAFTING)
await self._session.flush() await self._session.flush()
return podcast return podcast
@ -145,13 +145,13 @@ class PodcastService:
async def regenerate(self, podcast: Podcast) -> Podcast: async def regenerate(self, podcast: Podcast) -> Podcast:
"""Reopen the brief gate; the saved spec becomes the new starting point.""" """Reopen the brief gate; the saved spec becomes the new starting point."""
if _status(podcast) not in self._REGENERABLE: if _status(podcast) not in self._REGENERABLE:
raise InvalidTransition( raise InvalidTransitionError(
f"nothing to regenerate from {_status(podcast).value}" f"nothing to regenerate from {_status(podcast).value}"
) )
# Legacy episodes finished before briefs existed; a gate with nothing # Legacy episodes finished before briefs existed; a gate with nothing
# to review would strand them. # to review would strand them.
if podcast.spec is None: if podcast.spec is None:
raise PreconditionFailed("cannot regenerate without a brief") raise PreconditionFailedError("cannot regenerate without a brief")
self._transition(podcast, PodcastStatus.AWAITING_BRIEF) self._transition(podcast, PodcastStatus.AWAITING_BRIEF)
await self._session.flush() await self._session.flush()
return podcast return podcast
@ -164,7 +164,7 @@ class PodcastService:
has no regeneration to revert and is rejected. has no regeneration to revert and is rejected.
""" """
if not has_stored_episode(podcast): if not has_stored_episode(podcast):
raise InvalidTransition("no finished episode to fall back to") raise InvalidTransitionError("no finished episode to fall back to")
self._transition(podcast, PodcastStatus.READY) self._transition(podcast, PodcastStatus.READY)
await self._session.flush() await self._session.flush()
return podcast return podcast
@ -200,7 +200,7 @@ class PodcastService:
backing out goes through revert_regeneration instead. backing out goes through revert_regeneration instead.
""" """
if has_stored_episode(podcast): if has_stored_episode(podcast):
raise InvalidTransition( raise InvalidTransitionError(
"a finished episode exists; revert the regeneration instead" "a finished episode exists; revert the regeneration instead"
) )
self._transition(podcast, PodcastStatus.CANCELLED) self._transition(podcast, PodcastStatus.CANCELLED)
@ -210,7 +210,7 @@ class PodcastService:
def _transition(self, podcast: Podcast, target: PodcastStatus) -> None: def _transition(self, podcast: Podcast, target: PodcastStatus) -> None:
current = _status(podcast) current = _status(podcast)
if target not in _ALLOWED[current]: if target not in _ALLOWED[current]:
raise InvalidTransition( raise InvalidTransitionError(
f"{current.value} -> {target.value} is not allowed" f"{current.value} -> {target.value} is not allowed"
) )
podcast.status = target podcast.status = target

View file

@ -36,9 +36,10 @@ def draft_transcript_task(self, podcast_id: int, search_space_id: int) -> dict:
return run_async_celery_task( return run_async_celery_task(
lambda: _draft_transcript(podcast_id, search_space_id) lambda: _draft_transcript(podcast_id, search_space_id)
) )
except Exception as exc: # noqa: BLE001 - record and report, never crash worker except Exception as exc:
logger.error("Podcast %s drafting failed: %s", podcast_id, exc) logger.error("Podcast %s drafting failed: %s", podcast_id, exc)
run_async_celery_task(lambda: mark_failed(podcast_id, str(exc))) message = str(exc)
run_async_celery_task(lambda: mark_failed(podcast_id, message))
return {"status": "failed", "podcast_id": podcast_id} return {"status": "failed", "podcast_id": podcast_id}

View file

@ -15,7 +15,7 @@ from app.celery_app import celery_app
from app.podcasts.persistence import PodcastRepository from app.podcasts.persistence import PodcastRepository
from app.podcasts.rendering import PodcastRenderer from app.podcasts.rendering import PodcastRenderer
from app.podcasts.service import ( from app.podcasts.service import (
InvalidTransition, InvalidTransitionError,
PodcastService, PodcastService,
read_spec, read_spec,
read_transcript, read_transcript,
@ -36,9 +36,10 @@ _WORKDIR_BASE = Path(tempfile.gettempdir()) / "surfsense_podcasts"
def render_audio_task(self, podcast_id: int) -> dict: def render_audio_task(self, podcast_id: int) -> dict:
try: try:
return run_async_celery_task(lambda: _render_audio(podcast_id)) return run_async_celery_task(lambda: _render_audio(podcast_id))
except Exception as exc: # noqa: BLE001 - record and report, never crash worker except Exception as exc:
logger.error("Podcast %s render failed: %s", podcast_id, exc) logger.error("Podcast %s render failed: %s", podcast_id, exc)
run_async_celery_task(lambda: mark_failed(podcast_id, str(exc))) message = str(exc)
run_async_celery_task(lambda: mark_failed(podcast_id, message))
return {"status": "failed", "podcast_id": podcast_id} return {"status": "failed", "podcast_id": podcast_id}
@ -75,7 +76,7 @@ async def _render_audio(podcast_id: int) -> dict:
podcast, storage_backend=backend_name, storage_key=key podcast, storage_backend=backend_name, storage_key=key
) )
await session.commit() await session.commit()
except InvalidTransition: except InvalidTransitionError:
# A user back-out won the race (e.g. the regeneration was # A user back-out won the race (e.g. the regeneration was
# reverted): drop the stale render and leave the row alone. # reverted): drop the stale render and leave the row alone.
await purge_audio_object(key) await purge_audio_object(key)

View file

@ -50,9 +50,7 @@ class KokoroTextToSpeech(TextToSpeech):
async def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio: async def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if not isinstance(request.voice, str): if not isinstance(request.voice, str):
raise TextToSpeechError( raise TextToSpeechError("Kokoro voices are named by string, not a mapping")
"Kokoro voices are named by string, not a mapping"
)
pipeline = self._pipeline_for(request.language) pipeline = self._pipeline_for(request.language)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
@ -67,7 +65,7 @@ class KokoroTextToSpeech(TextToSpeech):
), ),
) )
segments = [audio for _gs, _ps, audio in generator] segments = [audio for _gs, _ps, audio in generator]
except Exception as exc: # noqa: BLE001 - normalise provider errors except Exception as exc:
raise TextToSpeechError(f"Kokoro synthesis failed: {exc}") from exc raise TextToSpeechError(f"Kokoro synthesis failed: {exc}") from exc
if not segments: if not segments:

View file

@ -57,10 +57,8 @@ class LiteLlmTextToSpeech(TextToSpeech):
try: try:
response = await aspeech(**kwargs) response = await aspeech(**kwargs)
except Exception as exc: # noqa: BLE001 - normalise provider errors except Exception as exc:
raise TextToSpeechError( raise TextToSpeechError(f"{self._model} synthesis failed: {exc}") from exc
f"{self._model} synthesis failed: {exc}"
) from exc
data = getattr(response, "content", None) data = getattr(response, "content", None)
if not data: if not data:

View file

@ -36,9 +36,7 @@ class VoiceCatalog:
"""All voices offered by ``provider``, in catalog order.""" """All voices offered by ``provider``, in catalog order."""
return list(self._by_provider.get(provider, ())) return list(self._by_provider.get(provider, ()))
def for_language( def for_language(self, provider: TtsProvider, language: str) -> list[CatalogVoice]:
self, provider: TtsProvider, language: str
) -> list[CatalogVoice]:
"""``provider`` voices that can render ``language``, in catalog order.""" """``provider`` voices that can render ``language``, in catalog order."""
return [v for v in self.for_provider(provider) if v.speaks(language)] return [v for v in self.for_provider(provider) if v.speaks(language)]
@ -50,6 +48,4 @@ class VoiceCatalog:
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_voice_catalog() -> VoiceCatalog: def get_voice_catalog() -> VoiceCatalog:
"""The process-wide catalog assembled from every provider's roster.""" """The process-wide catalog assembled from every provider's roster."""
return VoiceCatalog( return VoiceCatalog((*KOKORO_VOICES, *OPENAI_VOICES, *AZURE_VOICES, *VERTEX_VOICES))
(*KOKORO_VOICES, *OPENAI_VOICES, *AZURE_VOICES, *VERTEX_VOICES)
)

View file

@ -30,10 +30,52 @@ def _voice(
VERTEX_VOICES: tuple[CatalogVoice, ...] = ( VERTEX_VOICES: tuple[CatalogVoice, ...] = (
_voice("en-US-Studio-O", "en-US", "en-US", "en-US-Studio-O", "Studio O (US)", VoiceGender.FEMALE), _voice(
_voice("en-US-Studio-M", "en-US", "en-US", "en-US-Studio-M", "Studio M (US)", VoiceGender.MALE), "en-US-Studio-O",
_voice("en-GB-Studio-A", "en-GB", "en-UK", "en-UK-Studio-A", "Studio A (UK)", VoiceGender.FEMALE), "en-US",
_voice("en-GB-Studio-B", "en-GB", "en-UK", "en-UK-Studio-B", "Studio B (UK)", VoiceGender.MALE), "en-US",
_voice("en-AU-Studio-A", "en-AU", "en-AU", "en-AU-Studio-A", "Studio A (AU)", VoiceGender.FEMALE), "en-US-Studio-O",
_voice("en-AU-Studio-B", "en-AU", "en-AU", "en-AU-Studio-B", "Studio B (AU)", VoiceGender.MALE), "Studio O (US)",
VoiceGender.FEMALE,
),
_voice(
"en-US-Studio-M",
"en-US",
"en-US",
"en-US-Studio-M",
"Studio M (US)",
VoiceGender.MALE,
),
_voice(
"en-GB-Studio-A",
"en-GB",
"en-UK",
"en-UK-Studio-A",
"Studio A (UK)",
VoiceGender.FEMALE,
),
_voice(
"en-GB-Studio-B",
"en-GB",
"en-UK",
"en-UK-Studio-B",
"Studio B (UK)",
VoiceGender.MALE,
),
_voice(
"en-AU-Studio-A",
"en-AU",
"en-AU",
"en-AU-Studio-A",
"Studio A (AU)",
VoiceGender.FEMALE,
),
_voice(
"en-AU-Studio-B",
"en-AU",
"en-AU",
"en-AU-Studio-B",
"Studio B (AU)",
VoiceGender.MALE,
),
) )

View file

@ -30,7 +30,7 @@ _SAMPLE_TEXTS = {
"it": "Ciao! Questa è la mia voce quando racconto il tuo podcast.", "it": "Ciao! Questa è la mia voce quando racconto il tuo podcast.",
"ja": "こんにちは。ポッドキャストをお届けするときの私の声です。", "ja": "こんにちは。ポッドキャストをお届けするときの私の声です。",
"pt": "Olá! É assim que eu soo ao narrar o seu podcast.", "pt": "Olá! É assim que eu soo ao narrar o seu podcast.",
"zh": "你好!这就是我为你播报播客时的声音。", "zh": "你好!这就是我为你播报播客时的声音。", # noqa: RUF001
} }
_CONTENT_TYPES = {"mp3": "audio/mpeg", "wav": "audio/wav"} _CONTENT_TYPES = {"mp3": "audio/mpeg", "wav": "audio/wav"}
@ -40,9 +40,7 @@ async def render_voice_preview(
voice: CatalogVoice, tts: TextToSpeech voice: CatalogVoice, tts: TextToSpeech
) -> tuple[bytes, str]: ) -> tuple[bytes, str]:
"""Return ``(audio_bytes, content_type)`` for a sample spoken by ``voice``.""" """Return ``(audio_bytes, content_type)`` for a sample spoken by ``voice``."""
language = ( language = _FALLBACK_LANGUAGE if voice.language == ANY_LANGUAGE else voice.language
_FALLBACK_LANGUAGE if voice.language == ANY_LANGUAGE else voice.language
)
request = SynthesisRequest( request = SynthesisRequest(
text=_sample_text(language), voice=voice.native_ref, language=language text=_sample_text(language), voice=voice.native_ref, language=language
) )

View file

@ -36,7 +36,11 @@ def iter_completion_emission_frames(
"success", "success",
) )
elif status in ("failed", "error"): elif status in ("failed", "error"):
error_msg = out.get("error", "Unknown error") if isinstance(out, dict) else "Unknown error" error_msg = (
out.get("error", "Unknown error")
if isinstance(out, dict)
else "Unknown error"
)
yield ctx.streaming_service.format_terminal_info( yield ctx.streaming_service.format_terminal_info(
f"Podcast generation failed: {error_msg}", f"Podcast generation failed: {error_msg}",
"error", "error",

View file

@ -26,7 +26,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.app import app, limiter from app.app import app, limiter
from app.config import config as app_config from app.config import config as app_config
from app.db import SearchSpace, User, get_async_session from app.db import SearchSpace, User, get_async_session
from app.routes.search_spaces_routes import create_default_roles_and_membership
from app.podcasts.persistence import Podcast, PodcastStatus from app.podcasts.persistence import Podcast, PodcastStatus
from app.podcasts.schemas import ( from app.podcasts.schemas import (
DurationTarget, DurationTarget,
@ -39,6 +38,7 @@ from app.podcasts.schemas import (
) )
from app.podcasts.service import PodcastService from app.podcasts.service import PodcastService
from app.podcasts.tts import SynthesisRequest, SynthesizedAudio, TextToSpeech from app.podcasts.tts import SynthesisRequest, SynthesizedAudio, TextToSpeech
from app.routes.search_spaces_routes import create_default_roles_and_membership
from app.users import current_active_user from app.users import current_active_user
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@ -128,12 +128,8 @@ class FakeStorageBackend:
def fake_storage(monkeypatch) -> FakeStorageBackend: def fake_storage(monkeypatch) -> FakeStorageBackend:
"""Route audio storage to an in-memory backend for the stream routes.""" """Route audio storage to an in-memory backend for the stream routes."""
backend = FakeStorageBackend() backend = FakeStorageBackend()
monkeypatch.setattr( monkeypatch.setattr("app.podcasts.storage.get_storage_backend", lambda: backend)
"app.podcasts.storage.get_storage_backend", lambda: backend monkeypatch.setattr("app.file_storage.factory.get_storage_backend", lambda: backend)
)
monkeypatch.setattr(
"app.file_storage.factory.get_storage_backend", lambda: backend
)
return backend return backend
@ -159,9 +155,7 @@ def bind_task_session(db_session: AsyncSession, monkeypatch) -> AsyncSession:
"app.podcasts.tasks.render", "app.podcasts.tasks.render",
"app.podcasts.tasks.runtime", "app.podcasts.tasks.runtime",
): ):
monkeypatch.setattr( monkeypatch.setattr(f"{module}.get_celery_session_maker", lambda: _make_session)
f"{module}.get_celery_session_maker", lambda: _make_session
)
return db_session return db_session
@ -213,8 +207,12 @@ def build_spec(
language=language, language=language,
style=PodcastStyle.CONVERSATIONAL, style=PodcastStyle.CONVERSATIONAL,
speakers=[ speakers=[
SpeakerSpec(slot=0, name="Host", role=SpeakerRole.HOST, voice_id=voice_ids[0]), SpeakerSpec(
SpeakerSpec(slot=1, name="Guest", role=SpeakerRole.GUEST, voice_id=voice_ids[1]), slot=0, name="Host", role=SpeakerRole.HOST, voice_id=voice_ids[0]
),
SpeakerSpec(
slot=1, name="Guest", role=SpeakerRole.GUEST, voice_id=voice_ids[1]
),
], ],
duration=DurationTarget(min_minutes=10, max_minutes=20), duration=DurationTarget(min_minutes=10, max_minutes=20),
) )
@ -237,7 +235,7 @@ def make_podcast(db_session: AsyncSession):
session, so the endpoint under test reads a realistically-built row. session, so the endpoint under test reads a realistically-built row.
""" """
_LADDER = [ ladder = [
PodcastStatus.AWAITING_BRIEF, PodcastStatus.AWAITING_BRIEF,
PodcastStatus.DRAFTING, PodcastStatus.DRAFTING,
PodcastStatus.RENDERING, PodcastStatus.RENDERING,
@ -259,7 +257,7 @@ def make_podcast(db_session: AsyncSession):
await db_session.flush() await db_session.flush()
return podcast return podcast
targets = _LADDER[: _LADDER.index(status) + 1] targets = ladder[: ladder.index(status) + 1]
for target in targets: for target in targets:
if target is PodcastStatus.AWAITING_BRIEF: if target is PodcastStatus.AWAITING_BRIEF:
await service.attach_brief(podcast, build_spec()) await service.attach_brief(podcast, build_spec())

View file

@ -15,9 +15,7 @@ pytestmark = pytest.mark.integration
BASE = "/api/v1/podcasts" BASE = "/api/v1/podcasts"
async def test_cancel_from_a_live_state_succeeds( async def test_cancel_from_a_live_state_succeeds(client, db_search_space, make_podcast):
client, db_search_space, make_podcast
):
podcast = await make_podcast( podcast = await make_podcast(
search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF search_space_id=db_search_space.id, status=PodcastStatus.AWAITING_BRIEF
) )

View file

@ -23,18 +23,12 @@ BASE = "/api/v1/podcasts"
def preview_tts(monkeypatch, tmp_path) -> FakeTextToSpeech: def preview_tts(monkeypatch, tmp_path) -> FakeTextToSpeech:
"""Route preview synthesis to the fake provider and an isolated cache.""" """Route preview synthesis to the fake provider and an isolated cache."""
provider = FakeTextToSpeech() provider = FakeTextToSpeech()
monkeypatch.setattr( monkeypatch.setattr("app.podcasts.api.routes.get_text_to_speech", lambda: provider)
"app.podcasts.api.routes.get_text_to_speech", lambda: provider monkeypatch.setattr("app.podcasts.voices.preview.PREVIEW_CACHE_ROOT", tmp_path)
)
monkeypatch.setattr(
"app.podcasts.voices.preview.PREVIEW_CACHE_ROOT", tmp_path
)
return provider return provider
async def test_preview_returns_playable_audio_for_a_catalog_voice( async def test_preview_returns_playable_audio_for_a_catalog_voice(client, preview_tts):
client, preview_tts
):
resp = await client.get(f"{BASE}/voices/openai:alloy/preview") resp = await client.get(f"{BASE}/voices/openai:alloy/preview")
assert resp.status_code == 200 assert resp.status_code == 200
@ -42,9 +36,7 @@ async def test_preview_returns_playable_audio_for_a_catalog_voice(
assert resp.content == b"segment-audio" assert resp.content == b"segment-audio"
async def test_preview_is_synthesised_once_then_served_from_cache( async def test_preview_is_synthesised_once_then_served_from_cache(client, preview_tts):
client, preview_tts
):
first = await client.get(f"{BASE}/voices/openai:alloy/preview") first = await client.get(f"{BASE}/voices/openai:alloy/preview")
second = await client.get(f"{BASE}/voices/openai:alloy/preview") second = await client.get(f"{BASE}/voices/openai:alloy/preview")
@ -69,9 +61,7 @@ async def test_preview_voice_of_inactive_provider_is_404(client, preview_tts):
assert preview_tts.requests == [] assert preview_tts.requests == []
async def test_preview_without_tts_provider_is_503( async def test_preview_without_tts_provider_is_503(client, preview_tts, monkeypatch):
client, preview_tts, monkeypatch
):
monkeypatch.setattr(app_config, "TTS_SERVICE", None) monkeypatch.setattr(app_config, "TTS_SERVICE", None)
resp = await client.get(f"{BASE}/voices/openai:alloy/preview") resp = await client.get(f"{BASE}/voices/openai:alloy/preview")

View file

@ -38,7 +38,10 @@ def make_spec():
if speakers is None: if speakers is None:
speakers = [ speakers = [
SpeakerSpec( SpeakerSpec(
slot=0, name="Host", role=SpeakerRole.HOST, voice_id="kokoro:am_adam" slot=0,
name="Host",
role=SpeakerRole.HOST,
voice_id="kokoro:am_adam",
), ),
SpeakerSpec( SpeakerSpec(
slot=1, slot=1,

View file

@ -71,7 +71,9 @@ def _spec(voice_id: str) -> PodcastSpec:
async def test_render_rejects_a_turn_for_an_unknown_speaker(tmp_path): async def test_render_rejects_a_turn_for_an_unknown_speaker(tmp_path):
renderer = PodcastRenderer(tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam")) renderer = PodcastRenderer(
tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam")
)
transcript = Transcript(turns=[TranscriptTurn(speaker=5, text="Who am I?")]) transcript = Transcript(turns=[TranscriptTurn(speaker=5, text="Who am I?")])
with pytest.raises(RenderError): with pytest.raises(RenderError):
@ -81,7 +83,9 @@ async def test_render_rejects_a_turn_for_an_unknown_speaker(tmp_path):
async def test_render_rejects_a_speaker_whose_voice_is_not_in_the_catalog(tmp_path): async def test_render_rejects_a_speaker_whose_voice_is_not_in_the_catalog(tmp_path):
renderer = PodcastRenderer(tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam")) renderer = PodcastRenderer(
tts=_UnusedTTS(), catalog=_catalog_with("kokoro:am_adam")
)
transcript = Transcript(turns=[TranscriptTurn(speaker=0, text="Hello.")]) transcript = Transcript(turns=[TranscriptTurn(speaker=0, text="Hello.")])
with pytest.raises(RenderError): with pytest.raises(RenderError):

View file

@ -64,7 +64,9 @@ def test_a_preferred_voice_invalid_for_the_language_is_replaced():
speaker_count=1, speaker_count=1,
preferred=["kokoro:does-not-exist"], preferred=["kokoro:does-not-exist"],
) )
assert voices[0].voice_id in {v.voice_id for v in catalog.for_provider(TtsProvider.KOKORO)} assert voices[0].voice_id in {
v.voice_id for v in catalog.for_provider(TtsProvider.KOKORO)
}
def test_resolution_fails_when_no_voice_speaks_the_language(): def test_resolution_fails_when_no_voice_speaks_the_language():

View file

@ -52,7 +52,9 @@ def test_for_provider_returns_only_that_providers_voices():
def test_for_language_matches_on_the_primary_subtag(): def test_for_language_matches_on_the_primary_subtag():
"""A request for 'en' should match an 'en-US' voice (region-insensitive).""" """A request for 'en' should match an 'en-US' voice (region-insensitive)."""
catalog = VoiceCatalog([_voice("k1", language="en-US")]) catalog = VoiceCatalog([_voice("k1", language="en-US")])
assert [v.voice_id for v in catalog.for_language(TtsProvider.KOKORO, "en")] == ["k1"] assert [v.voice_id for v in catalog.for_language(TtsProvider.KOKORO, "en")] == [
"k1"
]
def test_for_language_excludes_other_languages(): def test_for_language_excludes_other_languages():

View file

@ -3,10 +3,7 @@ import type { MDXComponents } from "mdx/types";
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { changelog } from "@/.source/server"; import { changelog } from "@/.source/server";
import { import { ChangelogTimeline, type ChangelogTimelineEntry } from "@/components/ui/changelog-timeline";
ChangelogTimeline,
type ChangelogTimelineEntry,
} from "@/components/ui/changelog-timeline";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { getMDXComponents } from "@/mdx-components"; import { getMDXComponents } from "@/mdx-components";

View file

@ -13,11 +13,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { import type { CreditPurchase, PagePurchase, PurchaseStatus } from "@/contracts/types/stripe.types";
CreditPurchase,
PagePurchase,
PurchaseStatus,
} from "@/contracts/types/stripe.types";
import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View file

@ -48,8 +48,8 @@ import {
isCommentReplyMetadata, isCommentReplyMetadata,
isConnectorIndexingMetadata, isConnectorIndexingMetadata,
isDocumentProcessingMetadata, isDocumentProcessingMetadata,
isNewMentionMetadata,
isInsufficientCreditsMetadata, isInsufficientCreditsMetadata,
isNewMentionMetadata,
} from "@/contracts/types/inbox.types"; } from "@/contracts/types/inbox.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import type { InboxItem } from "@/hooks/use-inbox"; import type { InboxItem } from "@/hooks/use-inbox";

View file

@ -1,9 +1,9 @@
export { AllChatsSidebar, AllChatsSidebarContent } from "./AllChatsSidebar"; export { AllChatsSidebar, AllChatsSidebarContent } from "./AllChatsSidebar";
export { ChatListItem } from "./ChatListItem"; export { ChatListItem } from "./ChatListItem";
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
export { DocumentsSidebar } from "./DocumentsSidebar"; export { DocumentsSidebar } from "./DocumentsSidebar";
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar"; export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
export { NavSection } from "./NavSection"; export { NavSection } from "./NavSection";
export { Sidebar } from "./Sidebar"; export { Sidebar } from "./Sidebar";
export { SidebarCollapseButton } from "./SidebarCollapseButton"; export { SidebarCollapseButton } from "./SidebarCollapseButton";

View file

@ -2,9 +2,9 @@
import { import {
FilePlus2, FilePlus2,
type LucideIcon,
Search, Search,
Settings2, Settings2,
type LucideIcon,
WandSparkles, WandSparkles,
Workflow, Workflow,
X, X,

View file

@ -286,8 +286,8 @@ function PricingFAQ() {
Frequently Asked Questions Frequently Asked Questions
</h2> </h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground"> <p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
Everything you need to know about SurfSense credits and billing. Everything you need to know about SurfSense credits and billing. Can&apos;t find what you
Can&apos;t find what you need? Reach out at{" "} need? Reach out at{" "}
<a href="mailto:rohan@surfsense.com" className="text-blue-500 underline"> <a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
rohan@surfsense.com rohan@surfsense.com
</a> </a>

View file

@ -77,9 +77,7 @@ export function EarnCreditsContent() {
<div className="w-full space-y-5"> <div className="w-full space-y-5">
<div className="text-center"> <div className="text-center">
<h2 className="text-xl font-bold tracking-tight">Earn Credits</h2> <h2 className="text-xl font-bold tracking-tight">Earn Credits</h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">Earn bonus credits by completing tasks</p>
Earn bonus credits by completing tasks
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">