3 Commits
Author SHA1 Message Date
John Lancaster 3a141bd4cc page tweaks 2026-06-29 14:08:35 -05:00
John Lancaster 8129f5a9e8 ui instructions 2026-06-29 13:59:36 -05:00
John Lancaster 58b4c381a4 added revisions to transcription table 2026-06-29 13:59:26 -05:00
7 changed files with 138 additions and 112 deletions
+6
View File
@@ -0,0 +1,6 @@
---
description: Copilot rules for modifying the UI
applyTo: 'src/transcription/ui/**/*.py'
---
The UI is forbidden from directly touching the database. All interactions should be done using the [services](../../src/transcription/services/)
+10 -6
View File
@@ -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")
+53 -13
View File
@@ -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)
max_revision = -1 if (rev := rev_result.one_or_none()) is None else rev
next_revision = max_revision + 1
transcript = Transcript( transcript = Transcript(
job_id=job_id, job_id=job_id,
revision=next_revision,
provider=provider or self.settings.provider.value, provider=provider or self.settings.provider.value,
prompt_name=prompt_name, prompt_name=prompt_name,
text=text,
error_detail=error_detail,
) )
transcript.text = text
transcript.error_detail = error_detail
if provider is not None:
transcript.provider = provider
transcript.prompt_name = prompt_name
_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,
+6 -6
View File
@@ -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),
+13 -9
View File
@@ -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,16 +42,18 @@ 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
for transcript in transcripts:
with ui.column().classes("w-full q-gutter-y-xs"): with ui.column().classes("w-full q-gutter-y-xs"):
_metadata_row("Revision", str(transcript.revision))
_metadata_row("Provider", transcript.provider) _metadata_row("Provider", transcript.provider)
_metadata_row("Prompt", transcript.prompt_name) _metadata_row("Prompt", transcript.prompt_name)
_metadata_row("Created", transcript.created_at.isoformat()) _metadata_row("Created", transcript.created_at.isoformat())
@@ -58,16 +62,16 @@ def _render_transcript_section(transcript: Transcript | None) -> None:
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:
if transcript.error_detail:
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-red-1 text-red-10 q-pa-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("Failure detail").classes("text-caption text-uppercase")
ui.label(transcript.error_detail).classes("text-body2") 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.""" """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)
@@ -1,11 +1,14 @@
"""Common logic for generating table widgets.""" """Common logic for generating table widgets."""
import logging
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from nicegui import events from nicegui import events
from nicegui import ui from nicegui import ui
logger = logging.getLogger(__name__)
def _extract_row_id(args: Any) -> str | None: def _extract_row_id(args: Any) -> str | None:
if isinstance(args, dict): if isinstance(args, dict):
@@ -37,6 +40,7 @@ def _bind_row_click_handler(
on_row_click_id(row_id) on_row_click_id(row_id)
table.on("rowClick", handle_row_click) table.on("rowClick", handle_row_click)
logger.debug("Row click handler bound to table")
def build_table( def build_table(
@@ -63,6 +67,7 @@ def build_table(
.classes(classes) .classes(classes)
.props('table-style="table-layout: fixed; width: 100%;"') .props('table-style="table-layout: fixed; width: 100%;"')
) )
logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
if on_row_click_id is not None: if on_row_click_id is not None:
_bind_row_click_handler(table, on_row_click_id=on_row_click_id) _bind_row_click_handler(table, on_row_click_id=on_row_click_id)
return table return table
+30 -63
View File
@@ -4,34 +4,31 @@ 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 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.""" def register_page() -> None:
async with get_session() as session: """Register jobs list and detail routes."""
jobs = (
await session.exec( @ui.page("/jobs")
select(Job) async def jobs_page(request: Request) -> None:
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType] session_factory = resolve_session_factory(request.app.state)
.order_by(desc(Job.created_at)) jobs_service = JobService(session_factory=session_factory)
) render_navigation_header(current_path="/jobs")
).all()
return [ @ui.refreshable
async def render_table() -> None:
jobs = [
JobTableRow( JobTableRow(
id=job.id, id=job.id,
status=job.status.value, status=job.status.value,
@@ -40,52 +37,20 @@ async def fetch_job_rows() -> list[JobTableRow]:
created_at=job.created_at.isoformat(), created_at=job.created_at.isoformat(),
updated_at=job.updated_at.isoformat(), updated_at=job.updated_at.isoformat(),
) )
for job in jobs for job in await jobs_service.list_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:
"""Register jobs list and detail routes."""
@ui.page("/jobs")
async def jobs_page() -> None:
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()
render_jobs_table(jobs) render_jobs_table(jobs)
async def refresh() -> None: ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
status.text = "Refreshing..."
try:
await render_table.refresh()
status.text = "Refreshed"
except Exception as exc: # noqa: BLE001
status.text = f"Refresh failed: {summarize_error(exc, operation='jobs.refresh')}"
show_error(exc, title="Jobs refresh failed", operation="jobs.refresh")
ui.button("Refresh", on_click=refresh)
await render_table() await render_table()
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.button(icon="arrow_back", on_click=ui.navigate.back)
try: try:
parsed_id = UUID(job_id) parsed_id = UUID(job_id)
except ValueError: except ValueError:
@@ -93,12 +58,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")