Revise Source detail page to improve line-wrap issues.
Quality Gate / gate (push) Failing after 11s

This commit is contained in:
Jim Lancaster
2026-08-22 18:56:22 -05:00
parent 0d554c0648
commit 94db493756
3 changed files with 160 additions and 19 deletions
+3 -1
View File
@@ -34,7 +34,9 @@ The list accepts optional `document_id` and `job_id` query parameters. Document
- **Delete Source** opens the guarded delete route. - **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. - 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 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. - 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. - 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. - SourceJob Metadata shows latest status, Job ID, execution time, provider, model, prompt, and failure detail.
+123 -18
View File
@@ -4,10 +4,14 @@ from __future__ import annotations
import base64 import base64
import json import json
import logging
from pathlib import Path
from uuid import UUID from uuid import UUID
from fastapi import Request from fastapi import Request
from nicegui import ui from nicegui import ui
from PIL import Image
from PIL import UnidentifiedImageError
from transcription.config import Settings from transcription.config import Settings
from transcription.db.models import ExecutionAttempt 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.errors import TranscriptionNotFoundError
from transcription.services.evidence import EvidenceService from transcription.services.evidence import EvidenceService
from transcription.services.evidence import LatestExecutionAttempt from transcription.services.evidence import LatestExecutionAttempt
from transcription.services.source_media import lookup_source_mime_type
from transcription.services.sources import SourceService from transcription.services.sources import SourceService
from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card from transcription.ui.components.cards import archival_card
@@ -43,6 +48,10 @@ from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
logger = logging.getLogger(__name__)
_WIDE_NARROW_ASPECT_RATIO_THRESHOLD = 2.2
def register_page() -> None: # noqa: PLR0915 def register_page() -> None: # noqa: PLR0915
"""Register sources list, detail, and deletion routes.""" """Register sources list, detail, and deletion routes."""
@@ -120,6 +129,7 @@ def register_page() -> None: # noqa: PLR0915
sources_service = SourceService(session_factory=session_factory) sources_service = SourceService(session_factory=session_factory)
evidence_service = EvidenceService(session_factory=session_factory) evidence_service = EvidenceService(session_factory=session_factory)
render_navigation_header(current_path="/sources") render_navigation_header(current_path="/sources")
settings = resolve_runtime_settings(request)
parsed_source_id = parsed_record_id(source_id, noun="Source") parsed_source_id = parsed_record_id(source_id, noun="Source")
if parsed_source_id is None: if parsed_source_id is None:
@@ -143,6 +153,7 @@ def register_page() -> None: # noqa: PLR0915
return return
original_transcription = _resolve_original_transcription(source=source, latest_attempt=latest_attempt) 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 ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row(): with section_header_row():
@@ -176,23 +187,27 @@ def register_page() -> None: # noqa: PLR0915
extra_classes="text-xs", extra_classes="text-xs",
) )
with ui.grid().classes("w-full grid-cols-12 gap-4"): with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-8 gap-4"): if layout_mode == "wide_narrow":
with ui.grid().classes("w-full grid-cols-1 lg:grid-cols-2 gap-4"): _render_wide_narrow_source_content(
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(
source=source, 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, attempts=attempts,
evidence_service=evidence_service, evidence_service=evidence_service,
) )
@@ -200,7 +215,10 @@ def register_page() -> None: # noqa: PLR0915
source=source, source=source,
latest_job_source=latest_job_source, latest_job_source=latest_job_source,
latest_attempt=latest_attempt, 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") @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( def _render_source_metadata_column(
*, *,
source: Source, source: Source,
latest_job_source: JobSource | None, latest_job_source: JobSource | None,
latest_attempt: LatestExecutionAttempt | None, latest_attempt: LatestExecutionAttempt | None,
settings: Settings, settings: Settings,
column_classes: str = "col-span-12 lg:col-span-4 gap-4",
) -> None: ) -> 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_metadata_zone(source, settings=settings)
_render_source_job_metadata_zone( _render_source_job_metadata_zone(
latest_job_source, latest_job_source,
@@ -328,6 +404,35 @@ def _render_source_metadata_column(
_render_source_revision_logistics_zone(source) _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: def _render_source_metadata_zone(source: Source, *, settings: Settings) -> None:
with archival_card(title="Source Metadata"): with archival_card(title="Source Metadata"):
metadata_row("Upload Name:", source.upload_name) metadata_row("Upload Name:", source.upload_name)
+34
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from PIL import Image
from sqlmodel import select from sqlmodel import select
from transcription.db import session_scope from transcription.db import session_scope
@@ -20,6 +21,8 @@ from transcription.db.models import JobStatus
from transcription.db.models import Source from transcription.db.models import Source
from transcription.providers.evidence import TransportEvidence from transcription.providers.evidence import TransportEvidence
from transcription.services.sources import SourceService 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 --- # --- Unit Tests for Model @property Definitions ---
@@ -44,6 +47,37 @@ class TestSourceModelProperties:
assert source.latest_error_detail is None assert source.latest_error_detail is None
assert source.document_name 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 @pytest.mark.asyncio
async def test_source_properties_with_document_and_job_sources(self, seed_job): async def test_source_properties_with_document_and_job_sources(self, seed_job):
job_id = await seed_job( job_id = await seed_job(