generated from john/python-template
added revisions to transcription table
This commit is contained in:
@@ -1,16 +1,16 @@
|
|||||||
"""SQLModel domain models for the transcription system.
|
"""SQLModel domain models for the transcription system.
|
||||||
|
|
||||||
Three models capture the MVP lifecycle:
|
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 UTC
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Optional
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import UniqueConstraint
|
||||||
from sqlmodel import Field
|
from sqlmodel import Field
|
||||||
from sqlmodel import Relationship
|
from sqlmodel import Relationship
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
@@ -47,7 +47,7 @@ class Job(SQLModel, table=True):
|
|||||||
|
|
||||||
# --- relationships ---
|
# --- relationships ---
|
||||||
document: Document = Relationship(back_populates="jobs")
|
document: Document = Relationship(back_populates="jobs")
|
||||||
transcript: Optional["Transcript"] = Relationship(back_populates="job")
|
transcripts: list["Transcript"] = Relationship(back_populates="job")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self) -> str:
|
def filename(self) -> str:
|
||||||
@@ -59,8 +59,10 @@ class Transcript(SQLModel, table=True):
|
|||||||
"""The output of a transcription job."""
|
"""The output of a transcription job."""
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
job_id: UUID = Field(foreign_key="job.id", unique=True)
|
job_id: UUID = Field(foreign_key="job.id")
|
||||||
"""ID for the associated job. There's a 1-1 relationship bewteen transcripts and jobs."""
|
"""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
|
provider: str
|
||||||
"""Name of the transcription provider used to generate this transcript."""
|
"""Name of the transcription provider used to generate this transcript."""
|
||||||
prompt_name: str
|
prompt_name: str
|
||||||
@@ -71,5 +73,7 @@ class Transcript(SQLModel, table=True):
|
|||||||
"""Details of any error that occurred during transcription."""
|
"""Details of any error that occurred during transcription."""
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("job_id", "revision", name="uq_transcript_job_revision"),)
|
||||||
|
|
||||||
# --- relationships ---
|
# --- relationships ---
|
||||||
job: Job = Relationship(back_populates="transcript")
|
job: Job = Relationship(back_populates="transcripts")
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
from collections.abc import Sequence
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
@@ -108,38 +110,76 @@ class TranscriptionService(ServiceBase):
|
|||||||
settings=self.settings,
|
settings=self.settings,
|
||||||
provider=self.provider,
|
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,
|
self,
|
||||||
*,
|
*,
|
||||||
job_id: UUID,
|
job_id: UUID,
|
||||||
text: str | None,
|
text: str | None,
|
||||||
error_detail: str | None,
|
error_detail: str | None = None,
|
||||||
provider: str | None = None,
|
provider: str | None = None,
|
||||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
prompt_name: str = DEFAULT_PROMPT_FILE,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> Transcript:
|
) -> 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:
|
async with self._session_scope(session) as _session:
|
||||||
transcript = (await _session.exec(select(Transcript).where(Transcript.job_id == job_id))).first()
|
rev_query = select(func.max(Transcript.revision)).where(Transcript.job_id == job_id)
|
||||||
if transcript is None:
|
rev_result = await _session.exec(rev_query)
|
||||||
transcript = Transcript(
|
max_revision = -1 if (rev := rev_result.one_or_none()) is None else rev
|
||||||
job_id=job_id,
|
next_revision = max_revision + 1
|
||||||
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
|
|
||||||
|
|
||||||
|
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)
|
_session.add(transcript)
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
|
await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
|
||||||
return 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(
|
async def transcribe_document_image(
|
||||||
image_path: str | Path,
|
image_path: str | Path,
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ async def _finalize_transcribed(
|
|||||||
"""Transaction B: transcript + TRANSCRIBED in one commit."""
|
"""Transaction B: transcript + TRANSCRIBED in one commit."""
|
||||||
if session is None:
|
if session is None:
|
||||||
async with services.jobs._session_scope() as local_session:
|
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,
|
job_id=job.id,
|
||||||
text=result.text,
|
text=result.text,
|
||||||
error_detail=None,
|
error_detail=None,
|
||||||
@@ -137,7 +137,7 @@ async def _finalize_transcribed(
|
|||||||
await local_session.commit()
|
await local_session.commit()
|
||||||
return updated_job
|
return updated_job
|
||||||
|
|
||||||
await services.transcriptions.upsert_transcript_by_job(
|
await services.transcriptions.create_transcript_for_job(
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
text=result.text,
|
text=result.text,
|
||||||
error_detail=None,
|
error_detail=None,
|
||||||
@@ -165,7 +165,7 @@ async def _finalize_retry(
|
|||||||
"""Transaction C: transcript error + QUEUED + retry increment in one commit."""
|
"""Transaction C: transcript error + QUEUED + retry increment in one commit."""
|
||||||
if session is None:
|
if session is None:
|
||||||
async with services.jobs._session_scope() as local_session:
|
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,
|
job_id=job.id,
|
||||||
text=None,
|
text=None,
|
||||||
error_detail=format_error_detail(error),
|
error_detail=format_error_detail(error),
|
||||||
@@ -180,7 +180,7 @@ async def _finalize_retry(
|
|||||||
)
|
)
|
||||||
await local_session.commit()
|
await local_session.commit()
|
||||||
else:
|
else:
|
||||||
await services.transcriptions.upsert_transcript_by_job(
|
await services.transcriptions.create_transcript_for_job(
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
text=None,
|
text=None,
|
||||||
error_detail=format_error_detail(error),
|
error_detail=format_error_detail(error),
|
||||||
@@ -210,7 +210,7 @@ async def _finalize_failed(
|
|||||||
"""Transaction B: transcript error + FAILED in one commit."""
|
"""Transaction B: transcript error + FAILED in one commit."""
|
||||||
if session is None:
|
if session is None:
|
||||||
async with services.jobs._session_scope() as local_session:
|
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,
|
job_id=job.id,
|
||||||
text=None,
|
text=None,
|
||||||
error_detail=format_error_detail(error),
|
error_detail=format_error_detail(error),
|
||||||
@@ -225,7 +225,7 @@ async def _finalize_failed(
|
|||||||
await local_session.commit()
|
await local_session.commit()
|
||||||
return updated_job
|
return updated_job
|
||||||
|
|
||||||
await services.transcriptions.upsert_transcript_by_job(
|
await services.transcriptions.create_transcript_for_job(
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
text=None,
|
text=None,
|
||||||
error_detail=format_error_detail(error),
|
error_detail=format_error_detail(error),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.models import Document
|
from transcription.models import Document
|
||||||
@@ -40,34 +42,36 @@ def _render_document_section(document: Document) -> None:
|
|||||||
render_document_panzoom(document=document)
|
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"):
|
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")
|
ui.separator().classes("q-my-sm bg-blue-grey-7")
|
||||||
|
|
||||||
if transcript is None:
|
if not transcripts:
|
||||||
ui.label("Transcript not available yet.").classes("text-body2 text-grey-3")
|
ui.label("Transcript history is not available yet.").classes("text-body2 text-grey-3")
|
||||||
return
|
return
|
||||||
|
|
||||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
for transcript in transcripts:
|
||||||
_metadata_row("Provider", transcript.provider)
|
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||||
_metadata_row("Prompt", transcript.prompt_name)
|
_metadata_row("Revision", str(transcript.revision))
|
||||||
_metadata_row("Created", transcript.created_at.isoformat())
|
_metadata_row("Provider", transcript.provider)
|
||||||
|
_metadata_row("Prompt", transcript.prompt_name)
|
||||||
|
_metadata_row("Created", transcript.created_at.isoformat())
|
||||||
|
|
||||||
if transcript.text:
|
if transcript.text:
|
||||||
ui.separator().classes("q-my-sm bg-blue-grey-7")
|
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"):
|
with ui.card().classes("w-full bg-blue-grey-9 text-grey-1 q-pa-sm"):
|
||||||
ui.markdown(transcript.text).classes("text-grey-1")
|
ui.markdown(transcript.text).classes("text-grey-1")
|
||||||
return
|
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-md bg-blue-grey-7")
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
"""Render all sections for the job detail page."""
|
||||||
status_text = job.status.value
|
status_text = job.status.value
|
||||||
with ui.column().classes("w-full max-w-4xl q-gutter-md"):
|
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:
|
if document is not None:
|
||||||
_render_document_section(document)
|
_render_document_section(document)
|
||||||
|
|
||||||
_render_transcript_section(transcript)
|
_render_transcript_section(transcripts)
|
||||||
|
|||||||
@@ -4,69 +4,45 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
from nicegui import ui
|
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.app_state import resolve_session_factory
|
||||||
from transcription.models import Document
|
from transcription.services.jobs import JobService
|
||||||
from transcription.models import Job
|
from transcription.services.transcription import TranscriptionService
|
||||||
from transcription.models import Transcript
|
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
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 show_error
|
||||||
from transcription.ui.components.error_presenter import summarize_error
|
from transcription.ui.components.error_presenter import summarize_error
|
||||||
from transcription.ui.components.job_detail import render_job_detail
|
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
|
from transcription.ui.components.table.jobs import render_jobs_table
|
||||||
|
|
||||||
|
from ..components.table.jobs import JobTableRow
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None:
|
def register_page() -> None:
|
||||||
"""Register jobs list and detail routes."""
|
"""Register jobs list and detail routes."""
|
||||||
|
|
||||||
@ui.page("/jobs")
|
@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")
|
render_navigation_header(current_path="/jobs")
|
||||||
ui.label("Transcription Jobs")
|
ui.label("Transcription Jobs")
|
||||||
status = ui.label("Ready")
|
status = ui.label("Ready")
|
||||||
|
|
||||||
@ui.refreshable
|
@ui.refreshable
|
||||||
async def render_table() -> None:
|
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)
|
render_jobs_table(jobs)
|
||||||
|
|
||||||
async def refresh() -> None:
|
async def refresh() -> None:
|
||||||
@@ -83,7 +59,10 @@ def register_page() -> None:
|
|||||||
ui.link("Back to upload", "/upload")
|
ui.link("Back to upload", "/upload")
|
||||||
|
|
||||||
@ui.page("/jobs/{job_id}")
|
@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")
|
render_navigation_header(current_path="/jobs")
|
||||||
ui.label("Job Detail")
|
ui.label("Job Detail")
|
||||||
try:
|
try:
|
||||||
@@ -93,12 +72,14 @@ def register_page() -> None:
|
|||||||
ui.link("Back to jobs", "/jobs")
|
ui.link("Back to jobs", "/jobs")
|
||||||
return
|
return
|
||||||
|
|
||||||
job, document, transcript = await fetch_job_detail(parsed_id)
|
try:
|
||||||
if job is None:
|
job = await jobs_service.read_job(job_id=parsed_id)
|
||||||
|
except ValueError:
|
||||||
ui.label("Job not found")
|
ui.label("Job not found")
|
||||||
ui.link("Back to jobs", "/jobs")
|
ui.link("Back to jobs", "/jobs")
|
||||||
return
|
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")
|
ui.link("Back to jobs", "/jobs")
|
||||||
|
|||||||
Reference in New Issue
Block a user