generated from john/python-template
This commit is contained in:
@@ -34,7 +34,9 @@ The list accepts optional `document_id` and `job_id` query parameters. Document
|
||||
- **Delete Source** opens the guarded delete route.
|
||||
- Previous and Next navigate only among Sources belonging to the same Document in page order; unavailable boundary actions are disabled.
|
||||
- The media viewer resolves the stored Source path through the configured upload root.
|
||||
- The top layout is three columns: Source image, Editable Revision, and Source/SourceJob metadata.
|
||||
- The top layout is adaptive:
|
||||
- Standard pages use three columns with a wider Editable Revision column than the image column.
|
||||
- Wide+narrow landscape images switch to a stacked left layout (image above Editable Revision) with metadata on the right.
|
||||
- Editable Revision is seeded from an existing revision or the preferred machine transcription.
|
||||
- Source Metadata shows upload name, stored filename, page number, Document Name, Document ID, and stored path. Source ID appears in the page-header subtitle.
|
||||
- SourceJob Metadata shows latest status, Job ID, execution time, provider, model, prompt, and failure detail.
|
||||
|
||||
@@ -4,10 +4,14 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from nicegui import ui
|
||||
from PIL import Image
|
||||
from PIL import UnidentifiedImageError
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.db.models import ExecutionAttempt
|
||||
@@ -18,6 +22,7 @@ from transcription.services.errors import SourceDeleteBlockedError
|
||||
from transcription.services.errors import TranscriptionNotFoundError
|
||||
from transcription.services.evidence import EvidenceService
|
||||
from transcription.services.evidence import LatestExecutionAttempt
|
||||
from transcription.services.source_media import lookup_source_mime_type
|
||||
from transcription.services.sources import SourceService
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.cards import archival_card
|
||||
@@ -43,6 +48,10 @@ from transcription.ui.theme import page_header
|
||||
|
||||
from ...db.session import SessionFactoryDep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WIDE_NARROW_ASPECT_RATIO_THRESHOLD = 2.2
|
||||
|
||||
|
||||
def register_page() -> None: # noqa: PLR0915
|
||||
"""Register sources list, detail, and deletion routes."""
|
||||
@@ -120,6 +129,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
sources_service = SourceService(session_factory=session_factory)
|
||||
evidence_service = EvidenceService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/sources")
|
||||
settings = resolve_runtime_settings(request)
|
||||
|
||||
parsed_source_id = parsed_record_id(source_id, noun="Source")
|
||||
if parsed_source_id is None:
|
||||
@@ -143,6 +153,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
return
|
||||
|
||||
original_transcription = _resolve_original_transcription(source=source, latest_attempt=latest_attempt)
|
||||
layout_mode = _resolve_source_detail_layout(source=source, settings=settings)
|
||||
|
||||
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
||||
with section_header_row():
|
||||
@@ -176,23 +187,27 @@ def register_page() -> None: # noqa: PLR0915
|
||||
extra_classes="text-xs",
|
||||
)
|
||||
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
||||
with ui.column().classes("col-span-12 lg:col-span-8 gap-4"):
|
||||
with ui.grid().classes("w-full grid-cols-1 lg:grid-cols-2 gap-4"):
|
||||
with ui.column().classes("gap-2"):
|
||||
_render_source_navigation(navigation.previous_id, navigation.next_id)
|
||||
_render_source_viewer_zone(
|
||||
source,
|
||||
settings=resolve_runtime_settings(request),
|
||||
request=request,
|
||||
)
|
||||
_render_source_transcription_column(
|
||||
source=source,
|
||||
original_transcription=original_transcription,
|
||||
latest_job_source=latest_job_source,
|
||||
sources_service=sources_service,
|
||||
)
|
||||
_render_machine_candidates(
|
||||
if layout_mode == "wide_narrow":
|
||||
_render_wide_narrow_source_content(
|
||||
source=source,
|
||||
navigation=navigation,
|
||||
request=request,
|
||||
settings=settings,
|
||||
original_transcription=original_transcription,
|
||||
latest_job_source=latest_job_source,
|
||||
sources_service=sources_service,
|
||||
attempts=attempts,
|
||||
evidence_service=evidence_service,
|
||||
)
|
||||
else:
|
||||
_render_standard_source_content(
|
||||
source=source,
|
||||
navigation=navigation,
|
||||
request=request,
|
||||
settings=settings,
|
||||
original_transcription=original_transcription,
|
||||
latest_job_source=latest_job_source,
|
||||
sources_service=sources_service,
|
||||
attempts=attempts,
|
||||
evidence_service=evidence_service,
|
||||
)
|
||||
@@ -200,7 +215,10 @@ def register_page() -> None: # noqa: PLR0915
|
||||
source=source,
|
||||
latest_job_source=latest_job_source,
|
||||
latest_attempt=latest_attempt,
|
||||
settings=resolve_runtime_settings(request),
|
||||
settings=settings,
|
||||
column_classes="col-span-12 lg:col-span-4 gap-4"
|
||||
if layout_mode == "wide_narrow"
|
||||
else "col-span-12 lg:col-span-3 gap-4",
|
||||
)
|
||||
|
||||
@ui.page("/sources/{source_id}/delete")
|
||||
@@ -312,14 +330,72 @@ def _render_source_transcription_column(
|
||||
)
|
||||
|
||||
|
||||
def _render_standard_source_content(
|
||||
*,
|
||||
source: Source,
|
||||
navigation: SourceNavigation,
|
||||
request: Request,
|
||||
settings: Settings,
|
||||
original_transcription: str | None,
|
||||
latest_job_source: JobSource | None,
|
||||
sources_service: SourceService,
|
||||
attempts: list[ExecutionAttempt],
|
||||
evidence_service: EvidenceService,
|
||||
) -> None:
|
||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-2"):
|
||||
_render_source_navigation(navigation.previous_id, navigation.next_id)
|
||||
_render_source_viewer_zone(source, settings=settings, request=request)
|
||||
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"):
|
||||
_render_source_transcription_zone(
|
||||
source=source,
|
||||
original_transcription=original_transcription,
|
||||
latest_job_source=latest_job_source,
|
||||
sources_service=sources_service,
|
||||
)
|
||||
_render_machine_candidates(
|
||||
source=source,
|
||||
attempts=attempts,
|
||||
evidence_service=evidence_service,
|
||||
)
|
||||
|
||||
|
||||
def _render_wide_narrow_source_content(
|
||||
*,
|
||||
source: Source,
|
||||
navigation: SourceNavigation,
|
||||
request: Request,
|
||||
settings: Settings,
|
||||
original_transcription: str | None,
|
||||
latest_job_source: JobSource | None,
|
||||
sources_service: SourceService,
|
||||
attempts: list[ExecutionAttempt],
|
||||
evidence_service: EvidenceService,
|
||||
) -> None:
|
||||
with ui.column().classes("col-span-12 lg:col-span-8 gap-4"):
|
||||
_render_source_navigation(navigation.previous_id, navigation.next_id)
|
||||
_render_source_viewer_zone(source, settings=settings, request=request)
|
||||
_render_source_transcription_zone(
|
||||
source=source,
|
||||
original_transcription=original_transcription,
|
||||
latest_job_source=latest_job_source,
|
||||
sources_service=sources_service,
|
||||
)
|
||||
_render_machine_candidates(
|
||||
source=source,
|
||||
attempts=attempts,
|
||||
evidence_service=evidence_service,
|
||||
)
|
||||
|
||||
|
||||
def _render_source_metadata_column(
|
||||
*,
|
||||
source: Source,
|
||||
latest_job_source: JobSource | None,
|
||||
latest_attempt: LatestExecutionAttempt | None,
|
||||
settings: Settings,
|
||||
column_classes: str = "col-span-12 lg:col-span-4 gap-4",
|
||||
) -> None:
|
||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||
with ui.column().classes(column_classes):
|
||||
_render_source_metadata_zone(source, settings=settings)
|
||||
_render_source_job_metadata_zone(
|
||||
latest_job_source,
|
||||
@@ -328,6 +404,35 @@ def _render_source_metadata_column(
|
||||
_render_source_revision_logistics_zone(source)
|
||||
|
||||
|
||||
def _resolve_source_detail_layout(*, source: Source, settings: Settings) -> str:
|
||||
media_type = lookup_source_mime_type(source.file_path)
|
||||
if not media_type.startswith("image/"):
|
||||
return "standard"
|
||||
absolute = (settings.upload_dir / Path(source.file_path)).resolve()
|
||||
dimensions = _read_image_dimensions(absolute)
|
||||
if dimensions is None:
|
||||
return "standard"
|
||||
width, height = dimensions
|
||||
if _is_wide_narrow_image(width=width, height=height):
|
||||
return "wide_narrow"
|
||||
return "standard"
|
||||
|
||||
|
||||
def _is_wide_narrow_image(*, width: int, height: int) -> bool:
|
||||
if width <= 0 or height <= 0:
|
||||
return False
|
||||
return (width / height) >= _WIDE_NARROW_ASPECT_RATIO_THRESHOLD
|
||||
|
||||
|
||||
def _read_image_dimensions(path: Path) -> tuple[int, int] | None:
|
||||
try:
|
||||
with Image.open(path) as image:
|
||||
return image.size
|
||||
except (FileNotFoundError, OSError, UnidentifiedImageError):
|
||||
logger.debug("Unable to read image dimensions for source detail layout: %s", path)
|
||||
return None
|
||||
|
||||
|
||||
def _render_source_metadata_zone(source: Source, *, settings: Settings) -> None:
|
||||
with archival_card(title="Source Metadata"):
|
||||
metadata_row("Upload Name:", source.upload_name)
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlmodel import select
|
||||
|
||||
from transcription.db import session_scope
|
||||
@@ -20,6 +21,8 @@ from transcription.db.models import JobStatus
|
||||
from transcription.db.models import Source
|
||||
from transcription.providers.evidence import TransportEvidence
|
||||
from transcription.services.sources import SourceService
|
||||
from transcription.ui.pages.sources_page import _is_wide_narrow_image
|
||||
from transcription.ui.pages.sources_page import _resolve_source_detail_layout
|
||||
|
||||
# --- Unit Tests for Model @property Definitions ---
|
||||
|
||||
@@ -44,6 +47,37 @@ class TestSourceModelProperties:
|
||||
assert source.latest_error_detail is None
|
||||
assert source.document_name is None
|
||||
|
||||
def test_wide_narrow_aspect_detection_threshold(self):
|
||||
assert _is_wide_narrow_image(width=2200, height=1000)
|
||||
assert not _is_wide_narrow_image(width=1800, height=1000)
|
||||
assert not _is_wide_narrow_image(width=0, height=1000)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_detail_layout_switches_for_wide_narrow_images(self, app_client):
|
||||
app, _ = app_client
|
||||
async with session_scope() as session:
|
||||
document = Document(name="Layout test doc")
|
||||
session.add(document)
|
||||
await session.flush()
|
||||
|
||||
wide_path = app.state.settings.upload_dir / "documents" / str(document.id) / "wide-strip.png"
|
||||
wide_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.new("RGB", (2400, 800), color=(255, 255, 255)).save(wide_path, format="PNG")
|
||||
|
||||
source = Source(
|
||||
document_id=document.id,
|
||||
page_number=1,
|
||||
upload_name="wide-strip.png",
|
||||
filename="wide-strip.png",
|
||||
file_path=wide_path.relative_to(app.state.settings.upload_dir).as_posix(),
|
||||
file_hash="c" * 64,
|
||||
file_size_bytes=wide_path.stat().st_size,
|
||||
)
|
||||
session.add(source)
|
||||
await session.commit()
|
||||
|
||||
assert _resolve_source_detail_layout(source=source, settings=app.state.settings) == "wide_narrow"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_properties_with_document_and_job_sources(self, seed_job):
|
||||
job_id = await seed_job(
|
||||
|
||||
Reference in New Issue
Block a user