diff --git a/src/transcription/api/errors.py b/src/transcription/api/errors.py index 91d803c..352bba6 100644 --- a/src/transcription/api/errors.py +++ b/src/transcription/api/errors.py @@ -4,10 +4,13 @@ from __future__ import annotations import logging -from fastapi import FastAPI, Request +from fastapi import FastAPI +from fastapi import Request from fastapi.responses import JSONResponse -from transcription.errors import AppError, ErrorCategory, build_error_envelope +from transcription.errors import AppError +from transcription.errors import ErrorCategory +from transcription.errors import build_error_envelope logger = logging.getLogger(__name__) diff --git a/src/transcription/app.py b/src/transcription/app.py index d4e6553..5688c8e 100644 --- a/src/transcription/app.py +++ b/src/transcription/app.py @@ -3,21 +3,20 @@ from __future__ import annotations from contextlib import asynccontextmanager -from threading import Event, Thread +from threading import Event +from threading import Thread from fastapi import FastAPI -from transcription.api.errors import register_error_handlers -from transcription.api.health import router as health_router -from transcription.config import get_settings, setup_logging -from transcription.db import ( - create_all, - dispose_database_runtime, - initialize_database_runtime, - should_bootstrap_schema, -) -from transcription.ui import register_pages -from transcription.worker import run_worker_loop +from .api.errors import register_error_handlers +from .api.health import router as health_router +from .config import get_settings +from .config import setup_logging +from .db import cleanup_database +from .db import create_all +from .db import get_engine +from .ui import register_pages +from .worker import run_worker_loop def _start_worker(app: FastAPI) -> None: @@ -25,7 +24,7 @@ def _start_worker(app: FastAPI) -> None: worker_thread = Thread( target=run_worker_loop, kwargs={ - "engine": app.state.db_runtime.engine, + "engine": app.state.db_conn, "stop_event": stop_event, "poll_interval_seconds": 1.0, }, @@ -52,10 +51,11 @@ async def _lifespan(app: FastAPI): settings = get_settings() app.state.settings = settings - app.state.db_runtime = initialize_database_runtime(settings=settings) + engine = get_engine() + app.state.db_conn = engine - if should_bootstrap_schema(settings): - create_all(engine=app.state.db_runtime.engine) + if settings.should_bootstrap_schema: + create_all(engine=engine) settings.upload_dir.mkdir(parents=True, exist_ok=True) settings.prompt_dir.mkdir(parents=True, exist_ok=True) @@ -65,7 +65,7 @@ async def _lifespan(app: FastAPI): yield finally: _stop_worker(app) - dispose_database_runtime() + cleanup_database() def create_app() -> FastAPI: @@ -75,4 +75,3 @@ def create_app() -> FastAPI: register_pages(app) app.include_router(health_router) return app - diff --git a/src/transcription/config.py b/src/transcription/config.py index 05b3ed1..6b6b318 100644 --- a/src/transcription/config.py +++ b/src/transcription/config.py @@ -5,14 +5,16 @@ once at startup. Provider-specific defaults (model names, base URLs) are resolved by the provider adapters, not here. """ -import logging import logging.config +from contextvars import ContextVar from enum import StrEnum -from functools import lru_cache from pathlib import Path from typing import Literal -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings +from pydantic_settings import SettingsConfigDict + +logger = logging.getLogger(__name__) class Provider(StrEnum): @@ -48,6 +50,24 @@ class Settings(BaseSettings): worker_max_retries: int = 0 worker_retry_backoff_seconds: float = 0.0 + @property + def should_bootstrap_schema(self) -> bool: + """Return whether startup should auto-create schema for this environment.""" + if self.bootstrap_schema_on_startup is not None: + return self.bootstrap_schema_on_startup + return self.environment in {"development", "test"} + + +_settings: ContextVar[Settings | None] = ContextVar("settings", default=None) + + +def get_settings() -> Settings: + settings = _settings.get() + if settings is None: + settings = Settings() # pyright: ignore[reportCallIssue] + _settings.set(settings) + return settings + LOGGING_CONFIG: dict[str, object] = { "version": 1, @@ -69,18 +89,17 @@ LOGGING_CONFIG: dict[str, object] = { "level": "INFO", "handlers": ["console"], }, + "loggers": { + "transcription": { + "level": "DEBUG", + "handlers": ["console"], + "propagate": False, + } + }, } -@lru_cache(maxsize=1) -def get_settings() -> Settings: - """Return the singleton Settings instance. - - Cached so the entire application shares one validated config. - """ - return Settings() - - def setup_logging() -> None: """Configure root logging once at startup.""" logging.config.dictConfig(LOGGING_CONFIG) + logger.debug("Logging configured") diff --git a/src/transcription/db.py b/src/transcription/db.py index 67f75fc..5209d41 100644 --- a/src/transcription/db.py +++ b/src/transcription/db.py @@ -7,70 +7,44 @@ startup/shutdown behavior is predictable and lifespan-managed. import contextlib import logging from collections.abc import Generator -from dataclasses import dataclass +from functools import cache -from sqlalchemy import inspect, text +from sqlalchemy import inspect +from sqlalchemy import text from sqlalchemy.engine import Engine -from sqlmodel import Session, SQLModel, create_engine +from sqlmodel import Session +from sqlmodel import SQLModel +from sqlmodel import create_engine -from transcription.config import Settings, get_settings +from .config import Settings +from .config import get_settings logger = logging.getLogger(__name__) -@dataclass(frozen=True) -class DatabaseRuntime: - """Process-level database runtime resources.""" - - engine: Engine - - -_runtime: DatabaseRuntime | None = None - - def _build_engine(settings: Settings) -> Engine: connect_args: dict[str, object] = {} if settings.database_url.startswith("sqlite"): connect_args["check_same_thread"] = False return create_engine( - settings.database_url, + url=settings.database_url, echo=False, connect_args=connect_args, ) -def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime: - """Initialize and cache the process database runtime once.""" - global _runtime - - if _runtime is not None: - return _runtime - - runtime_settings = settings or get_settings() - _runtime = DatabaseRuntime(engine=_build_engine(runtime_settings)) - return _runtime +@cache +def get_engine() -> Engine: + """Return a new SQLAlchemy engine for the current settings.""" + settings = get_settings() + logger.debug("Creating new SQLAlchemy engine for database_url=%s", settings.database_url) + return _build_engine(settings) -def get_database_runtime() -> DatabaseRuntime: - """Return initialized database runtime, creating it if needed.""" - if _runtime is None: - return initialize_database_runtime() - return _runtime - - -def dispose_database_runtime() -> None: - """Dispose process database runtime resources.""" - global _runtime - if _runtime is not None: - _runtime.engine.dispose() - _runtime = None - - -def should_bootstrap_schema(settings: Settings) -> bool: - """Return whether startup should auto-create schema for this environment.""" - if settings.bootstrap_schema_on_startup is not None: - return settings.bootstrap_schema_on_startup - return settings.environment in {"development", "test"} +def cleanup_database() -> None: + """Cleanup database runtime resources.""" + engine = get_engine() + engine.dispose() def create_all(*, engine: Engine | None = None) -> None: @@ -78,9 +52,10 @@ def create_all(*, engine: Engine | None = None) -> None: # Import models so SQLModel metadata is fully registered before bootstrap. from transcription import models as _models # noqa: F401 - active_engine = engine or get_database_runtime().engine + active_engine = engine or get_engine() SQLModel.metadata.create_all(active_engine) _ensure_sqlite_compat_columns(active_engine) + logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url) def _ensure_sqlite_compat_columns(engine: Engine) -> None: @@ -100,17 +75,13 @@ def _ensure_sqlite_compat_columns(engine: Engine) -> None: columns = {column["name"] for column in inspector.get_columns("job")} if "retry_count" not in columns: with engine.begin() as connection: - connection.execute( - text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0") - ) - logger.warning( - "Applied SQLite compatibility schema patch table=job column=retry_count default=0" - ) + connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0")) + logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0") @contextlib.contextmanager def get_session(*, engine: Engine | None = None) -> Generator[Session]: """Yield a database session and ensure cleanup.""" - active_engine = engine or get_database_runtime().engine + active_engine = engine or get_engine() with Session(active_engine) as session: yield session diff --git a/src/transcription/errors.py b/src/transcription/errors.py index a4b429c..829ebf2 100644 --- a/src/transcription/errors.py +++ b/src/transcription/errors.py @@ -3,7 +3,8 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC +from datetime import datetime from enum import StrEnum from uuid import uuid4 @@ -64,7 +65,7 @@ def build_error_envelope(error: AppError) -> ErrorEnvelope: category=error.category.value, message=error.message, suggestion=error.suggestion, - timestamp=datetime.now(timezone.utc).isoformat(), + timestamp=datetime.now(UTC).isoformat(), ) @@ -83,4 +84,4 @@ def format_error_detail(error: AppError) -> str: return ( f"[{error.category.value}] {error.message} | " f"suggestion={error.suggestion} | error_id={error.error_id}" - ) \ No newline at end of file + ) diff --git a/src/transcription/models.py b/src/transcription/models.py index 9dd5f27..f35940d 100644 --- a/src/transcription/models.py +++ b/src/transcription/models.py @@ -4,12 +4,16 @@ Three models capture the MVP lifecycle: Document -> one-to-many -> Job -> one-to-one -> Transcript """ -from datetime import datetime, timezone +from datetime import UTC +from datetime import datetime from enum import StrEnum from typing import Optional -from uuid import UUID, uuid4 +from uuid import UUID +from uuid import uuid4 -from sqlmodel import Field, Relationship, SQLModel +from sqlmodel import Field +from sqlmodel import Relationship +from sqlmodel import SQLModel class JobStatus(StrEnum): @@ -26,7 +30,7 @@ class Document(SQLModel, table=True): filename: str file_path: str uploaded_at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc), + default_factory=lambda: datetime.now(UTC), ) # --- relationships --- @@ -41,10 +45,10 @@ class Job(SQLModel, table=True): status: JobStatus = Field(default=JobStatus.QUEUED) retry_count: int = Field(default=0, ge=0) created_at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc), + default_factory=lambda: datetime.now(UTC), ) updated_at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc), + default_factory=lambda: datetime.now(UTC), ) # --- relationships --- @@ -60,7 +64,7 @@ class Transcript(SQLModel, table=True): text: str | None = None error_detail: str | None = None created_at: datetime = Field( - default_factory=lambda: datetime.now(timezone.utc), + default_factory=lambda: datetime.now(UTC), ) # --- relationships --- diff --git a/src/transcription/providers/__init__.py b/src/transcription/providers/__init__.py index 6a5c042..318e40a 100644 --- a/src/transcription/providers/__init__.py +++ b/src/transcription/providers/__init__.py @@ -1,13 +1,13 @@ """Provider exports and factory for transcription adapters.""" -from transcription.config import Provider, Settings, get_settings -from transcription.providers.base import ( - ProviderAuthError, - ProviderError, - ProviderResponseError, - TranscriptionProvider, - TranscriptionResult, -) +from transcription.config import Provider +from transcription.config import Settings +from transcription.config import get_settings +from transcription.providers.base import ProviderAuthError +from transcription.providers.base import ProviderError +from transcription.providers.base import ProviderResponseError +from transcription.providers.base import TranscriptionProvider +from transcription.providers.base import TranscriptionResult from transcription.providers.openrouter import OpenRouterTranscriptionProvider @@ -21,11 +21,11 @@ def get_transcription_provider(*, settings: Settings | None = None) -> Transcrip __all__ = [ + "OpenRouterTranscriptionProvider", "ProviderAuthError", "ProviderError", "ProviderResponseError", "TranscriptionProvider", "TranscriptionResult", - "OpenRouterTranscriptionProvider", "get_transcription_provider", ] diff --git a/src/transcription/providers/openrouter.py b/src/transcription/providers/openrouter.py index a990b4b..e8ec702 100644 --- a/src/transcription/providers/openrouter.py +++ b/src/transcription/providers/openrouter.py @@ -9,13 +9,12 @@ from typing import Any from openrouter import OpenRouter -from transcription.config import Settings, get_settings -from transcription.providers.base import ( - ProviderAuthError, - ProviderError, - ProviderResponseError, - TranscriptionResult, -) +from transcription.config import Settings +from transcription.config import get_settings +from transcription.providers.base import ProviderAuthError +from transcription.providers.base import ProviderError +from transcription.providers.base import ProviderResponseError +from transcription.providers.base import TranscriptionResult logger = logging.getLogger(__name__) @@ -55,7 +54,7 @@ class OpenRouterTranscriptionProvider: http_referer=request.http_referer, x_open_router_title=request.x_open_router_title, ) - except Exception as exc: # noqa: BLE001 + except Exception as exc: message = str(exc).lower() if "401" in message or "auth" in message or "api key" in message: raise ProviderAuthError("OpenRouter authentication failed") from exc @@ -111,10 +110,7 @@ class OpenRouterTranscriptionProvider: parts: list[str] = [] for item in content: text_part = None - if isinstance(item, dict): - text_part = item.get("text") - else: - text_part = self._get_optional_attr(item, "text") + text_part = item.get("text") if isinstance(item, dict) else self._get_optional_attr(item, "text") if isinstance(text_part, str) and text_part.strip(): parts.append(text_part.strip()) diff --git a/src/transcription/services/__init__.py b/src/transcription/services/__init__.py index d1f3d7d..fb41a54 100644 --- a/src/transcription/services/__init__.py +++ b/src/transcription/services/__init__.py @@ -1,30 +1,25 @@ """Service layer exports.""" -from transcription.services.transcription import ( - DEFAULT_PROMPT_FILE, - PromptLoadError, - TranscriptionError, - load_image_payload, - load_prompt_text, - transcribe_document_image, -) -from transcription.services.upload import ( - SUPPORTED_UPLOAD_EXTENSIONS, - UploadError, - UploadJobResult, - create_upload_job, -) +from transcription.services.transcription import DEFAULT_PROMPT_FILE +from transcription.services.transcription import PromptLoadError +from transcription.services.transcription import TranscriptionError +from transcription.services.transcription import load_image_payload +from transcription.services.transcription import load_prompt_text +from transcription.services.transcription import transcribe_document_image +from transcription.services.upload import SUPPORTED_UPLOAD_EXTENSIONS +from transcription.services.upload import UploadError +from transcription.services.upload import UploadJobResult +from transcription.services.upload import create_upload_job __all__ = [ "DEFAULT_PROMPT_FILE", + "SUPPORTED_UPLOAD_EXTENSIONS", "PromptLoadError", "TranscriptionError", - "load_image_payload", - "load_prompt_text", - "transcribe_document_image", - "SUPPORTED_UPLOAD_EXTENSIONS", "UploadError", "UploadJobResult", "create_upload_job", + "load_image_payload", + "load_prompt_text", + "transcribe_document_image", ] - diff --git a/src/transcription/services/transcription.py b/src/transcription/services/transcription.py index 964466a..0cb14f0 100644 --- a/src/transcription/services/transcription.py +++ b/src/transcription/services/transcription.py @@ -6,16 +6,16 @@ import logging import mimetypes from pathlib import Path -from transcription.config import Settings, get_settings -from transcription.errors import AppError, ErrorCategory -from transcription.providers import ( - ProviderAuthError, - ProviderError, - ProviderResponseError, - TranscriptionProvider, - TranscriptionResult, - get_transcription_provider, -) +from transcription.config import Settings +from transcription.config import get_settings +from transcription.errors import AppError +from transcription.errors import ErrorCategory +from transcription.providers import ProviderAuthError +from transcription.providers import ProviderError +from transcription.providers import ProviderResponseError +from transcription.providers import TranscriptionProvider +from transcription.providers import TranscriptionResult +from transcription.providers import get_transcription_provider logger = logging.getLogger(__name__) diff --git a/src/transcription/services/upload.py b/src/transcription/services/upload.py index 0191402..68c220b 100644 --- a/src/transcription/services/upload.py +++ b/src/transcription/services/upload.py @@ -5,14 +5,19 @@ from __future__ import annotations import logging from dataclasses import dataclass from pathlib import Path -from uuid import UUID, uuid4 +from uuid import UUID +from uuid import uuid4 from sqlmodel import Session -from transcription.config import Settings, get_settings +from transcription.config import Settings +from transcription.config import get_settings from transcription.db import get_session -from transcription.errors import AppError, ErrorCategory -from transcription.models import Document, Job, JobStatus +from transcription.errors import AppError +from transcription.errors import ErrorCategory +from transcription.models import Document +from transcription.models import Job +from transcription.models import JobStatus logger = logging.getLogger(__name__) @@ -69,7 +74,7 @@ def create_upload_job( original_filename=filename, stored_path=stored_path, ) - except Exception as exc: # noqa: BLE001 + except Exception as exc: _best_effort_delete(stored_path) raise UploadError( "Failed to create upload database records", @@ -141,4 +146,4 @@ def _best_effort_delete(path: Path) -> None: if path.exists(): path.unlink() except OSError: - logger.warning("Failed to clean up upload file after DB error: %s", path) \ No newline at end of file + logger.warning("Failed to clean up upload file after DB error: %s", path) diff --git a/src/transcription/ui/__init__.py b/src/transcription/ui/__init__.py index b3487f3..016ed7a 100644 --- a/src/transcription/ui/__init__.py +++ b/src/transcription/ui/__init__.py @@ -12,5 +12,3 @@ def register_pages(app: FastAPI) -> None: register_upload_page() register_jobs_page() ui.run_with(app, mount_path="/ui", show_welcome_message=False) - - diff --git a/src/transcription/ui/error_presenter.py b/src/transcription/ui/error_presenter.py index f3edebd..06148a9 100644 --- a/src/transcription/ui/error_presenter.py +++ b/src/transcription/ui/error_presenter.py @@ -4,7 +4,9 @@ from __future__ import annotations from nicegui import ui -from transcription.errors import AppError, ErrorCategory, classify_unexpected_error +from transcription.errors import AppError +from transcription.errors import ErrorCategory +from transcription.errors import classify_unexpected_error def to_app_error(exc: Exception, *, operation: str) -> AppError: @@ -37,4 +39,4 @@ def summarize_error(exc: Exception, *, operation: str) -> str: error = to_app_error(exc, operation=operation) if error.category == ErrorCategory.INTERNAL_UNEXPECTED: return f"Unexpected error (ref: {error.error_id})" - return f"{error.message} (ref: {error.error_id})" \ No newline at end of file + return f"{error.message} (ref: {error.error_id})" diff --git a/src/transcription/ui/jobs_page.py b/src/transcription/ui/jobs_page.py index 496384d..cfdeb14 100644 --- a/src/transcription/ui/jobs_page.py +++ b/src/transcription/ui/jobs_page.py @@ -9,8 +9,11 @@ from nicegui import ui from sqlmodel import select from transcription.db import get_session -from transcription.models import Document, Job, Transcript -from transcription.ui.error_presenter import show_error, summarize_error +from transcription.models import Document +from transcription.models import Job +from transcription.models import Transcript +from transcription.ui.error_presenter import show_error +from transcription.ui.error_presenter import summarize_error @dataclass(frozen=True) diff --git a/src/transcription/ui/upload_page.py b/src/transcription/ui/upload_page.py index cdf6831..f0e00d1 100644 --- a/src/transcription/ui/upload_page.py +++ b/src/transcription/ui/upload_page.py @@ -7,8 +7,11 @@ from dataclasses import dataclass from nicegui import ui from nicegui.events import UploadEventArguments -from transcription.services.upload import UploadError, UploadJobResult, create_upload_job -from transcription.ui.error_presenter import show_error, summarize_error +from transcription.services.upload import UploadError +from transcription.services.upload import UploadJobResult +from transcription.services.upload import create_upload_job +from transcription.ui.error_presenter import show_error +from transcription.ui.error_presenter import summarize_error @dataclass diff --git a/src/transcription/worker.py b/src/transcription/worker.py index 342015b..513d0ef 100644 --- a/src/transcription/worker.py +++ b/src/transcription/worker.py @@ -4,17 +4,26 @@ from __future__ import annotations import logging import time -from datetime import datetime, timezone +from datetime import UTC +from datetime import datetime from threading import Event from pydantic import ValidationError from sqlalchemy.engine import Engine -from sqlmodel import Session, select +from sqlmodel import Session +from sqlmodel import select -from transcription.config import Settings, get_settings +from transcription.config import Settings +from transcription.config import get_settings from transcription.db import get_session -from transcription.errors import AppError, ErrorCategory, classify_unexpected_error, format_error_detail -from transcription.models import Document, Job, JobStatus, Transcript +from transcription.errors import AppError +from transcription.errors import ErrorCategory +from transcription.errors import classify_unexpected_error +from transcription.errors import format_error_detail +from transcription.models import Document +from transcription.models import Job +from transcription.models import JobStatus +from transcription.models import Transcript from transcription.services.transcription import transcribe_document_image logger = logging.getLogger(__name__) @@ -43,7 +52,7 @@ def _process_next_queued_job(*, session: Session) -> bool: logger.info("Picked queued job operation=worker.pick job_id=%s", job.id) job.status = JobStatus.PROCESSING - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) session.add(job) session.commit() session.refresh(job) @@ -68,7 +77,7 @@ def _process_next_queued_job(*, session: Session) -> bool: result = transcribe_document_image(document.file_path) _upsert_transcript(session=session, job_id=job.id, text=result.text, error_detail=None) job.status = JobStatus.TRANSCRIBED - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) session.add(job) session.commit() logger.info( @@ -77,7 +86,7 @@ def _process_next_queued_job(*, session: Session) -> bool: document.id, result.provider, ) - except Exception as exc: # noqa: BLE001 + except Exception as exc: error = exc if isinstance(exc, AppError) else classify_unexpected_error(exc, operation="worker.process_job") settings = _get_worker_settings() if _should_retry(job=job, error=error, settings=settings): @@ -131,7 +140,7 @@ def _requeue_for_retry(*, session: Session, job: Job, error: AppError, settings: _upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error)) job.retry_count += 1 job.status = JobStatus.QUEUED - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) session.add(job) session.commit() if settings.worker_retry_backoff_seconds > 0: @@ -141,7 +150,7 @@ def _requeue_for_retry(*, session: Session, job: Job, error: AppError, settings: def _finalize_failed_job(*, session: Session, job: Job, error: AppError) -> None: _upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error)) job.status = JobStatus.FAILED - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) session.add(job) session.commit()