diff --git a/src/transcription/models.py b/src/transcription/models.py index 60eb089..fef824e 100644 --- a/src/transcription/models.py +++ b/src/transcription/models.py @@ -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") diff --git a/src/transcription/services/transcription.py b/src/transcription/services/transcription.py index 00af127..7baa74a 100644 --- a/src/transcription/services/transcription.py +++ b/src/transcription/services/transcription.py @@ -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, diff --git a/src/transcription/services/workflows.py b/src/transcription/services/workflows.py index 368222b..f6d1867 100644 --- a/src/transcription/services/workflows.py +++ b/src/transcription/services/workflows.py @@ -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), diff --git a/src/transcription/ui/components/job_detail.py b/src/transcription/ui/components/job_detail.py index 7abcc12..aab01ce 100644 --- a/src/transcription/ui/components/job_detail.py +++ b/src/transcription/ui/components/job_detail.py @@ -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) diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index ae346df..a88669c 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -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")