generated from john/python-template
V4.6 Phase 1: deletions and quick wins
Pure remediation; no behavior change. Every item traces to a finding in docs/architecture_code_review_2026-08-17.md. Deletions - Delete app_state.py, which had zero importers and whose get_session_factory raised TypeError at runtime [HIGH-01]. - Delete services/transcription.py and point build_prompt_execution imports at services/sources.py; drop the store.py compatibility aliases [MED-05]. - Delete ServiceBase.queue and its unparameterized asyncio.Queue [MED-07]. - Delete db/operations.get_next_queued_job, a divergent duplicate [CRIT-01]. - Drop the discarded load_docs parameter from list_jobs [LOW-03]. Config - Delete worker_retry_backoff_seconds; no backoff behavior existed anywhere, so wiring it would have been a new feature [MED-02]. - Wire sqlite_check_same_thread through get_engine. The engine hardcoded the setting's own default, so this preserves behavior exactly [MED-02]. - Replace DATABASE_URL in docker-compose.yml with the nested DATABASE__DRIVER / DATABASE__PATH names. Settings uses env_nested_delimiter with extra="ignore", so DATABASE_URL was silently discarded [MED-10]. UI - Move the 23KB inline VIBESCRIBE_LOGO_SVG to ui/static/vibescribe_logo.svg and load it through a cached read_svg sibling of read_css [MED-09]. - Route the portrait upload failure through error_presenter.show_error [LOW-07]. - Cancel the job detail auto-refresh timer instead of only deactivating it, and name its interval constant [LOW-06]. Worker - Make WorkerNotifier runtime_checkable and validate the resolved object in resolve_worker_notifier, which previously returned any non-None attribute unchecked [LOW-04]. Docs and lint - Fix two stale paths in services.instructions.md, one of which pointed at the module deleted here [LOW-02]. - ruff check --fix to zero [LOW-01]. Verified: 264 passed, 4 skipped; ruff check clean. Co-authored-by: Copilot App <[email protected]>
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
yield card
|
||||
|
||||
@@ -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]
|
||||
return image_paths[-1]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 23 KiB |
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user