diff --git a/README.md b/README.md index e152d6c..860c176 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ The app lets you upload a document image/PDF, queues a background transcription - Persist document + job records in SQLite - Process jobs in a background worker (`queued -> processing -> transcribed/failed`) - Store transcript text (or failure detail) +- Track transcript revisions (AI-generated and manual updates) - Show status and results in the NiceGUI interface ## Quick start @@ -61,8 +62,11 @@ uv run uvicorn transcription.app:create_app --factory --reload - **Job detail page** (`/ui/jobs/{job_id}`) - Shows job metadata and status. - - Displays transcript text when successful. - - Displays failure detail when transcription fails. + - Shows transcript metadata, including provider and model. + - Shows a version table with `Created` and `Version`. + - Displays latest version text in an editable textbox. + - **Update** creates a new transcript version. + - Displays failure detail for failed revisions. ## Prompt artifacts diff --git a/src/transcription/db/operations.py b/src/transcription/db/operations.py index ce75d97..11c4aa2 100644 --- a/src/transcription/db/operations.py +++ b/src/transcription/db/operations.py @@ -58,3 +58,9 @@ def _ensure_sqlite_compat_columns(connection: Connection) -> None: if "retry_count" not in columns: connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0")) logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0") + + if "transcript" in table_names: + transcript_columns = {column["name"] for column in inspector.get_columns("transcript")} + if "model" not in transcript_columns: + connection.execute(text("ALTER TABLE transcript ADD COLUMN model VARCHAR")) + logger.warning("Applied SQLite compatibility schema patch table=transcript column=model") diff --git a/src/transcription/models.py b/src/transcription/models.py index 60eb089..2b7061d 100644 --- a/src/transcription/models.py +++ b/src/transcription/models.py @@ -1,7 +1,9 @@ """SQLModel domain models for the transcription system. -Three models capture the MVP lifecycle: - Document -> one-to-many -> Job -> one-to-one -> Transcript +Core models capture the MVP lifecycle: +Document (1) -> (many) Job +Job (1) -> (1) Transcript +Job (1) -> (many) TranscriptRevision """ from datetime import UTC @@ -11,6 +13,7 @@ from typing import Optional from uuid import UUID from uuid import uuid4 +from sqlalchemy import UniqueConstraint from sqlmodel import Field from sqlmodel import Relationship from sqlmodel import SQLModel @@ -48,6 +51,7 @@ class Job(SQLModel, table=True): # --- relationships --- document: Document = Relationship(back_populates="jobs") transcript: Optional["Transcript"] = Relationship(back_populates="job") + transcript_revisions: list["TranscriptRevision"] = Relationship(back_populates="job") @property def filename(self) -> str: @@ -65,6 +69,8 @@ class Transcript(SQLModel, table=True): """Name of the transcription provider used to generate this transcript.""" prompt_name: str """Name of the prompt used to generate this transcript.""" + model: str | None = None + """Provider model that generated the original AI transcript.""" text: str | None = None """The transcribed text. This may be None if the job failed or is still in progress.""" error_detail: str | None = None @@ -73,3 +79,23 @@ class Transcript(SQLModel, table=True): # --- relationships --- job: Job = Relationship(back_populates="transcript") + + +class TranscriptRevision(SQLModel, table=True): + """Version history entries for a transcription job.""" + + __table_args__ = (UniqueConstraint("job_id", "version_number", name="uq_transcript_revision_job_version"),) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + job_id: UUID = Field(foreign_key="job.id", index=True) + version_number: int = Field(ge=1) + provider: str + prompt_name: str + model: str | None = None + source: str = Field(default="ai") + text: str | None = None + error_detail: str | None = None + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + + # --- relationships --- + job: Job = Relationship(back_populates="transcript_revisions") diff --git a/src/transcription/providers/base.py b/src/transcription/providers/base.py index aa69a73..d9f8291 100644 --- a/src/transcription/providers/base.py +++ b/src/transcription/providers/base.py @@ -34,6 +34,7 @@ class TranscriptionResult: job_id=job_id, provider=self.provider, prompt_name=self.prompt_name, + model=self.model, text=self.text, ) diff --git a/src/transcription/services/transcription.py b/src/transcription/services/transcription.py index 00af127..faf8ef3 100644 --- a/src/transcription/services/transcription.py +++ b/src/transcription/services/transcription.py @@ -18,6 +18,7 @@ from transcription.config import get_settings from transcription.errors import AppError from transcription.errors import ErrorCategory from transcription.models import Transcript +from transcription.models import TranscriptRevision from transcription.providers import ProviderAuthError from transcription.providers import ProviderError from transcription.providers import ProviderResponseError @@ -63,6 +64,18 @@ class TranscriptionService(ServiceBase): await self._finalize(session=_session, caller_session=session, refresh=(transcript,)) return transcript + async def create_transcript_revision( + self, + transcript_revision: TranscriptRevision, + *, + session: AsyncSession | None = None, + ) -> TranscriptRevision: + """Create a new transcript revision in the database.""" + async with self._session_scope(session) as _session: + _session.add(transcript_revision) + await self._finalize(session=_session, caller_session=session, refresh=(transcript_revision,)) + return transcript_revision + async def read_transcript(self, transcript_id: UUID, *, session: AsyncSession | None = None) -> Transcript: """Read an existing transcript from the database.""" async with self._session_scope(session) as _session: @@ -80,6 +93,27 @@ class TranscriptionService(ServiceBase): ) return transcript + async def read_transcript_revision( + self, + transcript_revision_id: UUID, + *, + session: AsyncSession | None = None, + ) -> TranscriptRevision: + """Read an existing transcript revision from the database.""" + async with self._session_scope(session) as _session: + transcript_revision = await _session.get( + TranscriptRevision, + transcript_revision_id, + options=(selectinload(TranscriptRevision.job),), # pyright: ignore[reportArgumentType] + ) + if transcript_revision is None: + raise TranscriptionNotFoundError( + f"Transcript revision with id {transcript_revision_id} not found", + category=ErrorCategory.NOT_FOUND, + suggestion="Verify the transcript revision id and retry.", + ) + return transcript_revision + async def update_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> Transcript: """Update an existing transcript in the database.""" async with self._session_scope(session) as _session: @@ -87,12 +121,35 @@ class TranscriptionService(ServiceBase): await self._finalize(session=_session, caller_session=session, refresh=(merged,)) return merged + async def update_transcript_revision( + self, + transcript_revision: TranscriptRevision, + *, + session: AsyncSession | None = None, + ) -> TranscriptRevision: + """Update an existing transcript revision in the database.""" + async with self._session_scope(session) as _session: + merged = await _session.merge(transcript_revision) + await self._finalize(session=_session, caller_session=session, refresh=(merged,)) + return merged + async def delete_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> None: """Delete a transcript from the database.""" async with self._session_scope(session) as _session: await _session.delete(transcript) await self._finalize(session=_session, caller_session=session) + async def delete_transcript_revision( + self, + transcript_revision: TranscriptRevision, + *, + session: AsyncSession | None = None, + ) -> None: + """Delete a transcript revision from the database.""" + async with self._session_scope(session) as _session: + await _session.delete(transcript_revision) + await self._finalize(session=_session, caller_session=session) + async def transcribe_document( self, image_path: str | Path, @@ -118,6 +175,7 @@ class TranscriptionService(ServiceBase): error_detail: str | None, provider: str | None = None, prompt_name: str = DEFAULT_PROMPT_FILE, + model: str | None = None, session: AsyncSession | None = None, ) -> Transcript: """Create or update a transcript for a job id.""" @@ -135,11 +193,68 @@ class TranscriptionService(ServiceBase): if provider is not None: transcript.provider = provider transcript.prompt_name = prompt_name + if model is not None: + transcript.model = model _session.add(transcript) await self._finalize(session=_session, caller_session=session, refresh=(transcript,)) return transcript + async def list_transcript_revisions_by_job( + self, + *, + job_id: UUID, + session: AsyncSession | None = None, + ) -> list[TranscriptRevision]: + """Return transcript revisions for a job ordered by version number.""" + async with self._session_scope(session) as _session: + revisions = ( + await _session.exec( + select(TranscriptRevision) + .where(TranscriptRevision.job_id == job_id) + .order_by(TranscriptRevision.version_number) + ) + ).all() + return list(revisions) + + async def append_transcript_revision( + self, + *, + job_id: UUID, + text: str | None, + error_detail: str | None, + provider: str, + prompt_name: str, + model: str | None, + source: str, + session: AsyncSession | None = None, + ) -> TranscriptRevision: + """Append a new transcript revision and allocate the next version number.""" + async with self._session_scope(session) as _session: + latest_version = ( + await _session.exec( + select(TranscriptRevision.version_number) + .where(TranscriptRevision.job_id == job_id) + .order_by(TranscriptRevision.version_number.desc()) + .limit(1) + ) + ).first() + next_version = 1 if latest_version is None else latest_version + 1 + + revision = TranscriptRevision( + job_id=job_id, + version_number=next_version, + provider=provider, + prompt_name=prompt_name, + model=model, + source=source, + text=text, + error_detail=error_detail, + ) + _session.add(revision) + await self._finalize(session=_session, caller_session=session, refresh=(revision,)) + return revision + async def transcribe_document_image( image_path: str | Path, diff --git a/src/transcription/services/workflows.py b/src/transcription/services/workflows.py index 368222b..c60e82e 100644 --- a/src/transcription/services/workflows.py +++ b/src/transcription/services/workflows.py @@ -121,12 +121,24 @@ async def _finalize_transcribed( """Transaction B: transcript + TRANSCRIBED in one commit.""" if session is None: async with services.jobs._session_scope() as local_session: + prompt_name = result.prompt_name or DEFAULT_PROMPT_FILE await services.transcriptions.upsert_transcript_by_job( job_id=job.id, text=result.text, error_detail=None, provider=result.provider, - prompt_name=result.prompt_name, + prompt_name=prompt_name, + model=result.model, + session=local_session, + ) + await services.transcriptions.append_transcript_revision( + job_id=job.id, + text=result.text, + error_detail=None, + provider=result.provider, + prompt_name=prompt_name, + model=result.model, + source="ai", session=local_session, ) updated_job = await services.jobs.mark_job_status( @@ -137,12 +149,24 @@ async def _finalize_transcribed( await local_session.commit() return updated_job + prompt_name = result.prompt_name or DEFAULT_PROMPT_FILE await services.transcriptions.upsert_transcript_by_job( job_id=job.id, text=result.text, error_detail=None, provider=result.provider, - prompt_name=result.prompt_name, + prompt_name=prompt_name, + model=result.model, + session=session, + ) + await services.transcriptions.append_transcript_revision( + job_id=job.id, + text=result.text, + error_detail=None, + provider=result.provider, + prompt_name=prompt_name, + model=result.model, + source="ai", session=session, ) updated_job = await services.jobs.mark_job_status( @@ -165,11 +189,24 @@ async def _finalize_retry( """Transaction C: transcript error + QUEUED + retry increment in one commit.""" if session is None: async with services.jobs._session_scope() as local_session: + provider_name = services.transcriptions.settings.provider.value await services.transcriptions.upsert_transcript_by_job( job_id=job.id, text=None, error_detail=format_error_detail(error), + provider=provider_name, prompt_name=DEFAULT_PROMPT_FILE, + model=services.transcriptions.settings.provider_model, + session=local_session, + ) + await services.transcriptions.append_transcript_revision( + job_id=job.id, + text=None, + error_detail=format_error_detail(error), + provider=provider_name, + prompt_name=DEFAULT_PROMPT_FILE, + model=services.transcriptions.settings.provider_model, + source="ai", session=local_session, ) updated_job = await services.jobs.update_job_state( @@ -180,11 +217,24 @@ async def _finalize_retry( ) await local_session.commit() else: + provider_name = services.transcriptions.settings.provider.value await services.transcriptions.upsert_transcript_by_job( job_id=job.id, text=None, error_detail=format_error_detail(error), + provider=provider_name, prompt_name=DEFAULT_PROMPT_FILE, + model=services.transcriptions.settings.provider_model, + session=session, + ) + await services.transcriptions.append_transcript_revision( + job_id=job.id, + text=None, + error_detail=format_error_detail(error), + provider=provider_name, + prompt_name=DEFAULT_PROMPT_FILE, + model=services.transcriptions.settings.provider_model, + source="ai", session=session, ) updated_job = await services.jobs.update_job_state( @@ -210,11 +260,24 @@ async def _finalize_failed( """Transaction B: transcript error + FAILED in one commit.""" if session is None: async with services.jobs._session_scope() as local_session: + provider_name = services.transcriptions.settings.provider.value await services.transcriptions.upsert_transcript_by_job( job_id=job.id, text=None, error_detail=format_error_detail(error), + provider=provider_name, prompt_name=DEFAULT_PROMPT_FILE, + model=services.transcriptions.settings.provider_model, + session=local_session, + ) + await services.transcriptions.append_transcript_revision( + job_id=job.id, + text=None, + error_detail=format_error_detail(error), + provider=provider_name, + prompt_name=DEFAULT_PROMPT_FILE, + model=services.transcriptions.settings.provider_model, + source="ai", session=local_session, ) updated_job = await services.jobs.mark_job_status( @@ -225,11 +288,24 @@ async def _finalize_failed( await local_session.commit() return updated_job + provider_name = services.transcriptions.settings.provider.value await services.transcriptions.upsert_transcript_by_job( job_id=job.id, text=None, error_detail=format_error_detail(error), + provider=provider_name, prompt_name=DEFAULT_PROMPT_FILE, + model=services.transcriptions.settings.provider_model, + session=session, + ) + await services.transcriptions.append_transcript_revision( + job_id=job.id, + text=None, + error_detail=format_error_detail(error), + provider=provider_name, + prompt_name=DEFAULT_PROMPT_FILE, + model=services.transcriptions.settings.provider_model, + source="ai", session=session, ) updated_job = await services.jobs.mark_job_status( diff --git a/src/transcription/ui/components/job_detail.py b/src/transcription/ui/components/job_detail.py index d93d503..506460d 100644 --- a/src/transcription/ui/components/job_detail.py +++ b/src/transcription/ui/components/job_detail.py @@ -2,11 +2,46 @@ from __future__ import annotations +import base64 +import mimetypes +from collections.abc import Awaitable +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + from nicegui import ui from transcription.models import Document from transcription.models import Job from transcription.models import Transcript +from transcription.models import TranscriptRevision + + +@dataclass(frozen=True) +class RevisionDisplayRow: + id: str + created: str + version: str + text: str + error_detail: str | None + + +def _extract_row_id(args: object) -> str | None: + if isinstance(args, dict): + if isinstance(args.get("row"), dict): + row_id = args["row"].get("id") + return str(row_id) if row_id is not None else None + row_id = args.get("id") + return str(row_id) if row_id is not None else None + + if isinstance(args, list): + for value in args: + if isinstance(value, dict): + row_id = value.get("id") + if row_id is not None: + return str(row_id) + + return None def _status_chip_classes(status: str) -> str: @@ -36,7 +71,119 @@ def _render_document_section(document: Document) -> None: _metadata_row("File path", document.file_path) -def _render_transcript_section(transcript: Transcript | None) -> None: +def _document_data_url(document: Document) -> tuple[str | None, str | None]: + path = Path(document.file_path) + if not path.exists() or not path.is_file(): + return None, "Document preview unavailable: file not found" + + suffix = path.suffix.lower() + mime_type, _ = mimetypes.guess_type(path.name) + if suffix in {".tif", ".tiff"}: + mime_type = "image/tiff" + if mime_type is None: + return None, "Document preview unavailable: unsupported MIME type" + + encoded = base64.b64encode(path.read_bytes()).decode("ascii") + return f"data:{mime_type};base64,{encoded}", None + + +def _render_document_preview(document: Document) -> None: + source, error = _document_data_url(document) + if error is not None or source is None: + ui.label(error or "Document preview unavailable").classes("text-caption text-grey-7") + return + + suffix = Path(document.file_path).suffix.lower() + if suffix == ".pdf": + ui.html( + ( + '' + ) + ) + ui.label("Zoom controls are currently available for image files.").classes("text-caption text-grey-7 q-mt-sm") + return + + zoom_percent = {"value": 100} + + with ui.element("div").style( + "width:100%;height:520px;overflow:auto;border:1px solid #ddd;border-radius:8px;padding:8px;background:#fafafa;" + ): + image = ui.image(source).classes("rounded-borders").style("width:100%;max-width:none;") + + zoom_label = ui.label("Zoom: 100%").classes("text-caption text-grey-7 q-mt-sm") + + def _apply_zoom() -> None: + image.style(f"width:{zoom_percent['value']}%;max-width:none;") + image.update() + zoom_label.text = f"Zoom: {zoom_percent['value']}%" + zoom_label.update() + + def _zoom_in() -> None: + zoom_percent["value"] = min(300, zoom_percent["value"] + 25) + _apply_zoom() + + def _zoom_out() -> None: + zoom_percent["value"] = max(50, zoom_percent["value"] - 25) + _apply_zoom() + + def _zoom_reset() -> None: + zoom_percent["value"] = 100 + _apply_zoom() + + with ui.row().classes("q-gutter-sm q-mt-xs"): + ui.button("-", on_click=_zoom_out) + ui.button("+", on_click=_zoom_in) + ui.button("Reset", on_click=_zoom_reset) + + +def _build_display_rows(transcript: Transcript, revisions: list[TranscriptRevision]) -> list[RevisionDisplayRow]: + ordered = sorted(revisions, key=lambda revision: revision.version_number) + rows: list[RevisionDisplayRow] = [] + + if ordered: + first = ordered[0] + rows.append( + RevisionDisplayRow( + id="original", + created=first.created_at.isoformat(), + version="original", + text=first.text or "", + error_detail=first.error_detail, + ) + ) + for revision in ordered[1:]: + rows.append( + RevisionDisplayRow( + id=str(revision.version_number), + created=revision.created_at.isoformat(), + version=str(revision.version_number), + text=revision.text or "", + error_detail=revision.error_detail, + ) + ) + else: + rows.append( + RevisionDisplayRow( + id="original", + created=transcript.created_at.isoformat(), + version="original", + text=transcript.text or "", + error_detail=transcript.error_detail, + ) + ) + + return rows + + +def _render_transcript_versioned_section( + *, + document: Document | None, + transcript: Transcript | None, + revisions: list[TranscriptRevision], + on_update: Callable[[str], Awaitable[None]] | None, +) -> None: with ui.card().classes("w-full q-pa-md"): ui.label("Transcript").classes("text-subtitle1 text-weight-medium") ui.separator().classes("q-my-sm") @@ -46,24 +193,85 @@ def _render_transcript_section(transcript: Transcript | None) -> None: return with ui.column().classes("w-full q-gutter-y-xs"): + model_name = transcript.model + if model_name is None and revisions: + model_name = revisions[0].model _metadata_row("Provider", transcript.provider) + _metadata_row("Model", model_name or "unknown") _metadata_row("Prompt", transcript.prompt_name) - _metadata_row("Created", transcript.created_at.isoformat()) - if transcript.text: - ui.separator().classes("q-my-sm") - with ui.card().classes("w-full bg-grey-1 q-pa-sm"): - ui.markdown(transcript.text) - return + display_rows = _build_display_rows(transcript, revisions) + rows_by_id = {row.id: row for row in display_rows} - if transcript.error_detail: - ui.separator().classes("q-my-sm") - with ui.card().classes("w-full bg-red-1 text-red-10 q-pa-sm"): - ui.label("Failure detail").classes("text-caption text-uppercase") - ui.label(transcript.error_detail).classes("text-body2") + ui.separator().classes("q-my-sm") + ui.label("Versions").classes("text-subtitle2 text-weight-medium") + table = ui.table( + columns=[ + {"name": "created", "label": "Created", "field": "created", "align": "left"}, + {"name": "version", "label": "Version", "field": "version", "align": "left"}, + ], + rows=[ + { + "id": row.id, + "created": row.created, + "version": row.version, + } + for row in display_rows + ], + row_key="id", + ).classes("w-full") + + default_selected = display_rows[-1].id + selected_label = ui.label(f"Selected version: {rows_by_id[default_selected].version}").classes( + "text-caption text-grey-7" + ) + + ui.separator().classes("q-my-sm") + with ui.row().classes("w-full no-wrap items-start q-gutter-md"): + if document is not None: + with ui.column().classes("w-1/2"): + ui.label("Document Preview").classes("text-subtitle2 text-weight-medium") + _render_document_preview(document) + + with ui.column().classes("w-1/2"): + editor = ( + ui.textarea(label="Transcript text", value=rows_by_id[default_selected].text) + .props("autogrow outlined") + .classes("w-full") + ) + error_label = ui.label("").classes("text-body2 text-red-10") + + def _set_selected(version_id: str) -> None: + selected = rows_by_id.get(version_id) + if selected is None: + return + selected_label.text = f"Selected version: {selected.version}" + editor.value = selected.text + editor.update() + error_label.text = selected.error_detail or "" + error_label.update() + + def _on_row_click(event) -> None: # noqa: ANN001 + row_id = _extract_row_id(event.args) + if row_id is None: + return + _set_selected(row_id) + + table.on("rowClick", _on_row_click) + _set_selected(default_selected) + + if on_update is not None: + ui.button("Update", on_click=lambda: on_update(editor.value or "")) -def render_job_detail(*, job: Job, document: Document | None, transcript: Transcript | None) -> None: +def render_job_detail( + *, + job: Job, + document: Document | None, + transcript: Transcript | None, + revisions: list[TranscriptRevision], + on_update: Callable[[str], Awaitable[None]] | None = None, +) -> None: """Render all sections for the job detail page.""" status_text = job.status.value with ui.column().classes("w-full max-w-4xl q-gutter-md"): @@ -88,4 +296,9 @@ def render_job_detail(*, job: Job, document: Document | None, transcript: Transc if document is not None: _render_document_section(document) - _render_transcript_section(transcript) + _render_transcript_versioned_section( + document=document, + transcript=transcript, + revisions=revisions, + on_update=on_update, + ) diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index 8077a3c..2a950a9 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -13,6 +13,8 @@ from transcription.db import get_session from transcription.models import Document from transcription.models import Job from transcription.models import Transcript +from transcription.models import TranscriptRevision +from transcription.services import ServiceBundle from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import summarize_error from transcription.ui.components.job_detail import render_job_detail @@ -43,15 +45,22 @@ async def fetch_job_rows() -> list[JobTableRow]: ] -async def fetch_job_detail(job_id: UUID) -> tuple[Job | None, Document | None, Transcript | None]: - """Return job, document, and transcript for detail view.""" +async def fetch_job_detail(job_id: UUID) -> tuple[Job | None, Document | None, Transcript | None, list[TranscriptRevision]]: + """Return job, document, transcript snapshot, and revisions for detail view.""" async with get_session() as session: job = await session.get(Job, job_id) if job is None: - return None, None, None + return None, None, None, [] document = await session.get(Document, job.document_id) transcript = (await session.exec(select(Transcript).where(Transcript.job_id == job.id))).first() - return job, document, transcript + revisions = ( + await session.exec( + select(TranscriptRevision) + .where(TranscriptRevision.job_id == job.id) + .order_by(TranscriptRevision.version_number) + ) + ).all() + return job, document, transcript, list(revisions) def register_page() -> None: @@ -83,6 +92,7 @@ def register_page() -> None: @ui.page("/jobs/{job_id}") async def job_detail_page(job_id: str) -> None: ui.label("Job Detail") + content = ui.column().classes("w-full") try: parsed_id = UUID(job_id) except ValueError: @@ -90,12 +100,64 @@ def register_page() -> None: ui.link("Back to jobs", "/jobs") return - job, document, transcript = await fetch_job_detail(parsed_id) - if job is None: - ui.label("Job not found") - ui.link("Back to jobs", "/jobs") - return + async def refresh_content() -> None: + content.clear() + job, document, transcript, revisions = await fetch_job_detail(parsed_id) + if job is None: + with content: + ui.label("Job not found") + return - render_job_detail(job=job, document=document, transcript=transcript) + services = ServiceBundle() + + async def update_transcript_text(value: str) -> None: + try: + update_text = value.strip() + async with get_session() as session: + current_transcript = ( + await session.exec(select(Transcript).where(Transcript.job_id == parsed_id)) + ).first() + provider_name = current_transcript.provider if current_transcript is not None else "openrouter" + prompt_name = ( + current_transcript.prompt_name if current_transcript is not None else "transcribe_document.md" + ) + model_name = current_transcript.model if current_transcript is not None else None + + await services.transcriptions.upsert_transcript_by_job( + job_id=parsed_id, + text=update_text, + error_detail=None, + provider=provider_name, + prompt_name=prompt_name, + model=model_name, + session=session, + ) + await services.transcriptions.append_transcript_revision( + job_id=parsed_id, + text=update_text, + error_detail=None, + provider=provider_name, + prompt_name=prompt_name, + model=model_name, + source="user", + session=session, + ) + await session.commit() + + ui.notify("Transcript updated", type="positive") + await refresh_content() + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Transcript update failed", operation="jobs.detail.update") + + with content: + render_job_detail( + job=job, + document=document, + transcript=transcript, + revisions=revisions, + on_update=update_transcript_text, + ) + + await refresh_content() ui.link("Back to jobs", "/jobs") diff --git a/tests/integration/test_pipeline_flow.py b/tests/integration/test_pipeline_flow.py index 4e83315..17a9ad0 100644 --- a/tests/integration/test_pipeline_flow.py +++ b/tests/integration/test_pipeline_flow.py @@ -6,9 +6,9 @@ import pytest from sqlmodel import select from transcription.config import Settings -from transcription.models import Job, JobStatus, Transcript +from transcription.models import Job, JobStatus, Transcript, TranscriptRevision from transcription.providers.base import TranscriptionResult -from transcription.services.upload import create_upload_job +from transcription.services.store import create_upload_job from transcription.worker import process_next_queued_job @@ -16,24 +16,37 @@ from transcription.worker import process_next_queued_job class TestPipelineSuccessFlow: """Verify end-to-end success lifecycle behavior.""" - def test_upload_then_worker_persists_transcribed_terminal_state(self, session, tmp_path: Path, monkeypatch): + @pytest.mark.asyncio + async def test_upload_then_worker_persists_transcribed_terminal_state(self, async_session, tmp_path: Path, monkeypatch): """Upload followed by worker processing persists transcript and transcribed status.""" settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path) - upload_result = create_upload_job( + upload_result = await create_upload_job( filename="pipeline.jpg", file_bytes=b"pipeline-bytes", - session=session, + session=async_session, settings=settings, ) - def _fake_transcribe(_path: str) -> TranscriptionResult: - return TranscriptionResult(text="Pipeline transcript", provider="openrouter", model="test-model") + async def _fake_transcribe(_path: str) -> TranscriptionResult: + return TranscriptionResult( + text="Pipeline transcript", + provider="openrouter", + prompt_name="transcribe_document.md", + model="test-model", + ) - monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe) + monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _fake_transcribe) - processed = process_next_queued_job(session=session) - job = session.get(Job, upload_result.job_id) - transcript = session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id)).first() + processed = await process_next_queued_job(session=async_session) + job = await async_session.get(Job, upload_result.job_id) + transcript = (await async_session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id))).first() + revisions = ( + await async_session.exec( + select(TranscriptRevision) + .where(TranscriptRevision.job_id == upload_result.job_id) + .order_by(TranscriptRevision.version_number) + ) + ).all() assert processed is True assert job is not None @@ -41,30 +54,43 @@ class TestPipelineSuccessFlow: assert transcript is not None assert transcript.text == "Pipeline transcript" assert transcript.error_detail is None + assert transcript.model == "test-model" + assert len(revisions) == 1 + assert revisions[0].version_number == 1 + assert revisions[0].source == "ai" + assert revisions[0].text == "Pipeline transcript" @pytest.mark.integration class TestPipelineFailureFlow: """Verify end-to-end failure lifecycle behavior.""" - def test_upload_then_worker_persists_failed_terminal_state(self, session, tmp_path: Path, monkeypatch): + @pytest.mark.asyncio + async def test_upload_then_worker_persists_failed_terminal_state(self, async_session, tmp_path: Path, monkeypatch): """Upload followed by worker processing persists error detail and failed status.""" settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path) - upload_result = create_upload_job( + upload_result = await create_upload_job( filename="pipeline.jpg", file_bytes=b"pipeline-bytes", - session=session, + session=async_session, settings=settings, ) - def _fake_transcribe(_path: str) -> TranscriptionResult: + async def _fake_transcribe(_path: str) -> TranscriptionResult: raise RuntimeError("pipeline provider failure") - monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe) + monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _fake_transcribe) - processed = process_next_queued_job(session=session) - job = session.get(Job, upload_result.job_id) - transcript = session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id)).first() + processed = await process_next_queued_job(session=async_session) + job = await async_session.get(Job, upload_result.job_id) + transcript = (await async_session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id))).first() + revisions = ( + await async_session.exec( + select(TranscriptRevision) + .where(TranscriptRevision.job_id == upload_result.job_id) + .order_by(TranscriptRevision.version_number) + ) + ).all() assert processed is True assert job is not None @@ -74,3 +100,8 @@ class TestPipelineFailureFlow: assert "pipeline provider failure" in transcript.error_detail assert "[internal_unexpected_error]" in transcript.error_detail assert "error_id=" in transcript.error_detail + assert len(revisions) == 1 + assert revisions[0].version_number == 1 + assert revisions[0].source == "ai" + assert revisions[0].text is None + assert "pipeline provider failure" in (revisions[0].error_detail or "") diff --git a/tests/test_db.py b/tests/test_db.py index d6d615f..72d6b60 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,96 +1,71 @@ -"""Tests for transcription.db — schema bootstrap and session factory.""" +"""Tests for transcription.db — async schema bootstrap/runtime behavior.""" -from sqlalchemy import inspect, text -from sqlmodel import Session, SQLModel, create_engine -from sqlmodel.pool import StaticPool - - -def _in_memory_engine(): - """Create a fresh in-memory SQLite engine for isolated db tests.""" - return create_engine( - "sqlite://", - connect_args={"check_same_thread": False}, - poolclass=StaticPool, - ) +from sqlalchemy import inspect +from sqlalchemy import text +import pytest class TestSchemaBootstrap: - """Verify create_all produces the expected table set.""" + """Verify async create_all produces the expected table set.""" - def test_create_all_creates_expected_tables(self): - """After create_all(), document, job, and transcript tables exist.""" - engine = _in_memory_engine() - # Ensure models are imported so metadata is populated - from transcription.models import Document, Job, Transcript # noqa: F401 + @pytest.mark.asyncio + async def test_create_all_creates_expected_tables(self, default_settings): + """After async create_all(), document/job/transcript/revision tables exist.""" + # Ensure models are imported so metadata is populated. + from transcription.models import Document, Job, Transcript, TranscriptRevision # noqa: F401 - import transcription.db as db_module + from transcription.db.operations import create_all + from transcription.db.runtime import get_engine - db_module.create_all(engine=engine) + engine = get_engine(settings=default_settings) + await create_all(engine=engine) + + async with engine.begin() as connection: + table_names = set(await connection.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names())) - inspector = inspect(engine) - table_names = set(inspector.get_table_names()) assert "document" in table_names assert "job" in table_names assert "transcript" in table_names + assert "transcriptrevision" in table_names class TestSessionFactory: - """Verify get_session yields and cleans up sessions.""" + """Verify async get_session yields a usable AsyncSession.""" - def test_get_session_yields_session(self): - """get_session() yields a usable Session object.""" - engine = _in_memory_engine() - SQLModel.metadata.create_all(engine) + @pytest.mark.asyncio + async def test_get_session_yields_session(self, default_settings): + """get_session() yields an AsyncSession with a live connection.""" + from transcription.db.runtime import get_session - import transcription.db as db_module - - with db_module.get_session(engine=engine) as session: - assert isinstance(session, Session) - - def test_session_is_closed_after_generator_exit(self): - """After the context manager exits, the session is closed.""" - engine = _in_memory_engine() - SQLModel.metadata.create_all(engine) - - import transcription.db as db_module - - with db_module.get_session(engine=engine) as session: - # Session is usable inside the context - session.execute(text("SELECT 1")) - captured = session - - # After exiting, the session's internal connection is released - # (no active transaction bound to the session) - assert captured._transaction is None + async with get_session(settings=default_settings) as session: + result = await session.exec(text("SELECT 1")) + assert result.first()[0] == 1 class TestBootstrapPolicy: - """Verify schema bootstrap policy defaults and overrides.""" + """Verify startup schema bootstrap policy via Settings property.""" def test_production_defaults_to_no_bootstrap(self): """Production defaults to explicit non-bootstrap startup behavior.""" from transcription.config import Settings - from transcription.db import should_bootstrap_schema settings = Settings(openrouter_api_key="test-key", environment="production") - assert should_bootstrap_schema(settings) is False + assert settings.should_bootstrap_schema is False def test_development_defaults_to_bootstrap(self): """Development defaults to schema bootstrap for local workflows.""" from transcription.config import Settings - from transcription.db import should_bootstrap_schema settings = Settings(openrouter_api_key="test-key", environment="development") - assert should_bootstrap_schema(settings) is True + assert settings.should_bootstrap_schema is True def test_explicit_override_wins(self): """Explicit bootstrap_schema_on_startup overrides environment default.""" from transcription.config import Settings - from transcription.db import should_bootstrap_schema settings = Settings( openrouter_api_key="test-key", environment="production", bootstrap_schema_on_startup=True, ) - assert should_bootstrap_schema(settings) is True + assert settings.should_bootstrap_schema is True diff --git a/tests/test_models.py b/tests/test_models.py index d2b710b..930b539 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,11 +1,11 @@ -"""Tests for transcription.models — Document, Job, Transcript persistence and relationships.""" +"""Tests for transcription.models — Document, Job, Transcript, TranscriptRevision models.""" from uuid import UUID import pytest from sqlalchemy.exc import IntegrityError -from transcription.models import Document, Job, JobStatus, Transcript +from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision def _make_document(**overrides) -> Document: @@ -113,7 +113,7 @@ class TestTranscriptModel: """A Transcript with text set and error_detail None persists correctly.""" doc = _persist_document(session) job = _persist_job(session, doc) - transcript = Transcript(job_id=job.id, text="Dear Sir, ...") + transcript = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="Dear Sir, ...") session.add(transcript) session.commit() session.refresh(transcript) @@ -127,7 +127,12 @@ class TestTranscriptModel: """A Transcript with text None and error_detail set persists correctly.""" doc = _persist_document(session) job = _persist_job(session, doc) - transcript = Transcript(job_id=job.id, error_detail="Provider timeout") + transcript = Transcript( + job_id=job.id, + provider="openrouter", + prompt_name="transcribe_document.md", + error_detail="Provider timeout", + ) session.add(transcript) session.commit() session.refresh(transcript) @@ -142,16 +147,101 @@ class TestTranscriptModel: doc = _persist_document(session) job = _persist_job(session, doc) - t1 = Transcript(job_id=job.id, text="First") + t1 = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="First") session.add(t1) session.commit() - t2 = Transcript(job_id=job.id, text="Duplicate") + t2 = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="Duplicate") session.add(t2) with pytest.raises(IntegrityError): session.commit() +class TestTranscriptRevisionModel: + """Verify TranscriptRevision persistence and version uniqueness constraints.""" + + def test_revision_record_persists(self, session): + """A TranscriptRevision with version metadata persists correctly.""" + doc = _persist_document(session) + job = _persist_job(session, doc) + revision = TranscriptRevision( + job_id=job.id, + version_number=1, + provider="openrouter", + prompt_name="transcribe_document.md", + model="google/gemini-2.5-flash", + source="ai", + text="Initial text", + ) + session.add(revision) + session.commit() + session.refresh(revision) + + fetched = session.get(TranscriptRevision, revision.id) + assert fetched is not None + assert fetched.version_number == 1 + assert fetched.text == "Initial text" + assert fetched.source == "ai" + + def test_job_version_pair_is_unique(self, session): + """Duplicate version_number for same job raises integrity error.""" + doc = _persist_document(session) + job = _persist_job(session, doc) + + first = TranscriptRevision( + job_id=job.id, + version_number=1, + provider="openrouter", + prompt_name="transcribe_document.md", + source="ai", + text="Initial", + ) + duplicate = TranscriptRevision( + job_id=job.id, + version_number=1, + provider="openrouter", + prompt_name="transcribe_document.md", + source="user", + text="Edited", + ) + session.add(first) + session.commit() + + session.add(duplicate) + with pytest.raises(IntegrityError): + session.commit() + + def test_same_version_number_allowed_for_different_jobs(self, session): + """Version numbers are scoped per job, not globally.""" + doc1 = _persist_document(session) + job1 = _persist_job(session, doc1) + doc2 = _make_document(filename="letter2.jpg", file_path="/uploads/letter2.jpg") + session.add(doc2) + session.commit() + session.refresh(doc2) + job2 = _persist_job(session, doc2) + + r1 = TranscriptRevision( + job_id=job1.id, + version_number=1, + provider="openrouter", + prompt_name="transcribe_document.md", + source="ai", + text="Job1 v1", + ) + r2 = TranscriptRevision( + job_id=job2.id, + version_number=1, + provider="openrouter", + prompt_name="transcribe_document.md", + source="ai", + text="Job2 v1", + ) + session.add(r1) + session.add(r2) + session.commit() + + class TestRelationships: """Verify SQLModel relationship navigation between models.""" @@ -169,7 +259,12 @@ class TestRelationships: """job.transcript returns the linked Transcript.""" doc = _persist_document(session) job = _persist_job(session, doc) - transcript = Transcript(job_id=job.id, text="Transcribed text") + transcript = Transcript( + job_id=job.id, + provider="openrouter", + prompt_name="transcribe_document.md", + text="Transcribed text", + ) session.add(transcript) session.commit() @@ -177,3 +272,32 @@ class TestRelationships: assert job.transcript is not None assert isinstance(job.transcript, Transcript) assert job.transcript.text == "Transcribed text" + + def test_job_exposes_transcript_revisions(self, session): + """job.transcript_revisions returns all linked revisions.""" + doc = _persist_document(session) + job = _persist_job(session, doc) + session.add( + TranscriptRevision( + job_id=job.id, + version_number=1, + provider="openrouter", + prompt_name="transcribe_document.md", + source="ai", + text="v1", + ) + ) + session.add( + TranscriptRevision( + job_id=job.id, + version_number=2, + provider="openrouter", + prompt_name="transcribe_document.md", + source="user", + text="v2", + ) + ) + session.commit() + + session.refresh(job) + assert len(job.transcript_revisions) == 2 diff --git a/uploads/01c483af-e590-4e12-b2e7-0183ef11e4fe_Lowry Record - page 16.jpg b/uploads/01c483af-e590-4e12-b2e7-0183ef11e4fe_Lowry Record - page 16.jpg new file mode 100644 index 0000000..7549ec4 Binary files /dev/null and b/uploads/01c483af-e590-4e12-b2e7-0183ef11e4fe_Lowry Record - page 16.jpg differ diff --git a/uploads/25148a77-bfce-40a6-a5f2-148008ede6fd_Rod Moser Letter - p2.jpg b/uploads/25148a77-bfce-40a6-a5f2-148008ede6fd_Rod Moser Letter - p2.jpg new file mode 100644 index 0000000..0234422 Binary files /dev/null and b/uploads/25148a77-bfce-40a6-a5f2-148008ede6fd_Rod Moser Letter - p2.jpg differ diff --git a/uploads/3b58a01a-8d97-4166-8a47-a99b75a34100_Rod Moser Letter - p3.jpg b/uploads/3b58a01a-8d97-4166-8a47-a99b75a34100_Rod Moser Letter - p3.jpg new file mode 100644 index 0000000..4b8ff05 Binary files /dev/null and b/uploads/3b58a01a-8d97-4166-8a47-a99b75a34100_Rod Moser Letter - p3.jpg differ diff --git a/uploads/6365d9a7-402c-4d4c-a360-8824e9e6a936_Biography of Omie Rachel Cochran McCarthy_ 1892-1961.pdf b/uploads/6365d9a7-402c-4d4c-a360-8824e9e6a936_Biography of Omie Rachel Cochran McCarthy_ 1892-1961.pdf new file mode 100644 index 0000000..0ac4810 Binary files /dev/null and b/uploads/6365d9a7-402c-4d4c-a360-8824e9e6a936_Biography of Omie Rachel Cochran McCarthy_ 1892-1961.pdf differ diff --git a/uploads/8b799b25-7d4b-40eb-8cd4-69516343372f_Book Two - page 12.jpg b/uploads/8b799b25-7d4b-40eb-8cd4-69516343372f_Book Two - page 12.jpg new file mode 100644 index 0000000..8ee8be8 Binary files /dev/null and b/uploads/8b799b25-7d4b-40eb-8cd4-69516343372f_Book Two - page 12.jpg differ diff --git a/uploads/aa9c92f7-5ad2-4c7d-864e-53ef5390b85d_Rod Moser Letter - p1.jpg b/uploads/aa9c92f7-5ad2-4c7d-864e-53ef5390b85d_Rod Moser Letter - p1.jpg new file mode 100644 index 0000000..a1e9432 Binary files /dev/null and b/uploads/aa9c92f7-5ad2-4c7d-864e-53ef5390b85d_Rod Moser Letter - p1.jpg differ diff --git a/uploads/bef10e09-d48d-40e9-85c4-9ec53735e85b_Book Two - page 18.jpg b/uploads/bef10e09-d48d-40e9-85c4-9ec53735e85b_Book Two - page 18.jpg new file mode 100644 index 0000000..34fe7a0 Binary files /dev/null and b/uploads/bef10e09-d48d-40e9-85c4-9ec53735e85b_Book Two - page 18.jpg differ diff --git a/uploads/cc913a4f-77e3-4b77-aae3-0898f12bbc96_Hig's postcards to Zenna - November 24, 1924.png b/uploads/cc913a4f-77e3-4b77-aae3-0898f12bbc96_Hig's postcards to Zenna - November 24, 1924.png new file mode 100644 index 0000000..6eab614 Binary files /dev/null and b/uploads/cc913a4f-77e3-4b77-aae3-0898f12bbc96_Hig's postcards to Zenna - November 24, 1924.png differ