added revisions to transcription table

This commit is contained in:
John Lancaster
2026-06-29 13:59:26 -05:00
parent 5719debbaa
commit 58b4c381a4
5 changed files with 125 additions and 96 deletions
+10 -6
View File
@@ -1,16 +1,16 @@
"""SQLModel domain models for the transcription system.
Three models capture the MVP lifecycle:
Document -> one-to-many -> Job -> one-to-one -> Transcript
Document -> one-to-many -> Job -> one-to-many -> Transcript
"""
from datetime import UTC
from datetime import datetime
from enum import StrEnum
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
@@ -47,7 +47,7 @@ class Job(SQLModel, table=True):
# --- relationships ---
document: Document = Relationship(back_populates="jobs")
transcript: Optional["Transcript"] = Relationship(back_populates="job")
transcripts: list["Transcript"] = Relationship(back_populates="job")
@property
def filename(self) -> str:
@@ -59,8 +59,10 @@ class Transcript(SQLModel, table=True):
"""The output of a transcription job."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
job_id: UUID = Field(foreign_key="job.id", unique=True)
"""ID for the associated job. There's a 1-1 relationship bewteen transcripts and jobs."""
job_id: UUID = Field(foreign_key="job.id")
"""ID for the associated job."""
revision: int = Field(default=0, ge=0)
"""Revision number for this job's transcript history, starting at 0."""
provider: str
"""Name of the transcription provider used to generate this transcript."""
prompt_name: str
@@ -71,5 +73,7 @@ class Transcript(SQLModel, table=True):
"""Details of any error that occurred during transcription."""
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
__table_args__ = (UniqueConstraint("job_id", "revision", name="uq_transcript_job_revision"),)
# --- relationships ---
job: Job = Relationship(back_populates="transcript")
job: Job = Relationship(back_populates="transcripts")
+57 -17
View File
@@ -4,10 +4,12 @@ from __future__ import annotations
import logging
import mimetypes
from collections.abc import Sequence
from contextlib import contextmanager
from pathlib import Path
from uuid import UUID
from sqlalchemy import func
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import selectinload
from sqlmodel import select
@@ -108,38 +110,76 @@ class TranscriptionService(ServiceBase):
settings=self.settings,
provider=self.provider,
)
await self.create_transcript(transcript=result.to_transcript(job_id=job_id), session=session)
await self.create_transcript_for_job(
job_id=job_id,
text=result.text,
provider=result.provider,
prompt_name=result.prompt_name,
session=session,
)
async def upsert_transcript_by_job(
async def create_transcript_for_job(
self,
*,
job_id: UUID,
text: str | None,
error_detail: str | None,
error_detail: str | None = None,
provider: str | None = None,
prompt_name: str = DEFAULT_PROMPT_FILE,
session: AsyncSession | None = None,
) -> Transcript:
"""Create or update a transcript for a job id."""
"""Create a new transcript revision for a job id."""
async with self._session_scope(session) as _session:
transcript = (await _session.exec(select(Transcript).where(Transcript.job_id == job_id))).first()
if transcript is None:
transcript = Transcript(
job_id=job_id,
provider=provider or self.settings.provider.value,
prompt_name=prompt_name,
)
transcript.text = text
transcript.error_detail = error_detail
if provider is not None:
transcript.provider = provider
transcript.prompt_name = prompt_name
rev_query = select(func.max(Transcript.revision)).where(Transcript.job_id == job_id)
rev_result = await _session.exec(rev_query)
max_revision = -1 if (rev := rev_result.one_or_none()) is None else rev
next_revision = max_revision + 1
transcript = Transcript(
job_id=job_id,
revision=next_revision,
provider=provider or self.settings.provider.value,
prompt_name=prompt_name,
text=text,
error_detail=error_detail,
)
_session.add(transcript)
await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
return transcript
async def read_latest_transcript_by_job(
self,
job_id: UUID,
*,
session: AsyncSession | None = None,
) -> Transcript | None:
"""Read the latest transcript revision for a job id."""
async with self._session_scope(session) as _session:
query = _transcript_job_query(job_id=job_id).limit(1)
result = await _session.exec(query)
return result.one_or_none()
async def list_transcripts_by_job(
self,
job_id: UUID,
*,
session: AsyncSession | None = None,
) -> Sequence[Transcript]:
"""List transcript revisions for a job id in ascending revision order."""
async with self._session_scope(session) as _session:
query = _transcript_job_query(job_id=job_id)
result = await _session.exec(query)
return result.all()
def _transcript_job_query(job_id: UUID):
return (
select(Transcript)
.where(Transcript.job_id == job_id)
.options(selectinload(Transcript.job)) # pyright: ignore[reportArgumentType]
.order_by(Transcript.revision) # pyright: ignore[reportArgumentType]
) # fmt: skip
async def transcribe_document_image(
image_path: str | Path,
+6 -6
View File
@@ -121,7 +121,7 @@ async def _finalize_transcribed(
"""Transaction B: transcript + TRANSCRIBED in one commit."""
if session is None:
async with services.jobs._session_scope() as local_session:
await services.transcriptions.upsert_transcript_by_job(
await services.transcriptions.create_transcript_for_job(
job_id=job.id,
text=result.text,
error_detail=None,
@@ -137,7 +137,7 @@ async def _finalize_transcribed(
await local_session.commit()
return updated_job
await services.transcriptions.upsert_transcript_by_job(
await services.transcriptions.create_transcript_for_job(
job_id=job.id,
text=result.text,
error_detail=None,
@@ -165,7 +165,7 @@ 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:
await services.transcriptions.upsert_transcript_by_job(
await services.transcriptions.create_transcript_for_job(
job_id=job.id,
text=None,
error_detail=format_error_detail(error),
@@ -180,7 +180,7 @@ async def _finalize_retry(
)
await local_session.commit()
else:
await services.transcriptions.upsert_transcript_by_job(
await services.transcriptions.create_transcript_for_job(
job_id=job.id,
text=None,
error_detail=format_error_detail(error),
@@ -210,7 +210,7 @@ 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:
await services.transcriptions.upsert_transcript_by_job(
await services.transcriptions.create_transcript_for_job(
job_id=job.id,
text=None,
error_detail=format_error_detail(error),
@@ -225,7 +225,7 @@ async def _finalize_failed(
await local_session.commit()
return updated_job
await services.transcriptions.upsert_transcript_by_job(
await services.transcriptions.create_transcript_for_job(
job_id=job.id,
text=None,
error_detail=format_error_detail(error),
+24 -20
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from collections.abc import Sequence
from nicegui import ui
from transcription.models import Document
@@ -40,34 +42,36 @@ def _render_document_section(document: Document) -> None:
render_document_panzoom(document=document)
def _render_transcript_section(transcript: Transcript | None) -> None:
def _render_transcript_section(transcripts: Sequence[Transcript]) -> None:
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
ui.label("Transcript").classes("text-subtitle1 text-weight-medium")
ui.label("Transcripts").classes("text-subtitle1 text-weight-medium")
ui.separator().classes("q-my-sm bg-blue-grey-7")
if transcript is None:
ui.label("Transcript not available yet.").classes("text-body2 text-grey-3")
if not transcripts:
ui.label("Transcript history is not available yet.").classes("text-body2 text-grey-3")
return
with ui.column().classes("w-full q-gutter-y-xs"):
_metadata_row("Provider", transcript.provider)
_metadata_row("Prompt", transcript.prompt_name)
_metadata_row("Created", transcript.created_at.isoformat())
for transcript in transcripts:
with ui.column().classes("w-full q-gutter-y-xs"):
_metadata_row("Revision", str(transcript.revision))
_metadata_row("Provider", transcript.provider)
_metadata_row("Prompt", transcript.prompt_name)
_metadata_row("Created", transcript.created_at.isoformat())
if transcript.text:
ui.separator().classes("q-my-sm bg-blue-grey-7")
with ui.card().classes("w-full bg-blue-grey-9 text-grey-1 q-pa-sm"):
ui.markdown(transcript.text).classes("text-grey-1")
return
if transcript.text:
ui.separator().classes("q-my-sm bg-blue-grey-7")
with ui.card().classes("w-full bg-blue-grey-9 text-grey-1 q-pa-sm"):
ui.markdown(transcript.text).classes("text-grey-1")
elif transcript.error_detail:
ui.separator().classes("q-my-sm bg-blue-grey-7")
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")
if transcript.error_detail:
ui.separator().classes("q-my-sm bg-blue-grey-7")
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-md bg-blue-grey-7")
def render_job_detail(*, job: Job, document: Document | None, transcript: Transcript | None) -> None:
def render_job_detail(*, job: Job, document: Document | None, transcripts: Sequence[Transcript]) -> 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"):
@@ -92,4 +96,4 @@ 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_section(transcripts)
+28 -47
View File
@@ -4,69 +4,45 @@ from __future__ import annotations
from uuid import UUID
from fastapi import Request
from nicegui import ui
from sqlalchemy.orm import selectinload
from sqlmodel import desc
from sqlmodel import select
from transcription.db import get_session
from transcription.models import Document
from transcription.models import Job
from transcription.models import Transcript
from transcription.app_state import resolve_session_factory
from transcription.services.jobs import JobService
from transcription.services.transcription import TranscriptionService
from transcription.ui.components.app_shell import render_navigation_header
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
from transcription.ui.components.table.jobs import JobTableRow
from transcription.ui.components.table.jobs import render_jobs_table
async def fetch_job_rows() -> list[JobTableRow]:
"""Return jobs for display in most-recent-first order."""
async with get_session() as session:
jobs = (
await session.exec(
select(Job)
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
.order_by(desc(Job.created_at))
)
).all()
return [
JobTableRow(
id=job.id,
status=job.status.value,
filename=job.filename,
retry_count=job.retry_count,
created_at=job.created_at.isoformat(),
updated_at=job.updated_at.isoformat(),
)
for job in jobs
]
async def fetch_job_detail(job_id: UUID) -> tuple[Job | None, Document | None, Transcript | None]:
"""Return job, document, and transcript for detail view."""
async with get_session() as session:
job = await session.get(Job, job_id)
if job is 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
from ..components.table.jobs import JobTableRow
def register_page() -> None:
"""Register jobs list and detail routes."""
@ui.page("/jobs")
async def jobs_page() -> None:
async def jobs_page(request: Request) -> None:
session_factory = resolve_session_factory(request.app.state)
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
ui.label("Transcription Jobs")
status = ui.label("Ready")
@ui.refreshable
async def render_table() -> None:
jobs = await fetch_job_rows()
jobs = [
JobTableRow(
id=job.id,
status=job.status.value,
filename=job.filename,
retry_count=job.retry_count,
created_at=job.created_at.isoformat(),
updated_at=job.updated_at.isoformat(),
)
for job in await jobs_service.list_jobs()
]
render_jobs_table(jobs)
async def refresh() -> None:
@@ -83,7 +59,10 @@ def register_page() -> None:
ui.link("Back to upload", "/upload")
@ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str) -> None:
async def job_detail_page(job_id: str, request: Request) -> None:
session_factory = resolve_session_factory(request.app.state)
jobs_service = JobService(session_factory=session_factory)
transcription_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
ui.label("Job Detail")
try:
@@ -93,12 +72,14 @@ def register_page() -> None:
ui.link("Back to jobs", "/jobs")
return
job, document, transcript = await fetch_job_detail(parsed_id)
if job is None:
try:
job = await jobs_service.read_job(job_id=parsed_id)
except ValueError:
ui.label("Job not found")
ui.link("Back to jobs", "/jobs")
return
render_job_detail(job=job, document=document, transcript=transcript)
transcripts = list(await transcription_service.list_transcripts_by_job(parsed_id))
render_job_detail(job=job, document=job.document, transcripts=transcripts)
ui.link("Back to jobs", "/jobs")