Files
transcription/src/transcription/services/store.py
T
zoltan57andCopilot App 6a3ee26733 V4.6 Phase 5: UI boundaries and duplication
Fixes the three ui.instructions.md violations recorded as [HIGH-07] and extracts
the page-level duplication catalogued in review section 4.

Boundary violations
- jobs_page no longer imports session_scope or manages a transaction.
  store.create_document_job and store.create_job_for_document accept an optional
  session_factory and open their own session scope when the caller supplies
  neither a session nor a factory.
- sources_page no longer calls sqlalchemy.inspect. SourceService
  .read_latest_execution_attempt now returns a LatestExecutionAttempt read model
  carrying a plain transport_body_deferred flag, so ORM loader state stays inside
  the service. Rendered output is unchanged.
- Deletes ui/components/document_panzoom.py, its export, and its CSS. The
  component was exported but used by no page. Pan-zoom is planned for a clean
  reintroduction in V4.7 alongside the other photo/image work.

Extracted duplication
- ui/components/media_urls.py: pure upload-URL resolution taking upload_dir and
  base_url, replacing two identical ~60-line copies in sources_page and
  people_page.
- ui/components/guards.py: parse-then-render-terminal-message, replacing 28
  hand-written guard labels across five pages.
- ui/components/confirm_delete.py: the blocked-dependency notice and the
  delete/cancel action row, from four delete pages.
- ui/components/upload_panel.py: the auto-uploading file picker, from three
  pages. Source accept lists now derive from services.source_media
  .SOURCE_EXTENSIONS instead of being hard-coded.
- ui/components/table/registry.py: the two hand-rolled label-registry tables on
  the settings page now go through build_table, which gained selection and
  rows_per_page options.
- ui/components/formatters.py gains parse_uuid and parse_iso_date, replacing
  five and two private copies.
- ui/runtime.py owns resolve_runtime_settings, replacing three copies and
  removing get_settings from every page module.

[LOW-05]
- Upload handlers are annotated with events.UploadEventArguments.
- The Document and Person form builders return DocumentFormFields and
  PersonFormFields dataclasses instead of dict[str, Any].

Verification
- tests/test_ui_boundaries.py asserts no page imports a session scope, a session
  factory, get_settings, sqlalchemy, or sqlmodel, and that no component imports
  request or application state.
- 275 passed, 4 skipped. ruff check clean.

Findings: HIGH-07, LOW-05

Co-authored-by: Copilot App <[email protected]>
2026-08-17 17:44:39 -05:00

371 lines
12 KiB
Python

