diff --git a/.github/instructions/services.instructions.md b/.github/instructions/services.instructions.md index 84803a9..981b01e 100644 --- a/.github/instructions/services.instructions.md +++ b/.github/instructions/services.instructions.md @@ -7,7 +7,7 @@ applyTo: 'src/transcription/services/*.py' ## Structure -- Project core data models defined in [models](../../src/transcription/models.py) +- Project core data models defined in [models](../../src/transcription/db/models.py) - 1 service class per data model - Only services directly interact with the database, and only through async methods - Services are completely independent of one another. Any operation that needs to use more than a single service, which is most of them, needs to have a separate orchestration function. @@ -15,7 +15,7 @@ applyTo: 'src/transcription/services/*.py' ## Error Handling - Service-specific errors defined at the top of the respective module and inherit from `AppError` -- Use a context manager for large `try/except` blocks like in [transcription](../../src/transcription/services/transcription.py) +- Use a context manager for large `try/except` blocks like `handle_transcription_errors` in [sources](../../src/transcription/services/sources.py) ## Checklist diff --git a/docker-compose.yml b/docker-compose.yml index 8b7f92c..52e9314 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,10 @@ services: env_file: - .env environment: - DATABASE_URL: sqlite:////app/data/transcription.db + # Database configuration uses nested settings names (env_nested_delimiter="__"). + # DATABASE_URL is NOT read by the application and must not be used here. + DATABASE__DRIVER: sqlite + DATABASE__PATH: /app/data/transcription.db UPLOAD_DIR: /app/uploads PROMPT_DIR: /app/prompts ports: diff --git a/src/transcription/app_state.py b/src/transcription/app_state.py deleted file mode 100644 index 37a000a..0000000 --- a/src/transcription/app_state.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Helpers for accessing lifespan-owned application state resources.""" - -from __future__ import annotations - -from fastapi import FastAPI -from sqlalchemy.ext.asyncio import async_sessionmaker -from sqlmodel.ext.asyncio.session import AsyncSession - -from transcription.db.runtime import DatabaseRuntime -from transcription.db.session import get_session_factory -from transcription.worker import WorkerNotifier -from transcription.worker import resolve_worker_notifier - - -def resolve_database_runtime(state: object) -> DatabaseRuntime | None: - """Return database runtime from app-like state objects when available.""" - runtime = getattr(state, "runtime", None) - return runtime if isinstance(runtime, DatabaseRuntime) else None - - -def require_database_runtime(state: object) -> DatabaseRuntime: - """Return database runtime or raise when app lifespan has not initialized it.""" - runtime = resolve_database_runtime(state) - if runtime is None: - raise RuntimeError("Database runtime is not initialized on application state") - return runtime - - -def resolve_session_factory(state: object) -> async_sessionmaker[AsyncSession]: - """Return DB session factory from state when available, otherwise shared runtime.""" - runtime = resolve_database_runtime(state) - if runtime is not None: - return runtime.session_factory - return get_session_factory() - - -def get_worker_notifier(app: FastAPI) -> WorkerNotifier: - """Return app worker notifier, or a no-op fallback when unavailable.""" - return resolve_worker_notifier(app.state) diff --git a/src/transcription/config.py b/src/transcription/config.py index c7b722a..7d3514f 100644 --- a/src/transcription/config.py +++ b/src/transcription/config.py @@ -106,7 +106,6 @@ class Settings(BaseSettings): # --- worker reliability --- worker_max_retries: int = Field(default=0, ge=0) - worker_retry_backoff_seconds: float = Field(default=0.0, ge=0.0) worker_provider_timeout_seconds: float = Field(default=20.0, gt=0.0, le=20.0) worker_min_transcription_chars: int = Field(default=0, ge=0) worker_min_transcription_lines: int = Field(default=0, ge=0) diff --git a/src/transcription/db/engine.py b/src/transcription/db/engine.py index 89c541c..bae1490 100644 --- a/src/transcription/db/engine.py +++ b/src/transcription/db/engine.py @@ -33,14 +33,17 @@ def get_database_url(settings: Settings) -> str: def resolve_engine(settings: Settings | None = None) -> AsyncEngine: active_settings = settings or get_settings() - return get_engine(get_database_url(active_settings)) + return get_engine( + get_database_url(active_settings), + sqlite_check_same_thread=active_settings.sqlite_check_same_thread, + ) @cache -def get_engine(database_url: str) -> AsyncEngine: +def get_engine(database_url: str, *, sqlite_check_same_thread: bool = False) -> AsyncEngine: kwargs: dict[str, Any] = {"echo": False, "pool_pre_ping": True} if database_url.startswith("sqlite"): - kwargs["connect_args"] = {"check_same_thread": False} + kwargs["connect_args"] = {"check_same_thread": sqlite_check_same_thread} if ":memory:" in database_url: kwargs["poolclass"] = StaticPool diff --git a/src/transcription/services/base.py b/src/transcription/services/base.py index afcb941..7394cbf 100644 --- a/src/transcription/services/base.py +++ b/src/transcription/services/base.py @@ -1,4 +1,3 @@ -import asyncio from abc import ABC from collections.abc import Sequence from contextlib import asynccontextmanager @@ -17,17 +16,14 @@ class ServiceBase(ABC): settings: Settings session_factory: async_sessionmaker[AsyncSession] - queue: asyncio.Queue def __init__( self, session_factory: async_sessionmaker[AsyncSession] | None = None, - queue: asyncio.Queue | None = None, settings: Settings | None = None, ): self.settings = settings or get_settings() self.session_factory = session_factory or resolve_session_factory(settings=self.settings) - self.queue = queue or asyncio.Queue() @asynccontextmanager async def _session_scope(self, session: AsyncSession | None = None): diff --git a/src/transcription/services/store.py b/src/transcription/services/store.py index 47dec89..d484c29 100644 --- a/src/transcription/services/store.py +++ b/src/transcription/services/store.py @@ -22,8 +22,8 @@ from ..db.models import JobSource from ..db.models import JobSourceStatus from ..db.models import Source from .sources import TranscriptionError +from .sources import build_prompt_execution from .sources import validate_source_content -from .transcription import build_prompt_execution logger = logging.getLogger(__name__) @@ -32,9 +32,6 @@ class SourceStorageError(AppError): """Raised when Source content cannot be validated or persisted safely.""" -UploadError = SourceStorageError - - @dataclass(frozen=True) class JobCreateResult: """Summary of explicit Job create records.""" @@ -377,7 +374,3 @@ def _build_stored_filename(*, filename: str, filename_stem: str | None = None) - suffix = Path(safe_name).suffix.lower() stem = filename_stem or str(uuid4()) return f"{stem}{suffix}" - - -create_upload_job = create_document_job -store_file = store_source_file diff --git a/src/transcription/services/transcription.py b/src/transcription/services/transcription.py deleted file mode 100644 index eb2388e..0000000 --- a/src/transcription/services/transcription.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Compatibility exports for the Source-owned transcription implementation.""" - -from .sources import DEFAULT_PROMPT_FILE -from .sources import SOURCE_EXTENSIONS -from .sources import SOURCE_MIME_TYPES -from .sources import PromptExecution -from .sources import PromptLoadError -from .sources import SourceDeleteBlockedError -from .sources import SourceService -from .sources import TranscriptionError -from .sources import TranscriptionNotFoundError -from .sources import build_prompt_execution -from .sources import handle_transcription_errors -from .sources import load_prompt_text -from .sources import load_source_payload -from .sources import source_mime_type -from .sources import transcribe_document_image -from .sources import validate_source_content - -TranscriptionService = SourceService -SUPPORTED_EXTENSIONS = SOURCE_EXTENSIONS -load_image_payload = load_source_payload - -__all__ = [ - "DEFAULT_PROMPT_FILE", - "SOURCE_EXTENSIONS", - "SOURCE_MIME_TYPES", - "SUPPORTED_EXTENSIONS", - "PromptExecution", - "PromptLoadError", - "SourceDeleteBlockedError", - "SourceService", - "TranscriptionError", - "TranscriptionNotFoundError", - "TranscriptionService", - "build_prompt_execution", - "handle_transcription_errors", - "load_image_payload", - "load_prompt_text", - "load_source_payload", - "source_mime_type", - "transcribe_document_image", - "validate_source_content", -] diff --git a/src/transcription/ui/components/app_shell.py b/src/transcription/ui/components/app_shell.py index bfc7491..dcd6e85 100644 --- a/src/transcription/ui/components/app_shell.py +++ b/src/transcription/ui/components/app_shell.py @@ -4,7 +4,7 @@ from __future__ import annotations from nicegui import ui -from transcription.ui.theme import VIBESCRIBE_LOGO_SVG +from transcription.ui.resources import read_svg NAV_ITEMS: tuple[tuple[str, str, str], ...] = ( ("Documents", "/documents", "description"), @@ -55,7 +55,7 @@ def render_app_shell(*, current_path: str | None = None) -> None: with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"): with ui.element("a").props('href="/ui/homepage"').classes("app-shell__brand no-wrap"): - ui.html(VIBESCRIBE_LOGO_SVG).classes("app-shell__brand-mark") + ui.html(read_svg("vibescribe_logo.svg")).classes("app-shell__brand-mark") ui.label("VibeScribe").classes("app-shell__brand-name") with ui.element("nav").props('aria-label="Primary navigation"').classes("app-shell__nav"): diff --git a/src/transcription/ui/components/cards.py b/src/transcription/ui/components/cards.py index b74e766..5ecc58e 100644 --- a/src/transcription/ui/components/cards.py +++ b/src/transcription/ui/components/cards.py @@ -12,4 +12,4 @@ def archival_card(title: str | None = None, extra_classes: str = ""): ui.label(title.upper()).classes( "text-xs font-bold ui-text-muted tracking-wider mb-3 ui-header-divider pb-1" ) - yield card \ No newline at end of file + yield card diff --git a/src/transcription/ui/homepage_store.py b/src/transcription/ui/homepage_store.py index 2993b63..0032b0c 100644 --- a/src/transcription/ui/homepage_store.py +++ b/src/transcription/ui/homepage_store.py @@ -59,4 +59,4 @@ def latest_homepage_image() -> Path | None: image_paths = list_homepage_images() if not image_paths: return None - return image_paths[-1] \ No newline at end of file + return image_paths[-1] diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index 4432fbd..50b33f2 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -39,6 +39,8 @@ from transcription.worker import resolve_worker_notifier from ...db.session import SessionFactoryDep +JOB_DETAIL_REFRESH_INTERVAL_SECONDS = 4.0 + def register_page() -> None: # noqa: PLR0915 """Register jobs list and detail routes.""" @@ -238,19 +240,24 @@ def register_page() -> None: # noqa: PLR0915 ui.label("This page updates automatically while the job is active.").classes("text-xs ui-text-muted") async def refresh_job() -> None: + def stop_refresh() -> None: + timer = timer_holder[0] + if timer is not None: + timer.cancel() + timer_holder[0] = None + try: current_job[0] = await jobs_service.read_job(job_id=parsed_job_id) except Exception as exc: # noqa: BLE001 - if timer_holder[0] is not None: - timer_holder[0].active = False + stop_refresh() show_error(exc, title="Auto-refresh failed", operation="jobs.detail.refresh") return render_detail.refresh() if current_job[0].status not in {JobStatus.QUEUED, JobStatus.PROCESSING}: - timer_holder[0].active = False + stop_refresh() - timer_holder[0] = ui.timer(4.0, refresh_job) + timer_holder[0] = ui.timer(JOB_DETAIL_REFRESH_INTERVAL_SECONDS, refresh_job) @ui.page("/jobs/{job_id}/cancel") async def job_cancel_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None: diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py index 2444da1..16333c5 100644 --- a/src/transcription/ui/pages/people_page.py +++ b/src/transcription/ui/pages/people_page.py @@ -501,8 +501,8 @@ def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Setti except PersonMediaError as exc: ui.notify(str(exc), type="negative") return - except Exception: # noqa: BLE001 - ui.notify("Unable to store portrait image.", type="negative") + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Upload failed", operation="people.portrait.store") return try: diff --git a/src/transcription/ui/resources.py b/src/transcription/ui/resources.py index 4585a9d..d81e771 100644 --- a/src/transcription/ui/resources.py +++ b/src/transcription/ui/resources.py @@ -10,9 +10,19 @@ from pathlib import PurePosixPath @cache def read_css(relative_path: str) -> str: """Read and cache a CSS resource relative to ``ui/static``.""" + return _read_static(relative_path, suffix=".css") + + +@cache +def read_svg(relative_path: str) -> str: + """Read and cache an SVG resource relative to ``ui/static``.""" + return _read_static(relative_path, suffix=".svg") + + +def _read_static(relative_path: str, *, suffix: str) -> str: resource_path = PurePosixPath(relative_path) - if resource_path.is_absolute() or ".." in resource_path.parts or resource_path.suffix != ".css": - msg = f"Invalid CSS resource path: {relative_path}" + if resource_path.is_absolute() or ".." in resource_path.parts or resource_path.suffix != suffix: + msg = f"Invalid {suffix.lstrip('.').upper()} resource path: {relative_path}" raise ValueError(msg) resource = files("transcription.ui").joinpath("static", *resource_path.parts) diff --git a/src/transcription/ui/static/vibescribe_logo.svg b/src/transcription/ui/static/vibescribe_logo.svg new file mode 100644 index 0000000..338e51a --- /dev/null +++ b/src/transcription/ui/static/vibescribe_logo.svg @@ -0,0 +1,2 @@ + + diff --git a/src/transcription/ui/theme.py b/src/transcription/ui/theme.py index dc7bc92..9029b1b 100644 --- a/src/transcription/ui/theme.py +++ b/src/transcription/ui/theme.py @@ -31,10 +31,3 @@ def page_header(title: str, subtitle: str | None = None) -> None: ui.label(title).classes("ui-page-title") if subtitle: ui.label(subtitle).classes("ui-page-subtitle") - - -# VibeScribe logo -VIBESCRIBE_LOGO_SVG = """ - - -""" \ No newline at end of file diff --git a/src/transcription/worker.py b/src/transcription/worker.py index ab26a77..798111b 100644 --- a/src/transcription/worker.py +++ b/src/transcription/worker.py @@ -9,6 +9,7 @@ from contextlib import asynccontextmanager from contextlib import contextmanager from contextlib import suppress from typing import Protocol +from typing import runtime_checkable from sqlalchemy.ext.asyncio import async_sessionmaker from sqlmodel.ext.asyncio.session import AsyncSession @@ -27,6 +28,7 @@ from .services.workflows import process_next_queued_job as process_next_queued_j logger = logging.getLogger(__name__) +@runtime_checkable class WorkerNotifier(Protocol): """Abstraction for signaling the worker loop about new work.""" @@ -54,11 +56,11 @@ class NoopWorkerNotifier: def resolve_worker_notifier(state: object) -> WorkerNotifier: """Resolve notifier from app-like state objects with no-op fallback.""" notifier = getattr(state, "worker_notifier", None) - if isinstance(notifier, NoopWorkerNotifier): + if isinstance(notifier, WorkerNotifier): return notifier - if notifier is None: - return NoopWorkerNotifier() - return notifier + if notifier is not None: + logger.warning("Ignoring worker_notifier of unsupported type %r; using no-op fallback.", type(notifier)) + return NoopWorkerNotifier() @asynccontextmanager diff --git a/tests/services/test_transcription_external.py b/tests/services/test_transcription_external.py index 240803b..a2d4ad3 100644 --- a/tests/services/test_transcription_external.py +++ b/tests/services/test_transcription_external.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from transcription.services.transcription import transcribe_document_image +from transcription.services.sources import transcribe_document_image HAS_OPENROUTER_KEY = bool(os.getenv("OPENROUTER_API_KEY")) diff --git a/tests/test_app.py b/tests/test_app.py index c84a43b..5308d65 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -20,6 +20,7 @@ class TestAppFactory: app = create_app() assert isinstance(app, FastAPI) + @pytest.mark.integration class TestAppLifespan: """Verify startup and shutdown lifecycle behavior.""" diff --git a/tests/test_config.py b/tests/test_config.py index 4596334..e8d9c8f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -146,7 +146,6 @@ class TestWorkerReliabilitySettings: """Verify worker retry settings defaults.""" def test_worker_retry_defaults(self): - """worker retry settings default to no retries and no backoff.""" + """worker retry settings default to no retries.""" settings = _make_settings() assert settings.worker_max_retries == 0 - assert settings.worker_retry_backoff_seconds == 0.0 diff --git a/tests/ui/test_upload_page.py b/tests/ui/test_upload_page.py index 1031bf1..e695ae0 100644 --- a/tests/ui/test_upload_page.py +++ b/tests/ui/test_upload_page.py @@ -41,4 +41,3 @@ class TestPageRendering: assert response.status_code == 200 assert "Edit Home Page" in response.text assert "Homepage markdown" in response.text -