V2 step 2 complete

This commit is contained in:
Jim Lancaster
2026-08-01 16:17:38 -05:00
parent c46d1bd0bc
commit 61cc8a200b
14 changed files with 227 additions and 160 deletions
+30 -21
View File
@@ -13,6 +13,7 @@ from sqlalchemy import Column
from sqlalchemy import JSON from sqlalchemy import JSON
from sqlalchemy import UniqueConstraint from sqlalchemy import UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm.exc import DetachedInstanceError
from sqlalchemy.types import TypeDecorator from sqlalchemy.types import TypeDecorator
from sqlmodel import Field from sqlmodel import Field
from sqlmodel import Relationship from sqlmodel import Relationship
@@ -33,6 +34,7 @@ class JSONBCompat(TypeDecorator):
class JobStatus(StrEnum): class JobStatus(StrEnum):
QUEUED = "queued" QUEUED = "queued"
PROCESSING = "processing" PROCESSING = "processing"
TRANSCRIBED = "transcribed"
COMPLETED = "completed" COMPLETED = "completed"
PARTIAL_SUCCESS = "partial_success" PARTIAL_SUCCESS = "partial_success"
FAILED = "failed" FAILED = "failed"
@@ -63,9 +65,9 @@ class Document(SQLModel, table=True):
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
jobs: list["Job"] = Relationship(back_populates="document") jobs: list["Job"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
sources: list["Source"] = Relationship(back_populates="document") sources: list["Source"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
document_people: list["DocumentPerson"] = Relationship(back_populates="document") document_people: list["DocumentPerson"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
class Person(SQLModel, table=True): class Person(SQLModel, table=True):
@@ -90,7 +92,7 @@ class Person(SQLModel, table=True):
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
document_people: list["DocumentPerson"] = Relationship(back_populates="person") document_people: list["DocumentPerson"] = Relationship(back_populates="person", sa_relationship_kwargs={"lazy": "selectin"})
class DocumentPerson(SQLModel, table=True): class DocumentPerson(SQLModel, table=True):
@@ -108,8 +110,8 @@ class DocumentPerson(SQLModel, table=True):
UniqueConstraint("document_id", "person_id", "role", name="uq_document_person_role"), UniqueConstraint("document_id", "person_id", "role", name="uq_document_person_role"),
) )
document: Optional["Document"] = Relationship(back_populates="document_people") document: Optional["Document"] = Relationship(back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"})
person: Optional["Person"] = Relationship(back_populates="document_people") person: Optional["Person"] = Relationship(back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"})
class Job(SQLModel, table=True): class Job(SQLModel, table=True):
@@ -125,15 +127,29 @@ class Job(SQLModel, table=True):
model: str | None = None model: str | None = None
prompt_name: str | None = None prompt_name: str | None = None
document: Optional["Document"] = Relationship(back_populates="jobs") document: Optional["Document"] = Relationship(back_populates="jobs", sa_relationship_kwargs={"lazy": "selectin"})
job_sources: list["JobSource"] = Relationship(back_populates="job") job_sources: list["JobSource"] = Relationship(back_populates="job", sa_relationship_kwargs={"lazy": "selectin"})
@property @property
def filename(self) -> str: def filename(self) -> str:
"""Return the filename of the associated source, when available.""" """Return the filename of the associated source, when available."""
if not self.job_sources: if not self.job_sources:
return "unknown" return "unknown"
return self.job_sources[0].source.filename if self.job_sources[0].source is not None else "unknown"
for job_source in self.job_sources:
source = job_source.__dict__.get("source")
if source is None:
try:
source = job_source.source
except DetachedInstanceError:
source = None
except Exception: # noqa: BLE001
source = None
if source is not None:
return source.filename
return "unknown"
class Source(SQLModel, table=True): class Source(SQLModel, table=True):
@@ -141,7 +157,7 @@ class Source(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
document_id: UUID = Field(foreign_key="document.id") document_id: UUID = Field(foreign_key="document.id")
page_number: int = Field(ge=1) page_number: int = Field(default=1, ge=1)
upload_name: str upload_name: str
filename: str filename: str
file_path: str file_path: str
@@ -150,8 +166,8 @@ class Source(SQLModel, table=True):
date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC)) date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC))
date_revised: datetime | None = None date_revised: datetime | None = None
document: Optional["Document"] = Relationship(back_populates="sources") document: Optional["Document"] = Relationship(back_populates="sources", sa_relationship_kwargs={"lazy": "selectin"})
job_sources: list["JobSource"] = Relationship(back_populates="source") job_sources: list["JobSource"] = Relationship(back_populates="source", sa_relationship_kwargs={"lazy": "selectin"})
class JobSource(SQLModel, table=True): class JobSource(SQLModel, table=True):
@@ -169,14 +185,7 @@ class JobSource(SQLModel, table=True):
error_detail: str | None = None error_detail: str | None = None
executed_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) executed_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
job: Optional["Job"] = Relationship(back_populates="job_sources") job: Optional["Job"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "selectin"})
source: Optional["Source"] = Relationship(back_populates="job_sources") source: Optional["Source"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "selectin"})
class Revision(SQLModel):
"""Temporary compatibility shim for older service imports during the V2 migration."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
source_id: UUID = Field(default_factory=uuid4)
text: str | None = None
date_created: datetime = Field(default_factory=lambda: datetime.now(UTC))
+2 -1
View File
@@ -15,12 +15,13 @@ logger = logging.getLogger(__name__)
async def create_all(*, engine: AsyncEngine | None = None) -> None: async def create_all(*, engine: AsyncEngine | None = None) -> None:
"""Create all tables on the selected engine.""" """Create all tables on the selected engine from scratch."""
# Import models so SQLModel metadata is fully registered before bootstrap. # Import models so SQLModel metadata is fully registered before bootstrap.
from transcription.db import models as _models # noqa: F401 from transcription.db import models as _models # noqa: F401
active_engine = engine or resolve_engine() active_engine = engine or resolve_engine()
async with active_engine.begin() as connection: async with active_engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.drop_all)
await connection.run_sync(SQLModel.metadata.create_all) await connection.run_sync(SQLModel.metadata.create_all)
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url) logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
+6 -5
View File
@@ -8,6 +8,7 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ..db.models import Job from ..db.models import Job
from ..db.models import JobSource
from ..db.models import JobStatus from ..db.models import JobStatus
from ..db.models import Source from ..db.models import Source
from .base import ServiceBase from .base import ServiceBase
@@ -38,7 +39,7 @@ class JobService(ServiceBase):
select(Job) select(Job)
.options( .options(
selectinload(Job.document), # pyright: ignore[reportArgumentType] selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.sources).selectinload(Source.revision), # pyright: ignore[reportArgumentType] selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
) )
.where(Job.id == job_id) .where(Job.id == job_id)
.execution_options(populate_existing=True) .execution_options(populate_existing=True)
@@ -74,12 +75,12 @@ class JobService(ServiceBase):
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
query = select(Job).options( query = select(Job).options(
selectinload(Job.document), # pyright: ignore[reportArgumentType] selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.sources), # pyright: ignore[reportArgumentType] selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
) )
if status is not None: if status is not None:
query = query.where(Job.status == status) query = query.where(Job.status == status)
if filename is not None: if filename is not None:
query = query.where(Job.sources.any(Source.filename == filename)) query = query.where(Job.job_sources.any(JobSource.source.has(Source.filename == filename)))
result = await _session.exec(query) result = await _session.exec(query)
return result.all() return result.all()
@@ -94,7 +95,7 @@ class JobService(ServiceBase):
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
query = select(Job).options( query = select(Job).options(
selectinload(Job.document), # pyright: ignore[reportArgumentType] selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.sources), # pyright: ignore[reportArgumentType] selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
) )
result = await _session.exec(query) result = await _session.exec(query)
return result.all() return result.all()
@@ -151,7 +152,7 @@ class JobService(ServiceBase):
select(Job) select(Job)
.options( .options(
selectinload(Job.document), # pyright: ignore[reportArgumentType] selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.sources).selectinload(Source.revision), # pyright: ignore[reportArgumentType] selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
) )
.where(Job.status == JobStatus.QUEUED) .where(Job.status == JobStatus.QUEUED)
.order_by(Job.date_created) # pyright: ignore[reportArgumentType] .order_by(Job.date_created) # pyright: ignore[reportArgumentType]
+12 -1
View File
@@ -13,6 +13,8 @@ from transcription.errors import ErrorCategory
from ..db.models import Document from ..db.models import Document
from ..db.models import Job from ..db.models import Job
from ..db.models import JobSource
from ..db.models import JobSourceStatus
from ..db.models import Source from ..db.models import Source
from .documents import UploadJobResult from .documents import UploadJobResult
@@ -81,12 +83,21 @@ async def _create_upload_records(
source = Source( source = Source(
document_id=document.id, document_id=document.id,
job_id=job.id, page_number=1,
upload_name=Path(original_filename).name, upload_name=Path(original_filename).name,
filename=stored_path.name, filename=stored_path.name,
file_path=str(stored_path), file_path=str(stored_path),
) )
session.add(source) session.add(source)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit() await session.commit()
await session.refresh(document) await session.refresh(document)
+71 -61
View File
@@ -19,7 +19,8 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings from transcription.config import Settings
from transcription.config import get_settings from transcription.config import get_settings
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import Revision from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import Source from transcription.db.models import Source
from transcription.errors import AppError from transcription.errors import AppError
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
@@ -51,7 +52,7 @@ class TranscriptionNotFoundError(TranscriptionError):
class TranscriptionService(ServiceBase): class TranscriptionService(ServiceBase):
"""Service class for job transcription output and optional source revisions.""" """Service class for job transcription output and page-level source revisions."""
provider: TranscriptionProvider provider: TranscriptionProvider
@@ -59,52 +60,47 @@ class TranscriptionService(ServiceBase):
super().__init__(session_factory=session_factory) super().__init__(session_factory=session_factory)
self.provider = get_transcription_provider(settings=self.settings) self.provider = get_transcription_provider(settings=self.settings)
async def create_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> Revision: async def create_job_source(
"""Create a new revision in the database.""" self,
job_source: JobSource,
*,
session: AsyncSession | None = None,
) -> JobSource:
"""Create a new job_source execution record in the database."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
_session.add(revision) _session.add(job_source)
await self._finalize(session=_session, caller_session=session, refresh=(revision,)) await self._finalize(session=_session, caller_session=session, refresh=(job_source,))
return revision return job_source
async def read_revision(self, revision_id: UUID, *, session: AsyncSession | None = None) -> Revision: async def read_job_source(self, job_source_id: UUID, *, session: AsyncSession | None = None) -> JobSource:
"""Read an existing revision from the database.""" """Read an existing job_source record."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
revision = await _session.get( job_source = await _session.get(
Revision, JobSource,
revision_id, job_source_id,
options=(selectinload(Revision.source),), # pyright: ignore[reportArgumentType] options=(selectinload(JobSource.source),), # pyright: ignore[reportArgumentType]
) )
if revision is None: if job_source is None:
raise TranscriptionNotFoundError( raise TranscriptionNotFoundError(
f"Revision with id {revision_id} not found", f"JobSource with id {job_source_id} not found",
category=ErrorCategory.NOT_FOUND, category=ErrorCategory.NOT_FOUND,
suggestion="Verify the revision id and retry.", suggestion="Verify the job source id and retry.",
) )
return revision return job_source
async def update_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> Revision: async def update_job_source(self, job_source: JobSource, *, session: AsyncSession | None = None) -> JobSource:
"""Update an existing revision in the database.""" """Update an existing job_source record."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
merged = await _session.merge(revision) merged = await _session.merge(job_source)
await self._finalize(session=_session, caller_session=session, refresh=(merged,)) await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged return merged
async def delete_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> None: async def delete_job_source(self, job_source: JobSource, *, session: AsyncSession | None = None) -> None:
"""Delete a revision from the database.""" """Delete a job_source record."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
await _session.delete(revision) await _session.delete(job_source)
await self._finalize(session=_session, caller_session=session) await self._finalize(session=_session, caller_session=session)
# Temporary compatibility methods for callers still using transcript naming.
async def read_transcript(self, transcript_id: UUID, *, session: AsyncSession | None = None) -> Revision:
"""Backward-compatible alias for read_revision."""
return await self.read_revision(transcript_id, session=session)
async def delete_transcript(self, transcript: Revision, *, session: AsyncSession | None = None) -> None:
"""Backward-compatible alias for delete_revision."""
await self.delete_revision(transcript, session=session)
async def transcribe_document( async def transcribe_document(
self, self,
image_path: str | Path, image_path: str | Path,
@@ -151,13 +147,37 @@ class TranscriptionService(ServiceBase):
suggestion="Verify the job id and retry.", suggestion="Verify the job id and retry.",
) )
job.text = text
job.error_detail = error_detail
job.provider = provider or job.provider or self.settings.provider.value job.provider = provider or job.provider or self.settings.provider.value
job.model = model or job.model or _resolve_transcript_model(provider=self.provider, settings=self.settings) job.model = model or job.model or _resolve_transcript_model(provider=self.provider, settings=self.settings)
job.prompt_name = prompt_name or job.prompt_name or DEFAULT_PROMPT_FILE job.prompt_name = prompt_name or job.prompt_name or DEFAULT_PROMPT_FILE
job.date_updated = datetime.now(UTC) job.date_updated = datetime.now(UTC)
source = await _session.exec(
select(Source)
.where(Source.document_id == job.document_id)
.order_by(Source.page_number) # pyright: ignore[reportArgumentType]
)
source_row = source.first()
if source_row is not None:
existing_job_source = await _session.exec(
select(JobSource).where(JobSource.job_id == job.id).where(JobSource.source_id == source_row.id)
)
job_source = existing_job_source.first()
if job_source is None:
job_source = JobSource(
job_id=job.id,
source_id=source_row.id,
status=JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED,
raw_transcription=text,
error_detail=error_detail,
)
_session.add(job_source)
else:
job_source.raw_transcription = text
job_source.error_detail = error_detail
job_source.status = JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED
job_source.executed_at = datetime.now(UTC)
await self._finalize(session=_session, caller_session=session, refresh=(job,)) await self._finalize(session=_session, caller_session=session, refresh=(job,))
return job return job
@@ -167,8 +187,8 @@ class TranscriptionService(ServiceBase):
source_id: UUID, source_id: UUID,
text: str, text: str,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> Revision: ) -> Source:
"""Create or replace the single optional revision for a source.""" """Persist a human revision on a source page."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
source = await _session.get(Source, source_id) source = await _session.get(Source, source_id)
if source is None: if source is None:
@@ -178,45 +198,35 @@ class TranscriptionService(ServiceBase):
suggestion="Verify the source id and retry.", suggestion="Verify the source id and retry.",
) )
query = select(Revision).where(Revision.source_id == source_id) source.revised_text = text
existing = (await _session.exec(query)).one_or_none() source.date_revised = datetime.now(UTC)
await self._finalize(session=_session, caller_session=session, refresh=(source,))
if existing is None: return source
revision = Revision(source_id=source_id, text=text)
_session.add(revision)
await self._finalize(session=_session, caller_session=session, refresh=(revision,))
return revision
existing.text = text
merged = await _session.merge(existing)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
async def read_revision_by_source( async def read_revision_by_source(
self, self,
source_id: UUID, source_id: UUID,
*, *,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> Revision | None: ) -> Source | None:
"""Read the single optional revision for a source.""" """Read the source record for a given page, including any revision text."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
query = select(Revision).where(Revision.source_id == source_id) return await _session.get(Source, source_id)
result = await _session.exec(query)
return result.one_or_none()
async def list_revisions_by_job( async def list_revisions_by_job(
self, self,
job_id: UUID, job_id: UUID,
*, *,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> Sequence[Revision]: ) -> Sequence[Source]:
"""List revisions connected to all sources for a job.""" """List source pages for a job that carry revision text."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
query = ( query = (
select(Revision) select(Source)
.join(Source, Source.id == Revision.source_id) .join(JobSource, JobSource.source_id == Source.id)
.where(Source.job_id == job_id) .where(JobSource.job_id == job_id)
.order_by(Revision.date_created) # pyright: ignore[reportArgumentType] .where(Source.revised_text.is_not(None))
.order_by(Source.date_revised) # pyright: ignore[reportArgumentType]
) )
result = await _session.exec(query) result = await _session.exec(query)
return result.all() return result.all()
+2 -2
View File
@@ -291,9 +291,9 @@ async def _finalize_failed(
def _resolve_primary_source(job: Job) -> Source | None: def _resolve_primary_source(job: Job) -> Source | None:
if not job.sources: if not job.job_sources:
return None return None
return job.sources[0] return next((job_source.source for job_source in job.job_sources if job_source.source is not None), None)
def _validate_transcription_quality(*, result: TranscriptionResult, settings: Settings) -> None: def _validate_transcription_quality(*, result: TranscriptionResult, settings: Settings) -> None:
@@ -7,7 +7,6 @@ import logging
from nicegui import ui from nicegui import ui
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import Revision
from transcription.db.models import Source from transcription.db.models import Source
from transcription.ui.components.document_panzoom import render_document_panzoom from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.transcript import render_original_transcription_card from transcription.ui.components.transcript import render_original_transcription_card
@@ -48,7 +47,7 @@ def _render_source_section(source: Source) -> None:
render_document_panzoom(source=source) render_document_panzoom(source=source)
def _render_revision_section(revision: Revision | None) -> None: def _render_revision_section(revision: Source | None) -> None:
with ui.card().classes("w-full q-pa-md vibe-card"): with ui.card().classes("w-full q-pa-md vibe-card"):
ui.label("Revision").classes("text-subtitle1 text-weight-medium") ui.label("Revision").classes("text-subtitle1 text-weight-medium")
ui.separator().classes("q-my-sm") ui.separator().classes("q-my-sm")
@@ -60,7 +59,7 @@ def _render_revision_section(revision: Revision | None) -> None:
render_revision_row(revision=revision, initially_expanded=True) render_revision_row(revision=revision, initially_expanded=True)
def render_job_detail(*, job: Job, source: Source | None, revision: Revision | None) -> None: def render_job_detail(*, job: Job, source: Source | None, revision: Source | None) -> None:
"""Render all sections for the job detail page.""" """Render all sections for the job detail page."""
logger.debug("Rendering job detail for job ID %s", job.id) logger.debug("Rendering job detail for job ID %s", job.id)
status_text = job.status.value status_text = job.status.value
+34 -13
View File
@@ -10,14 +10,16 @@ from typing import Any
from nicegui import ui from nicegui import ui
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import Revision from transcription.db.models import JobSource
from transcription.db.models import Source
type RevisionAction = Callable[[Revision], Awaitable[None] | None] type RevisionAction = Callable[[Source], Awaitable[None] | None]
def render_original_transcription_card(*, job: Job, classes: str = "w-full") -> Any: def render_original_transcription_card(*, job: Job, classes: str = "w-full") -> Any:
"""Render the immutable original job transcription output.""" """Render the immutable original job transcription output."""
status_label = "Failed" if job.error_detail else "Transcribed" latest_error_detail = _latest_job_error_detail(job)
status_label = "Failed" if latest_error_detail else "Transcribed"
header = f"Original Transcription | {status_label}" header = f"Original Transcription | {status_label}"
provider = job.provider or "unknown" provider = job.provider or "unknown"
model = job.model or "unknown" model = job.model or "unknown"
@@ -30,28 +32,33 @@ def render_original_transcription_card(*, job: Job, classes: str = "w-full") ->
_metadata_row(label="Prompt", value=job.prompt_name or "unknown") _metadata_row(label="Prompt", value=job.prompt_name or "unknown")
_metadata_row(label="Updated", value=_format_created_at(job.date_updated)) _metadata_row(label="Updated", value=_format_created_at(job.date_updated))
if job.text: latest_transcription = _latest_job_transcription(job)
with ui.card().classes("w-full q-pa-sm"):
ui.markdown(job.text)
if job.error_detail: if latest_transcription:
with ui.card().classes("w-full q-pa-sm"):
ui.markdown(latest_transcription)
if latest_error_detail:
with ui.card().classes("w-full vibe-card--error q-pa-sm"): with ui.card().classes("w-full vibe-card--error q-pa-sm"):
ui.label("Failure detail").classes("text-caption text-uppercase") ui.label("Failure detail").classes("text-caption text-uppercase")
ui.label(job.error_detail).classes("text-body2") ui.label(latest_error_detail).classes("text-body2")
return card return card
def render_revision_row( def render_revision_row(
*, *,
revision: Revision, revision: Source | None,
initially_expanded: bool = False, initially_expanded: bool = False,
classes: str = "w-full", classes: str = "w-full",
on_delete: RevisionAction | None = None, on_delete: RevisionAction | None = None,
) -> Any: ) -> Any:
"""Render a collapsible row for the single optional source revision.""" """Render a collapsible row for the single optional source revision."""
if revision is None:
return None
header = "Revision | User-authored" header = "Revision | User-authored"
caption = _format_created_at(revision.date_created) caption = _format_created_at(revision.date_revised or revision.date_uploaded)
expansion = ui.expansion(value=initially_expanded, group="group").classes(f"{classes} rounded-borders vibe-card") expansion = ui.expansion(value=initially_expanded, group="group").classes(f"{classes} rounded-borders vibe-card")
@@ -84,15 +91,29 @@ def render_revision_row(
ui.button(icon="delete", on_click=delete_current_transcript).props( ui.button(icon="delete", on_click=delete_current_transcript).props(
'flat round dense color="negative"' 'flat round dense color="negative"'
) )
_metadata_row(label="Created", value=_format_created_at(revision.date_created)) _metadata_row(label="Created", value=_format_created_at(revision.date_revised or revision.date_uploaded))
if revision.text: if revision.revised_text:
with ui.card().classes("w-full q-pa-sm"): with ui.card().classes("w-full q-pa-sm"):
ui.markdown(revision.text) ui.markdown(revision.revised_text)
return expansion return expansion
def _latest_job_transcription(job: Job) -> str | None:
for job_source in sorted(job.job_sources, key=lambda item: item.executed_at, reverse=True):
if job_source.raw_transcription:
return job_source.raw_transcription
return None
def _latest_job_error_detail(job: Job) -> str | None:
for job_source in sorted(job.job_sources, key=lambda item: item.executed_at, reverse=True):
if job_source.error_detail:
return job_source.error_detail
return None
def _format_created_at(value: datetime) -> str: def _format_created_at(value: datetime) -> str:
"""Return a compact UTC-like timestamp for row captions.""" """Return a compact UTC-like timestamp for row captions."""
return value.strftime("%Y-%m-%d %H:%M:%S %Z") return value.strftime("%Y-%m-%d %H:%M:%S %Z")
+12 -21
View File
@@ -87,17 +87,6 @@ def register_page() -> None: # noqa: PLR0915
render_original_transcription_card(job=job) render_original_transcription_card(job=job)
async def delete_revision_by_id(revision_id: UUID) -> None:
try:
revision = await transcription_service.read_revision(revision_id=revision_id)
await transcription_service.delete_revision(revision)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete failed", operation="jobs.delete_revision")
return
ui.notify("Deleted revision", type="positive")
await render_revision_panel.refresh()
@ui.refreshable @ui.refreshable
async def render_revision_panel() -> None: async def render_revision_panel() -> None:
refreshed_job = await jobs_service.read_job(job_id=parsed_job_id) refreshed_job = await jobs_service.read_job(job_id=parsed_job_id)
@@ -106,10 +95,8 @@ def register_page() -> None: # noqa: PLR0915
ui.label("No source is available for revision editing.").classes("text-body2 vibe-text-muted") ui.label("No source is available for revision editing.").classes("text-body2 vibe-text-muted")
return return
current_revision = refreshed_source.revision current_revision_text = refreshed_source.revised_text
default_revision_text = ( default_revision_text = current_revision_text or ""
current_revision.text if current_revision is not None else (refreshed_job.text or "")
)
ui.label("Revision Editor").classes("text-subtitle1 text-weight-medium") ui.label("Revision Editor").classes("text-subtitle1 text-weight-medium")
editor = ui.textarea(label="Revision text", value=default_revision_text).props("autogrow outlined") editor = ui.textarea(label="Revision text", value=default_revision_text).props("autogrow outlined")
@@ -135,25 +122,29 @@ def register_page() -> None: # noqa: PLR0915
with ui.row().classes("w-full justify-end"): with ui.row().classes("w-full justify-end"):
ui.button( ui.button(
"Create revision" if current_revision is None else "Update revision", "Create revision" if current_revision_text is None else "Update revision",
on_click=save_revision, on_click=save_revision,
icon="save", icon="save",
).props('unelevated color="primary"') ).props('unelevated color="primary"')
if current_revision is None: if current_revision_text is None:
ui.label("No revision exists for this source.").classes("text-body2 vibe-text-muted") ui.label("No revision exists for this source.").classes("text-body2 vibe-text-muted")
return return
render_revision_row( render_revision_row(
revision=current_revision, revision=refreshed_source,
initially_expanded=True, initially_expanded=True,
on_delete=lambda _revision, rid=current_revision.id: delete_revision_by_id(rid),
) )
await render_revision_panel() await render_revision_panel()
def _resolve_primary_source(job: Job) -> Source | None: def _resolve_primary_source(job: Job) -> Source | None:
if not job.sources: if not job.job_sources:
return None
for job_source in job.job_sources:
if job_source.source is not None:
return job_source.source
return None return None
return job.sources[0]
+1
View File
@@ -31,6 +31,7 @@ def session():
connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False},
poolclass=StaticPool, poolclass=StaticPool,
) )
SQLModel.metadata.drop_all(engine)
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
with Session(engine) as sync_session: with Session(engine) as sync_session:
yield sync_session yield sync_session
+8 -7
View File
@@ -64,8 +64,8 @@ class TestPipelineSuccessFlow:
assert processed is True assert processed is True
assert job is not None assert job is not None
assert job.status == JobStatus.TRANSCRIBED assert job.status == JobStatus.TRANSCRIBED
assert job.text == "Pipeline transcript" assert any(job_source.raw_transcription == "Pipeline transcript" for job_source in job.job_sources)
assert job.error_detail is None assert all(job_source.error_detail is None for job_source in job.job_sources)
@pytest.mark.integration @pytest.mark.integration
@@ -104,8 +104,9 @@ class TestPipelineFailureFlow:
assert processed is True assert processed is True
assert job is not None assert job is not None
assert job.status == JobStatus.FAILED assert job.status == JobStatus.FAILED
assert job.text is None assert all(job_source.raw_transcription is None for job_source in job.job_sources)
assert job.error_detail is not None assert any(job_source.error_detail is not None for job_source in job.job_sources)
assert "pipeline provider failure" in job.error_detail error_detail = next(job_source.error_detail for job_source in job.job_sources if job_source.error_detail is not None)
assert "[internal_unexpected_error]" in job.error_detail assert "pipeline provider failure" in error_detail
assert "error_id=" in job.error_detail assert "[internal_unexpected_error]" in error_detail
assert "error_id=" in error_detail
+12 -3
View File
@@ -4,6 +4,8 @@ import pytest
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
from transcription.db.models import Source from transcription.db.models import Source
from transcription.services.documents import DocumentService from transcription.services.documents import DocumentService
@@ -66,14 +68,21 @@ class TestJobService:
await job_service.create_job(job=job) await job_service.create_job(job=job)
async with job_service._session_scope() as session: async with job_service._session_scope() as session:
session.add( source = Source(
Source(
document_id=document.id, document_id=document.id,
job_id=job.id, page_number=1,
upload_name="letter.jpg", upload_name="letter.jpg",
filename="stored-letter.jpg", filename="stored-letter.jpg",
file_path="/uploads/stored-letter.jpg", file_path="/uploads/stored-letter.jpg",
) )
session.add(source)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
) )
await session.commit() await session.commit()
+18 -12
View File
@@ -1,4 +1,4 @@
"""Tests for revision behavior in TranscriptionService.""" """Tests for source revision behavior in TranscriptionService."""
from uuid import uuid4 from uuid import uuid4
@@ -6,6 +6,8 @@ import pytest
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
from transcription.db.models import Source from transcription.db.models import Source
from transcription.services.documents import DocumentService from transcription.services.documents import DocumentService
@@ -15,7 +17,7 @@ from transcription.services.transcription import TranscriptionService
@pytest.mark.integration @pytest.mark.integration
class TestTranscriptionServiceRevisionUpsert: class TestTranscriptionServiceRevisionUpsert:
"""Verify optional single-revision create/update semantics.""" """Verify page-level source revision semantics."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upsert_revision_creates_new_revision(self, default_session_factory): async def test_upsert_revision_creates_new_revision(self, default_session_factory):
@@ -26,29 +28,31 @@ class TestTranscriptionServiceRevisionUpsert:
document = Document(id=uuid4(), name="revision-create") document = Document(id=uuid4(), name="revision-create")
await documents.create_document(document=document) await documents.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED, text="Original text") job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED)
await jobs.create_job(job=job) await jobs.create_job(job=job)
source = Source( source = Source(
document_id=document.id, document_id=document.id,
job_id=job.id, page_number=1,
upload_name="source.jpg", upload_name="source.jpg",
filename="source.jpg", filename="source.jpg",
file_path="uploads/source.jpg", file_path="uploads/source.jpg",
) )
async with transcriptions._session_scope() as session: async with transcriptions._session_scope() as session:
session.add(source) session.add(source)
await session.flush()
session.add(JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING))
await session.commit() await session.commit()
await session.refresh(source) await session.refresh(source)
revision = await transcriptions.upsert_revision_for_source(source_id=source.id, text="User revision") revision = await transcriptions.upsert_revision_for_source(source_id=source.id, text="User revision")
fetched = await transcriptions.read_revision_by_source(source.id) fetched = await transcriptions.read_revision_by_source(source.id)
assert revision.source_id == source.id assert revision.id == source.id
assert revision.text == "User revision" assert revision.revised_text == "User revision"
assert fetched is not None assert fetched is not None
assert fetched.id == revision.id assert fetched.id == source.id
assert fetched.text == "User revision" assert fetched.revised_text == "User revision"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upsert_revision_updates_existing_single_revision(self, default_session_factory): async def test_upsert_revision_updates_existing_single_revision(self, default_session_factory):
@@ -59,18 +63,20 @@ class TestTranscriptionServiceRevisionUpsert:
document = Document(id=uuid4(), name="revision-update") document = Document(id=uuid4(), name="revision-update")
await documents.create_document(document=document) await documents.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED, text="Original text") job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED)
await jobs.create_job(job=job) await jobs.create_job(job=job)
source = Source( source = Source(
document_id=document.id, document_id=document.id,
job_id=job.id, page_number=1,
upload_name="source.jpg", upload_name="source.jpg",
filename="source.jpg", filename="source.jpg",
file_path="uploads/source.jpg", file_path="uploads/source.jpg",
) )
async with transcriptions._session_scope() as session: async with transcriptions._session_scope() as session:
session.add(source) session.add(source)
await session.flush()
session.add(JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING))
await session.commit() await session.commit()
await session.refresh(source) await session.refresh(source)
@@ -79,7 +85,7 @@ class TestTranscriptionServiceRevisionUpsert:
revisions = await transcriptions.list_revisions_by_job(job.id) revisions = await transcriptions.list_revisions_by_job(job.id)
assert first.id == second.id assert first.id == second.id
assert second.text == "Revision v2" assert second.revised_text == "Revision v2"
assert len(revisions) == 1 assert len(revisions) == 1
assert revisions[0].id == first.id assert revisions[0].id == first.id
assert revisions[0].text == "Revision v2" assert revisions[0].revised_text == "Revision v2"
+15 -8
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from collections.abc import Generator from collections.abc import Generator
from datetime import UTC
from datetime import datetime
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
@@ -21,8 +23,9 @@ from transcription.db import initialize_database_runtime
from transcription.db import session_scope from transcription.db import session_scope
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
from transcription.db.models import Revision
from transcription.db.models import Source from transcription.db.models import Source
RevisionSeed = str RevisionSeed = str
@@ -55,7 +58,6 @@ def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
async def _clear() -> None: async def _clear() -> None:
async with session_scope() as session: async with session_scope() as session:
await session.exec(delete(Revision))
await session.exec(delete(Source)) await session.exec(delete(Source))
await session.exec(delete(Job)) await session.exec(delete(Job))
await session.exec(delete(Document)) await session.exec(delete(Document))
@@ -94,8 +96,6 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
document_id=document.id, document_id=document.id,
status=status, status=status,
retry_count=0, retry_count=0,
text=transcription_text,
error_detail=error_detail,
provider="openrouter", provider="openrouter",
model="google/gemini-2.5-flash", model="google/gemini-2.5-flash",
prompt_name="transcribe_document.md", prompt_name="transcribe_document.md",
@@ -105,7 +105,6 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
source = Source( source = Source(
document_id=document.id, document_id=document.id,
job_id=job.id,
upload_name=filename, upload_name=filename,
filename=filename, filename=filename,
file_path=str(stored_path), file_path=str(stored_path),
@@ -113,14 +112,22 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
session.add(source) session.add(source)
await session.flush() await session.flush()
if revision_text is not None: if transcription_text is not None or error_detail is not None:
session.add( session.add(
Revision( JobSource(
job_id=job.id,
source_id=source.id, source_id=source.id,
text=revision_text, status=JobSourceStatus.TRANSCRIBED if transcription_text is not None else JobSourceStatus.FAILED,
raw_transcription=transcription_text,
error_detail=error_detail,
) )
) )
if revision_text is not None:
source.revised_text = revision_text
source.date_revised = datetime.now(UTC)
session.add(source)
await session.commit() await session.commit()
return job.id return job.id