Update job detail page to track revisions and display document image next to transcription text.

This commit is contained in:
Jim Lancaster
2026-06-28 18:02:19 -05:00
parent e2e421835f
commit 761765636a
19 changed files with 744 additions and 111 deletions
+6 -2
View File
@@ -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
+6
View File
@@ -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")
+28 -2
View File
@@ -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")
+1
View File
@@ -34,6 +34,7 @@ class TranscriptionResult:
job_id=job_id,
provider=self.provider,
prompt_name=self.prompt_name,
model=self.model,
text=self.text,
)
+115
View File
@@ -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,
+78 -2
View File
@@ -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(
+227 -14
View File
@@ -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(
(
'<iframe title="Document preview" '
f'src="{source}" '
'style="width:100%;height:520px;border:1px solid #ddd;border-radius:8px;"></iframe>'
)
)
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,
)
+72 -10
View File
@@ -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")
+50 -19
View File
@@ -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 "")
+30 -55
View File
@@ -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
+131 -7
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB