diff --git a/src/transcription/db/operations.py b/src/transcription/db/operations.py index c938423..0a34ce5 100644 --- a/src/transcription/db/operations.py +++ b/src/transcription/db/operations.py @@ -15,13 +15,12 @@ logger = logging.getLogger(__name__) async def create_all(*, engine: AsyncEngine | None = None) -> None: - """Create all tables on the selected engine from scratch.""" + """Create any missing tables on the selected engine.""" # Import models so SQLModel metadata is fully registered before bootstrap. from transcription.db import models as _models # noqa: F401 active_engine = engine or resolve_engine() async with active_engine.begin() as connection: - await connection.run_sync(SQLModel.metadata.drop_all) await connection.run_sync(SQLModel.metadata.create_all) logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url) diff --git a/src/transcription/services/transcription.py b/src/transcription/services/transcription.py index 1f26d35..ad8b10b 100644 --- a/src/transcription/services/transcription.py +++ b/src/transcription/services/transcription.py @@ -83,6 +83,27 @@ class TranscriptionService(ServiceBase): ) return source + async def read_source_detail(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source: + """Read a source page record with job-source context for UI detail rendering.""" + async with self._session_scope(session) as _session: + query = ( + select(Source) + .options( + selectinload(Source.job_sources).selectinload(JobSource.job), # pyright: ignore[reportArgumentType] + ) + .where(Source.id == source_id) + .execution_options(populate_existing=True) + ) + source = (await _session.exec(query)).first() + + if source is None: + raise TranscriptionNotFoundError( + f"Source with id {source_id} not found", + category=ErrorCategory.NOT_FOUND, + suggestion="Verify the source id and retry.", + ) + return source + async def update_source(self, source: Source, *, session: AsyncSession | None = None) -> Source: """Update an existing source page record.""" async with self._session_scope(session) as _session: diff --git a/src/transcription/ui/__init__.py b/src/transcription/ui/__init__.py index 30d4678..bdf5bf2 100644 --- a/src/transcription/ui/__init__.py +++ b/src/transcription/ui/__init__.py @@ -6,6 +6,7 @@ from nicegui import ui from transcription.ui.pages.documents_page import register_page as register_documents_page from transcription.ui.pages.jobs_page import register_page as register_jobs_page from transcription.ui.pages.people_page import register_page as register_people_page +from transcription.ui.pages.sources_page import register_page as register_sources_page from transcription.ui.pages.upload_page import register_page as register_upload_page from transcription.ui.resources import read_css @@ -27,5 +28,6 @@ def register_pages(app: FastAPI) -> None: register_upload_page() register_documents_page() register_people_page() + register_sources_page() register_jobs_page() ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False) diff --git a/src/transcription/ui/components/app_shell.py b/src/transcription/ui/components/app_shell.py index 5257f27..b66cb44 100644 --- a/src/transcription/ui/components/app_shell.py +++ b/src/transcription/ui/components/app_shell.py @@ -9,6 +9,7 @@ from transcription.ui.resources import read_css NAV_ITEMS: tuple[tuple[str, str, str], ...] = ( ("Documents", "/documents", "description"), ("People", "/people", "group"), + ("Sources", "/sources", "folder"), ("Jobs", "/jobs", "work_history"), ) @@ -20,6 +21,8 @@ def _is_active_path(*, current_path: str, item_path: str) -> bool: return current_path == "/documents" or current_path.startswith("/documents/") if item_path == "/people": return current_path == "/people" or current_path.startswith("/people/") + if item_path == "/sources": + return current_path == "/sources" or current_path.startswith("/sources/") return current_path == item_path diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py index c2e78ae..e7f9eb2 100644 --- a/src/transcription/ui/pages/documents_page.py +++ b/src/transcription/ui/pages/documents_page.py @@ -6,6 +6,7 @@ from datetime import date from uuid import UUID from fastapi import Request +from fastapi.responses import RedirectResponse from nicegui import ui from transcription.db.models import Document @@ -211,7 +212,7 @@ def register_page() -> None: with ui.row().classes("w-full items-center gap-2"): ui.button( "Sources", - on_click=lambda: ui.navigate.to(f"/documents/{document.id}/sources"), + on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"), icon="description", ).props("flat") ui.button( @@ -274,40 +275,9 @@ def register_page() -> None: ) @ui.page("/documents/{document_id}/sources") - async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> None: - document_service = DocumentService(session_factory=session_factory) - render_navigation_header(current_path="/documents") - - try: - parsed_document_id = UUID(document_id) - except ValueError: - ui.label("Invalid document id").classes("text-h6 text-negative") - return - - try: - document = await document_service.read_document_detail(document_id=parsed_document_id) - except DocumentError: - ui.label("Document not found").classes("text-h6 text-negative") - return - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Load failed", operation="documents.sources") - return - - ui.label(f"Sources for {document.name}").classes("text-h5 text-weight-medium") - with ui.row().classes("w-full items-center gap-2"): - ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back") - ui.button("Add sources", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="upload_file").props( - 'unelevated color="primary"' - ) - - if not document.sources: - ui.label("No sources added yet.").classes("text-body2 vibe-text-muted") - return - - for source in sorted(document.sources, key=lambda item: item.page_number): - with ui.card().classes("w-full"): - ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-body2") - ui.label(f"Stored filename: {source.filename}").classes("text-body2") + async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse: + _ = session_factory + return RedirectResponse(url=f"/ui/sources?document_id={document_id}") @ui.page("/documents/{document_id}/edit") async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None: diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index 95eeb09..c664f54 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -218,7 +218,7 @@ def register_page() -> None: # noqa: PLR0915 ).props("flat") ui.button( "Sources", - on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}/sources"), + on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"), icon="description", ).props("flat") ui.button( diff --git a/src/transcription/ui/pages/sources_page.py b/src/transcription/ui/pages/sources_page.py new file mode 100644 index 0000000..e356b1c --- /dev/null +++ b/src/transcription/ui/pages/sources_page.py @@ -0,0 +1,222 @@ +"""Sources list and detail page registration.""" + +from __future__ import annotations + +from uuid import UUID +from urllib.parse import urlencode + +from fastapi import Request +from fastapi.responses import RedirectResponse +from nicegui import ui + +from transcription.db.models import JobSource +from transcription.db.models import Source +from transcription.services.documents import DocumentError +from transcription.services.documents import DocumentService +from transcription.services.jobs import JobService +from transcription.services.transcription import TranscriptionService +from transcription.services.transcription import TranscriptionNotFoundError +from transcription.ui.components.app_shell import render_navigation_header +from transcription.ui.components.document_panzoom import render_document_panzoom +from transcription.ui.components.error_presenter import show_error + +from ...db.session import SessionFactoryDep + + +def register_page() -> None: + """Register source list and detail routes.""" + + @ui.page("/sources") + async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None: + sources_service = TranscriptionService(session_factory=session_factory) + jobs_service = JobService(session_factory=session_factory) + documents_service = DocumentService(session_factory=session_factory) + render_navigation_header(current_path="/sources") + + document_id_text = request.query_params.get("document_id") + job_id_text = request.query_params.get("job_id") + + document_id = _parse_uuid(document_id_text) + job_id = _parse_uuid(job_id_text) + + document_name = None + job_label = None + back_path = None + sources: list[Source] = [] + + try: + if document_id is not None: + document = await documents_service.read_document_detail(document_id=document_id) + document_name = document.name + back_path = f"/documents/{document.id}" + sources = list(sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold()))) + elif job_id is not None: + job = await jobs_service.read_job(job_id=job_id) + job_label = str(job.id) + back_path = f"/jobs/{job.id}" + job_sources = await sources_service.list_job_sources(job_id=job.id) + sources = [job_source.source for job_source in job_sources if job_source.source is not None] + sources.sort(key=lambda item: (item.page_number, item.upload_name.casefold())) + else: + sources = list(sorted(await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number))) + except DocumentError: + ui.label("Document not found").classes("text-h6 text-negative") + return + except ValueError: + ui.label("Job not found").classes("text-h6 text-negative") + return + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Load failed", operation="sources.list") + return + + if document_name is not None: + ui.label(f"Sources for {document_name}").classes("text-h5 text-weight-medium") + elif job_label is not None: + ui.label(f"Sources for Job {job_label}").classes("text-h5 text-weight-medium") + else: + ui.label("Sources").classes("text-h5 text-weight-medium") + + with ui.row().classes("w-full items-center gap-2"): + if back_path is not None: + back_label = "Back to Document" if document_id is not None else "Back to Job" + ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back") + + if not sources: + ui.label("No sources added yet.").classes("text-body2 vibe-text-muted") + return + + with ui.column().classes("w-full gap-2"): + for source in sources: + with ui.card().classes("w-full") as card: + card.on( + "click", + lambda _=None, source_id=source.id, filters=_build_filter_query(document_id=document_id, job_id=job_id): ui.navigate.to( + f"/sources/{source_id}{filters}" + ), + ) + card.classes("cursor-pointer") + with ui.column().classes("gap-1"): + ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-subtitle1 text-weight-medium") + ui.label(f"Stored filename: {source.filename}").classes("text-body2") + ui.label(f"Document id: {source.document_id}").classes("text-body2 vibe-text-muted") + + @ui.page("/sources/{source_id}") + async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None: + sources_service = TranscriptionService(session_factory=session_factory) + render_navigation_header(current_path="/sources") + + try: + parsed_source_id = UUID(source_id) + except ValueError: + ui.label("Invalid source id").classes("text-h6 text-negative") + return + + try: + source = await sources_service.read_source_detail(source_id=parsed_source_id) + except TranscriptionNotFoundError: + ui.label("Source not found").classes("text-h6 text-negative") + return + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Load failed", operation="sources.read") + return + + back_path = _back_path_from_query(request.query_params) + ui.label(f"Source {source.upload_name}").classes("text-h5 text-weight-medium") + + with ui.row().classes("w-full items-center gap-2"): + if back_path is not None: + back_label = "Back to Document" if "document_id" in request.query_params else "Back to Job" if "job_id" in request.query_params else "Back to Sources" + ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back") + else: + ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back") + + with ui.column().classes("w-full gap-3"): + render_document_panzoom(source=source) + + with ui.column().classes("gap-1"): + ui.label(f"Page number: {source.page_number}").classes("text-body2") + ui.label(f"Upload name: {source.upload_name}").classes("text-body2") + ui.label(f"Stored filename: {source.filename}").classes("text-body2") + ui.label(f"Document id: {source.document_id}").classes("text-body2") + ui.label(f"Uploaded: {source.date_uploaded.isoformat()}").classes("text-body2") + ui.label(f"Revised: {source.date_revised.isoformat() if source.date_revised else 'not set'}").classes("text-body2") + + ui.separator() + ui.label("Transcription text").classes("text-subtitle1 text-weight-medium") + ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly").classes("w-full") + + ui.label("Revision text").classes("text-subtitle1 text-weight-medium") + revision_input = ui.textarea(label="Revision text", value=source.revised_text or "").props("outlined autogrow") + revision_input.classes("w-full") + + async def save_revision() -> None: + candidate = (revision_input.value or "").strip() + if not candidate: + ui.notify("Revision text is required.", type="warning") + return + + try: + await sources_service.upsert_revision_for_source(source_id=source.id, text=candidate) + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Save failed", operation="sources.save_revision") + return + + ui.notify("Revision saved", type="positive") + ui.navigate.to(request.url.path + _back_query(request.query_params)) + + with ui.row().classes("w-full items-center gap-2"): + ui.button("Save revision", on_click=save_revision, icon="save").props('unelevated color="primary"') + + @ui.page("/documents/{document_id}/sources") + async def document_sources_page(document_id: str) -> RedirectResponse: + return RedirectResponse(url=f"/ui/sources?document_id={document_id}") + + @ui.page("/jobs/{job_id}/sources") + async def job_sources_page(job_id: str) -> RedirectResponse: + return RedirectResponse(url=f"/ui/sources?job_id={job_id}") + + +def _parse_uuid(value: str | None) -> UUID | None: + if not value: + return None + try: + return UUID(value) + except ValueError: + return None + + +def _build_filter_query(*, document_id: UUID | None, job_id: UUID | None) -> str: + params: dict[str, str] = {} + if document_id is not None: + params["document_id"] = str(document_id) + if job_id is not None: + params["job_id"] = str(job_id) + return f"?{urlencode(params)}" if params else "" + + +def _back_query(query_params) -> str: + params = {} + for key in ("document_id", "job_id"): + if query_params.get(key): + params[key] = query_params.get(key) + return f"?{urlencode(params)}" if params else "" + + +def _back_path_from_query(query_params) -> str | None: + document_id = query_params.get("document_id") + if document_id: + return f"/documents/{document_id}" + job_id = query_params.get("job_id") + if job_id: + return f"/jobs/{job_id}" + return None + + +def _source_transcription_text(source: Source) -> str | None: + for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True): + if job_source.raw_transcription: + return job_source.raw_transcription + for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True): + if job_source.error_detail: + return job_source.error_detail + return None \ No newline at end of file diff --git a/tests/ui/test_documents_page.py b/tests/ui/test_documents_page.py index 7c07a47..e4d68b3 100644 --- a/tests/ui/test_documents_page.py +++ b/tests/ui/test_documents_page.py @@ -218,10 +218,11 @@ class TestDocumentsPageRendering: return str(target.id) document_id = asyncio.run(_seed()) - response = client.get(f"/ui/documents/{document_id}/sources") + response = client.get(f"/ui/sources?document_id={document_id}") assert response.status_code == 200 assert "Sources for Target" in response.text + assert "Back to Document" in response.text assert "target_page.png" in response.text assert "other_page.png" not in response.text diff --git a/tests/ui/test_pages_registration.py b/tests/ui/test_pages_registration.py index c79cf4c..cde900e 100644 --- a/tests/ui/test_pages_registration.py +++ b/tests/ui/test_pages_registration.py @@ -14,9 +14,11 @@ class TestPageRegistration: upload_response = client.get("/ui/upload", follow_redirects=False) documents_response = client.get("/ui/documents") people_response = client.get("/ui/people") + sources_response = client.get("/ui/sources") jobs_response = client.get("/ui/jobs") assert upload_response.status_code == 307 assert documents_response.status_code == 200 assert people_response.status_code == 200 + assert sources_response.status_code == 200 assert jobs_response.status_code == 200 diff --git a/tests/ui/test_sources_page.py b/tests/ui/test_sources_page.py new file mode 100644 index 0000000..0b3b48d --- /dev/null +++ b/tests/ui/test_sources_page.py @@ -0,0 +1,139 @@ +"""Tests for the sources page routes.""" + +import asyncio +from pathlib import Path + +import pytest +from sqlmodel import select + +from transcription.db import session_scope +from transcription.db.models import Document +from transcription.db.models import Job +from transcription.db.models import Source + + +@pytest.mark.integration +class TestSourcesPageRendering: + """Verify source list and detail routes render expected states.""" + + def test_sources_page_renders_empty_state(self, app_client): + _, client = app_client + + response = client.get("/ui/sources") + + assert response.status_code == 200 + assert "Sources" in response.text + assert "No sources added yet." in response.text + + def test_sources_page_lists_seeded_sources(self, app_client): + _, client = app_client + + async def _seed() -> None: + async with session_scope() as session: + document = Document(name="Source Document", document_type="letter") + session.add(document) + await session.flush() + session.add( + Source( + document_id=document.id, + page_number=1, + upload_name="page_one.png", + filename="stored_page_one.png", + file_path="/tmp/stored_page_one.png", + ) + ) + await session.commit() + + asyncio.run(_seed()) + + response = client.get("/ui/sources") + + assert response.status_code == 200 + assert "Page 1: page_one.png" in response.text + assert "stored_page_one.png" in response.text + + def test_sources_page_filters_to_document_context(self, app_client): + _, client = app_client + + async def _seed() -> str: + async with session_scope() as session: + target = Document(name="Target", document_type="letter") + other = Document(name="Other", document_type="record") + session.add(target) + session.add(other) + await session.flush() + + session.add( + Source( + document_id=target.id, + page_number=1, + upload_name="target_page.png", + filename="target_stored.png", + file_path="/tmp/target_stored.png", + ) + ) + session.add( + Source( + document_id=other.id, + page_number=1, + upload_name="other_page.png", + filename="other_stored.png", + file_path="/tmp/other_stored.png", + ) + ) + await session.commit() + return str(target.id) + + document_id = asyncio.run(_seed()) + + response = client.get(f"/ui/sources?document_id={document_id}") + + assert response.status_code == 200 + assert "Sources for Target" in response.text + assert "Back to Document" in response.text + assert "target_page.png" in response.text + assert "other_page.png" not in response.text + + def test_sources_page_filters_to_job_context(self, app_client, seed_job): + _, client = app_client + job_id = seed_job(filename="job-page.png", transcription_text="job text") + + response = client.get(f"/ui/sources?job_id={job_id}") + + assert response.status_code == 200 + assert "Sources for Job" in response.text + assert "Back to Job" in response.text + assert "job-page.png" in response.text + + def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job): + _, client = app_client + fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "small_png.png" + job_id = seed_job( + filename="detail-source.png", + transcription_text="original transcription text", + revision_text="human revision text", + source_file=fixture_path, + ) + + async def _get_source_id() -> str: + async with session_scope() as session: + job = await session.get(Job, job_id) + assert job is not None + source = ( + await session.exec(select(Source).where(Source.document_id == job.document_id)) + ).first() + assert source is not None + return str(source.id) + + source_id = asyncio.run(_get_source_id()) + + response = client.get(f"/ui/sources/{source_id}") + + assert response.status_code == 200 + assert "Source detail-source.png" in response.text + assert "Transcription text" in response.text + assert "original transcription text" in response.text + assert "Revision text" in response.text + assert "human revision text" in response.text + assert "Page number:" in response.text + assert "Stored filename:" in response.text