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

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

View file

@ -23,7 +23,9 @@ class StructuredOutputError(RuntimeError):
"""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``."""
response = await llm.ainvoke(messages)
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 ..structured import invoke_json
from .config import TranscriptConfig
from .planning import Outline, OutlineSegment, SegmentDraft
from .planning import Outline, SegmentDraft
from .state import TranscriptState
# 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"})
)
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
finally:
list_file.unlink(missing_ok=True)

View file

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

View file

@ -70,7 +70,9 @@ class SpeakerSpec(BaseModel):
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)
role: SpeakerRole
voice_id: str = Field(

View file

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

View file

@ -15,7 +15,7 @@ from app.celery_app import celery_app
from app.podcasts.persistence import PodcastRepository
from app.podcasts.rendering import PodcastRenderer
from app.podcasts.service import (
InvalidTransition,
InvalidTransitionError,
PodcastService,
read_spec,
read_transcript,
@ -36,9 +36,10 @@ _WORKDIR_BASE = Path(tempfile.gettempdir()) / "surfsense_podcasts"
def render_audio_task(self, podcast_id: int) -> dict:
try:
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)
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}
@ -75,7 +76,7 @@ async def _render_audio(podcast_id: int) -> dict:
podcast, storage_backend=backend_name, storage_key=key
)
await session.commit()
except InvalidTransition:
except InvalidTransitionError:
# A user back-out won the race (e.g. the regeneration was
# reverted): drop the stale render and leave the row alone.
await purge_audio_object(key)

View file

@ -50,9 +50,7 @@ class KokoroTextToSpeech(TextToSpeech):
async def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if not isinstance(request.voice, str):
raise TextToSpeechError(
"Kokoro voices are named by string, not a mapping"
)
raise TextToSpeechError("Kokoro voices are named by string, not a mapping")
pipeline = self._pipeline_for(request.language)
loop = asyncio.get_event_loop()
@ -67,7 +65,7 @@ class KokoroTextToSpeech(TextToSpeech):
),
)
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
if not segments:

View file

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

View file

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

View file

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

View file

@ -36,7 +36,11 @@ def iter_completion_emission_frames(
"success",
)
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(
f"Podcast generation failed: {error_msg}",
"error",