generated from john/python-template
upload service stuff
This commit is contained in:
@@ -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",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"')
|
||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user