from __future__ import annotations
import hashlib
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from uuid import UUID
from uuid import uuid4
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings
from transcription.config import get_settings
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from ..db.models import Document
from ..db.models import Job
from ..db.models import JobSource
from ..db.models import JobSourceStatus
from ..db.models import Source
from ..db.session import SessionFactory
from ..db.session import session_scope
from .media_storage import build_stored_filename
from .media_storage import write_media_bytes
from .sources import TranscriptionError
from .sources import build_prompt_execution
from .sources import validate_source_content
logger = logging.getLogger(__name__)
class SourceStorageError(AppError):
"""Raised when Source content cannot be validated or persisted safely."""
@dataclass(frozen=True)
class JobCreateResult:
"""Summary of explicit Job create records."""
document_id: UUID
job_id: UUID
source_ids: tuple[UUID, ...]
@dataclass(frozen=True)
class DocumentJobResult:
"""Summary of a Document, Source, and Job created together."""
document_id: UUID
job_id: UUID
stored_path: Path
original_filename: str
@dataclass(frozen=True)
class PendingStoredSource:
"""Pre-staged Source artifact tied to a Source id."""
source_id: UUID
original_filename: str
stored_path: Path
file_hash: str
file_size_bytes: int
async def create_document_job(
*,
filename: str,
file_bytes: bytes,
session: AsyncSession | None = None,
session_factory: SessionFactory | None = None,
settings: Settings | None = None,
) -> DocumentJobResult:
"""Create a Document, its first Source, and a queued Job.
Owns its own session when the caller does not supply one, so UI callers
never have to import a session scope.
"""
runtime_settings = settings or get_settings()
prompt_execution = build_prompt_execution(settings=runtime_settings)
document_id = uuid4()
source_id = uuid4()
stored_path = await store_source_file(
filename=filename,
file_bytes=file_bytes,
settings=runtime_settings,
relative_directory=Path("documents") / str(document_id),
filename_stem=str(source_id),
)
file_hash, file_size_bytes = _compute_file_metadata(file_bytes)
try:
async with session_scope(
session_factory=session_factory,
session=session,
settings=runtime_settings,
) as _session:
document, job = await _create_document_job_records(
session=_session,
document_id=document_id,
source_id=source_id,
original_filename=filename,
stored_path=stored_path,
file_hash=file_hash,
file_size_bytes=file_size_bytes,
prompt_execution=prompt_execution,
)
except Exception as exc:
_best_effort_delete(stored_path)
raise SourceStorageError(
"Failed to create Document, Source, and Job records",
category=ErrorCategory.INFRA_TRANSIENT,
suggestion="Retry upload. If this keeps happening, verify database availability.",
retriable=True,
) from exc
logger.info("Created document job document_id=%s job_id=%s", document.id, job.id)
return DocumentJobResult(
document_id=document.id,
job_id=job.id,
stored_path=stored_path,
original_filename=Path(filename).name,
)
async def create_job_for_document(
*,
document_id: UUID,
source_files: Sequence[tuple[str, bytes]],
session: AsyncSession | None = None,
session_factory: SessionFactory | None = None,
provider: str | None = None,
model: str | None = None,
settings: Settings | None = None,
) -> JobCreateResult:
"""Create a queued Job for an existing Document with one or more Sources.
Owns its own session when the caller does not supply one, so UI callers
never have to import a session scope.
"""
if not source_files:
raise SourceStorageError(
"At least one Source file is required to create a Job",
category=ErrorCategory.VALIDATION,
suggestion="Upload one or more files and try again.",
)
runtime_settings = settings or get_settings()
prompt_execution = build_prompt_execution(settings=runtime_settings)
sorted_source_files = sorted(source_files, key=lambda item: Path(item[0]).name.casefold())
stored_sources: list[PendingStoredSource] = []
for filename, file_bytes in sorted_source_files:
source_id = uuid4()
stored_path = await store_source_file(
filename=filename,
file_bytes=file_bytes,
settings=runtime_settings,
relative_directory=Path("documents") / str(document_id),
filename_stem=str(source_id),
)
stored_sources.append(
PendingStoredSource(
source_id=source_id,
original_filename=filename,
stored_path=stored_path,
file_hash=_compute_file_hash(file_bytes),
file_size_bytes=len(file_bytes),
)
)
try:
async with session_scope(
session_factory=session_factory,
session=session,
settings=runtime_settings,
) as _session:
job, source_ids = await _create_job_for_document_records(
session=_session,
document_id=document_id,
stored_sources=stored_sources,
provider=provider,
model=model,
prompt_execution=prompt_execution,
)
except Exception as exc:
for source in stored_sources:
_best_effort_delete(source.stored_path)
raise SourceStorageError(
"Failed to create Job records from Source files",
category=ErrorCategory.INFRA_TRANSIENT,
suggestion="Retry creation. If this keeps happening, verify database availability.",
retriable=True,
) from exc
logger.info("Created explicit job document_id=%s job_id=%s sources=%s", document_id, job.id, len(source_ids))
return JobCreateResult(
document_id=document_id,
job_id=job.id,
source_ids=tuple(source_ids),
)
async def _create_document_job_records(
*,
session: AsyncSession,
document_id: UUID,
source_id: UUID,
original_filename: str,
stored_path: Path,
file_hash: str,
file_size_bytes: int,
prompt_execution,
) -> tuple[Document, Job]:
document = Document(
id=document_id,
name=Path(original_filename).name,
)
session.add(document)
await session.flush()
job = Job(
document_id=document.id,
prompt_name=prompt_execution.prompt_name,
prompt_hash=prompt_execution.prompt_hash,
system_prompt=prompt_execution.system_prompt,
user_prompt=prompt_execution.user_prompt,
temperature=prompt_execution.temperature,
top_p=prompt_execution.top_p,
)
session.add(job)
await session.flush()
source = Source(
id=source_id,
document_id=document.id,
page_number=1,
upload_name=Path(original_filename).name,
filename=stored_path.name,
file_path=str(stored_path),
file_hash=file_hash,
file_size_bytes=file_size_bytes,
)
session.add(source)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
await session.refresh(document)
await session.refresh(job)
return document, job
async def _create_job_for_document_records(
*,
session: AsyncSession,
document_id: UUID,
stored_sources: Sequence[PendingStoredSource],
provider: str | None,
model: str | None,
prompt_execution,
) -> tuple[Job, list[UUID]]:
document = await session.get(Document, document_id)
if document is None:
raise SourceStorageError(
f"Document with id {document_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Select an existing document and retry.",
)
existing_sources = (await session.exec(select(Source).where(Source.document_id == document_id))).all()
next_page_number = max((source.page_number for source in existing_sources), default=0) + 1
job = Job(
document_id=document_id,
provider=(provider or None),
model=(model or None),
prompt_name=prompt_execution.prompt_name,
prompt_hash=prompt_execution.prompt_hash,
system_prompt=prompt_execution.system_prompt,
user_prompt=prompt_execution.user_prompt,
temperature=prompt_execution.temperature,
top_p=prompt_execution.top_p,
)
session.add(job)
await session.flush()
source_ids: list[UUID] = []
for page_offset, stored_source in enumerate(stored_sources):
source = Source(
id=stored_source.source_id,
document_id=document_id,
page_number=next_page_number + page_offset,
upload_name=Path(stored_source.original_filename).name,
filename=stored_source.stored_path.name,
file_path=str(stored_source.stored_path),
file_hash=stored_source.file_hash,
file_size_bytes=stored_source.file_size_bytes,
)
session.add(source)
await session.flush()
source_ids.append(source.id)
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
await session.refresh(job)
return job, source_ids
def _best_effort_delete(path: Path) -> None:
try:
if path.exists():
path.unlink()
except OSError:
logger.warning("Failed to clean up Source file after database error: %s", path)
def _compute_file_hash(file_bytes: bytes) -> str:
return hashlib.sha256(file_bytes).hexdigest()
def _compute_file_metadata(file_bytes: bytes) -> tuple[str, int]:
return _compute_file_hash(file_bytes), len(file_bytes)
async def store_source_file(
*,
filename: str,
file_bytes: bytes,
settings: Settings | None = None,
relative_directory: Path | None = None,
filename_stem: str | None = None,
) -> Path:
"""Validate and persist a Source file to configured media storage."""
runtime_settings = settings or get_settings()
try:
validate_source_content(filename=filename, content=file_bytes)
except TranscriptionError as exc:
raise SourceStorageError(
exc.message,
category=exc.category,
suggestion=exc.suggestion,
retriable=exc.retriable,
) from exc
upload_dir = runtime_settings.upload_dir
return await write_media_bytes(
target_dir=upload_dir if relative_directory is None else upload_dir / relative_directory,
stored_name=build_stored_filename(filename=filename, filename_stem=filename_stem),
file_bytes=file_bytes,
error=SourceStorageError,
failure_message="Failed to persist Source file",
failure_suggestion="Check upload directory permissions and available disk space, then retry.",
log_label="Source file",
)