upload service stuff

This commit is contained in:
John Lancaster
2026-06-27 10:10:05 -05:00
parent f2aadf7e53
commit 96cbadd56e
6 changed files with 239 additions and 113 deletions
+3 -22
View File
@@ -1,25 +1,6 @@
"""Service layer exports.""" """Service layer exports."""
from transcription.services.transcription import DEFAULT_PROMPT_FILE from .documents import DocumentService
from transcription.services.transcription import PromptLoadError from .jobs import JobService
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__ = [ __all__ = ["DocumentService", "JobService"]
"DEFAULT_PROMPT_FILE",
"SUPPORTED_UPLOAD_EXTENSIONS",
"PromptLoadError",
"TranscriptionError",
"UploadError",
"UploadJobResult",
"create_upload_job",
"load_image_payload",
"load_prompt_text",
"transcribe_document_image",
]
+69
View File
@@ -0,0 +1,69 @@
from collections.abc import Sequence
from uuid import UUID
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import selectinload
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..db.runtime import get_session_factory
from ..models import Document
class DocumentService:
"""Thin service class for managing documents in the database."""
session_factory: async_sessionmaker[AsyncSession]
def __init__(self, session_factory: async_sessionmaker[AsyncSession] | None = None):
self.session_factory = session_factory or get_session_factory()
async def create_document(self, document: Document) -> Document:
"""Create a new document in the database."""
async with self.session_factory() as session:
session.add(document)
await session.commit()
await session.refresh(document)
return document
async def read_document(self, document_id: UUID) -> Document:
"""Read an existing document from the database.
The selectinload option is used to eagerly load related jobs for the document.
"""
async with self.session_factory() as session:
document = await session.get(
Document,
document_id,
options=(selectinload(Document.jobs),), # pyright: ignore[reportArgumentType]
)
if document is None:
raise ValueError(f"Document with id {document_id} not found")
return document
async def update_document(self, document: Document) -> Document:
"""Update an existing document in the database."""
async with self.session_factory() as session:
await session.merge(document)
await session.commit()
await session.refresh(document)
return document
async def delete_document(self, document: Document) -> None:
"""Delete a document from the database."""
async with self.session_factory() as session:
await session.delete(document)
await session.commit()
async def query_documents(self, *, filename: str | None = None) -> Sequence[Document]:
"""Query documents from the database based on provided filters."""
async with self.session_factory() as session:
query = select(Document)
if filename is not None:
query = query.where(Document.filename == filename)
return (await session.exec(query)).all()
async def list_documents(self) -> Sequence[Document]:
"""List all documents in the database."""
async with self.session_factory() as session:
return (await session.exec(select(Document))).all()
@@ -4,7 +4,12 @@ from __future__ import annotations
import logging import logging
import mimetypes import mimetypes
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from uuid import UUID
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings from transcription.config import Settings
from transcription.config import get_settings from transcription.config import get_settings
@@ -17,6 +22,8 @@ from transcription.providers import TranscriptionProvider
from transcription.providers import TranscriptionResult from transcription.providers import TranscriptionResult
from transcription.providers import get_transcription_provider from transcription.providers import get_transcription_provider
from ..db.runtime import get_session_factory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_PROMPT_FILE = "transcribe_document.md" DEFAULT_PROMPT_FILE = "transcribe_document.md"
@@ -31,6 +38,39 @@ class TranscriptionError(AppError):
"""Raised when transcription execution fails.""" """Raised when transcription execution fails."""
@dataclass(frozen=True)
class UploadJobResult:
"""Summary of created upload records."""
document_id: UUID
job_id: UUID
stored_path: Path
original_filename: str
class TranscriptionService:
"""Service class for managing transcription operations.
This is the top-level service that composes functionality from the other services."""
provider: TranscriptionProvider
def __init__(self, session_factory: async_sessionmaker[AsyncSession] | None = None):
self.session_factory = session_factory or get_session_factory()
self.provider = get_transcription_provider(settings=get_settings())
async def receive_upload(
self,
*,
document_path: str | Path,
prompt_name: str = DEFAULT_PROMPT_FILE,
settings: Settings | None = None,
) -> TranscriptionResult:
"""Receive an uploaded document and transcribe it."""
runtime_settings = settings or get_settings()
logger.info("Starting transcription for document=%s mime_type=%s", document_path, mime_type)
def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settings | None = None) -> str: def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settings | None = None) -> str:
"""Load and validate prompt text from PROMPT_DIR.""" """Load and validate prompt text from PROMPT_DIR."""
runtime_settings = settings or get_settings() runtime_settings = settings or get_settings()
+53 -28
View File
@@ -8,17 +8,21 @@ from pathlib import Path
from uuid import UUID from uuid import UUID
from uuid import uuid4 from uuid import uuid4
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings from transcription.config import Settings
from transcription.config import get_settings from transcription.config import get_settings
from transcription.db import get_session
from transcription.errors import AppError from transcription.errors import AppError
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
from transcription.models import Document from transcription.models import Document
from transcription.models import Job from transcription.models import Job
from transcription.models import JobStatus from transcription.models import JobStatus
from ..db.runtime import get_session_factory
from ..models import Job
from ..models import JobStatus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"} SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
@@ -38,46 +42,43 @@ class UploadJobResult:
original_filename: str original_filename: str
class UploadService:
"""Service class for managing file uploads and job creation."""
session_factory: async_sessionmaker[AsyncSession]
def __init__(self, session_factory: async_sessionmaker[AsyncSession] | None = None):
self.session_factory = session_factory or get_session_factory()
async def upload_file(
self,
*,
filename: str,
file_bytes: bytes,
) -> UploadJobResult:
"""Persist an uploaded file and create document/job records."""
return await create_upload_job(
filename=filename,
file_bytes=file_bytes,
session=self.session_factory(),
)
async def create_upload_job( async def create_upload_job(
*, *,
filename: str, filename: str,
file_bytes: bytes, file_bytes: bytes,
session: AsyncSession | None = None, session: AsyncSession,
settings: Settings | None = None, settings: Settings | None = None,
) -> UploadJobResult: ) -> UploadJobResult:
"""Persist an uploaded file and create document/job records.""" """Persist an uploaded file and create document/job records."""
runtime_settings = settings or get_settings() stored_path = store_file(filename=filename, file_bytes=file_bytes, settings=settings)
_validate_upload(filename=filename, file_bytes=file_bytes)
upload_dir = runtime_settings.upload_dir
upload_dir.mkdir(parents=True, exist_ok=True)
stored_name = _build_stored_filename(filename)
stored_path = upload_dir / stored_name
try: try:
stored_path.write_bytes(file_bytes)
except OSError as exc:
raise UploadError(
"Failed to persist upload file",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Check upload directory permissions and available disk space, then retry.",
) from exc
try:
if session is not None:
document, job = await _create_upload_records( document, job = await _create_upload_records(
session=session, session=session,
original_filename=filename, original_filename=filename,
stored_path=stored_path, stored_path=stored_path,
) )
else:
async with get_session() as local_session:
document, job = await _create_upload_records(
session=local_session,
original_filename=filename,
stored_path=stored_path,
)
except Exception as exc: except Exception as exc:
_best_effort_delete(stored_path) _best_effort_delete(stored_path)
raise UploadError( raise UploadError(
@@ -96,6 +97,30 @@ async def create_upload_job(
) )
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
"""Persist an uploaded file to the configured upload directory."""
runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes)
upload_dir = runtime_settings.upload_dir
upload_dir.mkdir(parents=True, exist_ok=True)
stored_name = _build_stored_filename(filename)
stored_path = upload_dir / stored_name
try:
stored_path.write_bytes(file_bytes)
except OSError as exc:
raise UploadError(
"Failed to persist upload file",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Check upload directory permissions and available disk space, then retry.",
) from exc
logger.info("Stored uploaded file: %s", stored_path)
return stored_path
def _validate_upload(*, filename: str, file_bytes: bytes) -> None: def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
if not file_bytes: if not file_bytes:
raise UploadError( raise UploadError(
+64
View File
@@ -0,0 +1,64 @@
"""Reusable upload widget for document submission."""
from __future__ import annotations
from collections.abc import Awaitable
from collections.abc import Callable
from nicegui import ui
from nicegui.binding import bindable_dataclass
from nicegui.events import UploadEventArguments
from transcription.services.transcription import UploadJobResult
from transcription.services.upload import UploadError
from transcription.services.upload import UploadService
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.error_presenter import summarize_error
type UploadSubmitter = Callable[[str, bytes], Awaitable[UploadJobResult]]
@bindable_dataclass
class UploadWidgetState:
"""Simple state container for upload feedback."""
loading: bool = False
message: str = ""
def render_upload_widget(*, service: UploadService) -> None:
"""Render upload controls and common status/error handling."""
state = UploadWidgetState()
status_label = ui.label("Upload a document to start transcription.")
status_label.bind_text(state, "message")
async def on_upload(event: UploadEventArguments) -> None:
if state.loading:
ui.notify("Upload already in progress. Please wait.", type="warning")
return
state.loading = True
status_label.text = "Uploading..."
try:
payload = await event.file.read()
result = await service.upload_file(filename=event.file.name, file_bytes=payload)
job_id = result.job_id
state.message = f"Created job {job_id}" if job_id is not None else "Upload complete"
status_label.text = state.message
ui.notify(state.message, type="positive")
except UploadError as exc:
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
except Exception as exc: # noqa: BLE001
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
finally:
state.loading = False
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select document file",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf"')
+6 -59
View File
@@ -2,73 +2,20 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from nicegui import ui from nicegui import ui
from nicegui.events import UploadEventArguments
from transcription.services.upload import UploadError from transcription.services.upload import UploadService
from transcription.services.upload import UploadJobResult from transcription.ui.components.upload import render_upload_widget
from transcription.services.upload import create_upload_job
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.error_presenter import summarize_error
@dataclass
class UploadPageState:
"""Simple state container for upload page feedback."""
loading: bool = False
message: str = ""
def accepted_upload_types() -> str:
"""Return accepted file type string for upload input."""
return ".jpg,.jpeg,.png,.tif,.tiff,.pdf"
async def submit_upload(*, filename: str, file_bytes: bytes) -> UploadJobResult:
"""Create an upload job from incoming file data."""
return await create_upload_job(filename=filename, file_bytes=file_bytes)
def register_page() -> None: def register_page() -> None:
"""Register the upload page route.""" """Register the upload page route."""
@ui.page("/") service = UploadService()
@ui.page("/upload", title="Upload Document")
def upload_page() -> None: def upload_page() -> None:
state = UploadPageState() render_upload_widget(service=service)
status_label = ui.label("Upload a document to start transcription.")
async def on_upload(event: UploadEventArguments) -> None:
if state.loading:
ui.notify("Upload already in progress. Please wait.", type="warning")
return
state.loading = True
status_label.text = "Uploading..."
try:
payload = await event.file.read()
result = await submit_upload(filename=event.file.name, file_bytes=payload)
state.message = f"Created job {result.job_id}"
status_label.text = state.message
ui.notify(state.message, type="positive")
except UploadError as exc:
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
except Exception as exc: # noqa: BLE001
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
finally:
state.loading = False
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select document file",
).props(f"accept={accepted_upload_types()}")
with ui.row(): with ui.row():
ui.link("View jobs", "/jobs") ui.link("View jobs", "/jobs")