Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a141bd4cc | ||
|
|
8129f5a9e8 | ||
|
|
58b4c381a4 | ||
|
|
5719debbaa | ||
|
|
e7c7ab71b4 | ||
|
|
ca5c9f787f | ||
|
|
8064821503 | ||
|
|
593388ef3a | ||
|
|
83ee7b31e0 | ||
|
|
455a01d7c4 |
@@ -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,7 +10,6 @@ The app lets you upload a document image/PDF, queues a background transcription
|
||||
- Persist document + job records in SQLite
|
||||
- Process jobs in a background worker (`queued -> processing -> transcribed/failed`)
|
||||
- Store transcript text (or failure detail)
|
||||
- Track transcript revisions (AI-generated and manual updates)
|
||||
- Show status and results in the NiceGUI interface
|
||||
|
||||
## Quick start
|
||||
@@ -62,11 +61,8 @@ uv run uvicorn transcription.app:create_app --factory --reload
|
||||
|
||||
- **Job detail page** (`/ui/jobs/{job_id}`)
|
||||
- Shows job metadata and status.
|
||||
- Shows transcript metadata, including provider and model.
|
||||
- Shows a version table with `Created` and `Version`.
|
||||
- Displays latest version text in an editable textbox.
|
||||
- **Update** creates a new transcript version.
|
||||
- Displays failure detail for failed revisions.
|
||||
- Displays transcript text when successful.
|
||||
- Displays failure detail when transcription fails.
|
||||
|
||||
## Prompt artifacts
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi import status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .api.errors import register_error_handlers
|
||||
from .api.health import router as health_router
|
||||
@@ -25,7 +26,7 @@ from .worker import worker_consumer_lifespan
|
||||
async def _lifespan(app: FastAPI):
|
||||
configure_logging()
|
||||
|
||||
settings = get_settings()
|
||||
settings = getattr(app.state, "settings", None) or get_settings()
|
||||
app.state.settings = settings
|
||||
app.state.services = ServiceBundle()
|
||||
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||
@@ -52,6 +53,13 @@ async def _lifespan(app: FastAPI):
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
||||
settings = get_settings()
|
||||
app.state.settings = settings
|
||||
app.mount(
|
||||
"/uploads",
|
||||
StaticFiles(directory=settings.upload_dir, check_dir=False),
|
||||
name="uploads",
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root_redirect() -> RedirectResponse:
|
||||
|
||||
@@ -58,9 +58,3 @@ def _ensure_sqlite_compat_columns(connection: Connection) -> None:
|
||||
if "retry_count" not in columns:
|
||||
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
|
||||
logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0")
|
||||
|
||||
if "transcript" in table_names:
|
||||
transcript_columns = {column["name"] for column in inspector.get_columns("transcript")}
|
||||
if "model" not in transcript_columns:
|
||||
connection.execute(text("ALTER TABLE transcript ADD COLUMN model VARCHAR"))
|
||||
logger.warning("Applied SQLite compatibility schema patch table=transcript column=model")
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"""SQLModel domain models for the transcription system.
|
||||
|
||||
Core models capture the MVP lifecycle:
|
||||
Document (1) -> (many) Job
|
||||
Job (1) -> (1) Transcript
|
||||
Job (1) -> (many) TranscriptRevision
|
||||
Three models capture the MVP lifecycle:
|
||||
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
|
||||
|
||||
@@ -50,8 +47,7 @@ class Job(SQLModel, table=True):
|
||||
|
||||
# --- relationships ---
|
||||
document: Document = Relationship(back_populates="jobs")
|
||||
transcript: Optional["Transcript"] = Relationship(back_populates="job")
|
||||
transcript_revisions: list["TranscriptRevision"] = Relationship(back_populates="job")
|
||||
transcripts: list["Transcript"] = Relationship(back_populates="job")
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
@@ -63,39 +59,21 @@ 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
|
||||
"""Name of the prompt used to generate this transcript."""
|
||||
model: str | None = None
|
||||
"""Provider model that generated the original AI transcript."""
|
||||
text: str | None = None
|
||||
"""The transcribed text. This may be None if the job failed or is still in progress."""
|
||||
error_detail: str | None = None
|
||||
"""Details of any error that occurred during transcription."""
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
# --- relationships ---
|
||||
job: Job = Relationship(back_populates="transcript")
|
||||
|
||||
|
||||
class TranscriptRevision(SQLModel, table=True):
|
||||
"""Version history entries for a transcription job."""
|
||||
|
||||
__table_args__ = (UniqueConstraint("job_id", "version_number", name="uq_transcript_revision_job_version"),)
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
job_id: UUID = Field(foreign_key="job.id", index=True)
|
||||
version_number: int = Field(ge=1)
|
||||
provider: str
|
||||
prompt_name: str
|
||||
model: str | None = None
|
||||
source: str = Field(default="ai")
|
||||
text: str | None = None
|
||||
error_detail: str | None = None
|
||||
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_revisions")
|
||||
job: Job = Relationship(back_populates="transcripts")
|
||||
|
||||
@@ -34,7 +34,6 @@ class TranscriptionResult:
|
||||
job_id=job_id,
|
||||
provider=self.provider,
|
||||
prompt_name=self.prompt_name,
|
||||
model=self.model,
|
||||
text=self.text,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -18,7 +20,6 @@ from transcription.config import get_settings
|
||||
from transcription.errors import AppError
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.models import Transcript
|
||||
from transcription.models import TranscriptRevision
|
||||
from transcription.providers import ProviderAuthError
|
||||
from transcription.providers import ProviderError
|
||||
from transcription.providers import ProviderResponseError
|
||||
@@ -64,18 +65,6 @@ class TranscriptionService(ServiceBase):
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
|
||||
return transcript
|
||||
|
||||
async def create_transcript_revision(
|
||||
self,
|
||||
transcript_revision: TranscriptRevision,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> TranscriptRevision:
|
||||
"""Create a new transcript revision in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
_session.add(transcript_revision)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(transcript_revision,))
|
||||
return transcript_revision
|
||||
|
||||
async def read_transcript(self, transcript_id: UUID, *, session: AsyncSession | None = None) -> Transcript:
|
||||
"""Read an existing transcript from the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
@@ -93,27 +82,6 @@ class TranscriptionService(ServiceBase):
|
||||
)
|
||||
return transcript
|
||||
|
||||
async def read_transcript_revision(
|
||||
self,
|
||||
transcript_revision_id: UUID,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> TranscriptRevision:
|
||||
"""Read an existing transcript revision from the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
transcript_revision = await _session.get(
|
||||
TranscriptRevision,
|
||||
transcript_revision_id,
|
||||
options=(selectinload(TranscriptRevision.job),), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
if transcript_revision is None:
|
||||
raise TranscriptionNotFoundError(
|
||||
f"Transcript revision with id {transcript_revision_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the transcript revision id and retry.",
|
||||
)
|
||||
return transcript_revision
|
||||
|
||||
async def update_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> Transcript:
|
||||
"""Update an existing transcript in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
@@ -121,35 +89,12 @@ class TranscriptionService(ServiceBase):
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||
return merged
|
||||
|
||||
async def update_transcript_revision(
|
||||
self,
|
||||
transcript_revision: TranscriptRevision,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> TranscriptRevision:
|
||||
"""Update an existing transcript revision in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
merged = await _session.merge(transcript_revision)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||
return merged
|
||||
|
||||
async def delete_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> None:
|
||||
"""Delete a transcript from the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
await _session.delete(transcript)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
async def delete_transcript_revision(
|
||||
self,
|
||||
transcript_revision: TranscriptRevision,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Delete a transcript revision from the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
await _session.delete(transcript_revision)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
async def transcribe_document(
|
||||
self,
|
||||
image_path: str | Path,
|
||||
@@ -165,95 +110,75 @@ 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,
|
||||
model: str | None = None,
|
||||
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:
|
||||
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,
|
||||
)
|
||||
|
||||
transcript.text = text
|
||||
transcript.error_detail = error_detail
|
||||
if provider is not None:
|
||||
transcript.provider = provider
|
||||
transcript.prompt_name = prompt_name
|
||||
if model is not None:
|
||||
transcript.model = model
|
||||
|
||||
_session.add(transcript)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
|
||||
return transcript
|
||||
|
||||
async def list_transcript_revisions_by_job(
|
||||
async def read_latest_transcript_by_job(
|
||||
self,
|
||||
*,
|
||||
job_id: UUID,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> list[TranscriptRevision]:
|
||||
"""Return transcript revisions for a job ordered by version number."""
|
||||
) -> Transcript | None:
|
||||
"""Read the latest transcript revision for a job id."""
|
||||
async with self._session_scope(session) as _session:
|
||||
revisions = (
|
||||
await _session.exec(
|
||||
select(TranscriptRevision)
|
||||
.where(TranscriptRevision.job_id == job_id)
|
||||
.order_by(TranscriptRevision.version_number)
|
||||
)
|
||||
).all()
|
||||
return list(revisions)
|
||||
query = _transcript_job_query(job_id=job_id).limit(1)
|
||||
result = await _session.exec(query)
|
||||
return result.one_or_none()
|
||||
|
||||
async def append_transcript_revision(
|
||||
async def list_transcripts_by_job(
|
||||
self,
|
||||
*,
|
||||
job_id: UUID,
|
||||
text: str | None,
|
||||
error_detail: str | None,
|
||||
provider: str,
|
||||
prompt_name: str,
|
||||
model: str | None,
|
||||
source: str,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> TranscriptRevision:
|
||||
"""Append a new transcript revision and allocate the next version number."""
|
||||
) -> Sequence[Transcript]:
|
||||
"""List transcript revisions for a job id in ascending revision order."""
|
||||
async with self._session_scope(session) as _session:
|
||||
latest_version = (
|
||||
await _session.exec(
|
||||
select(TranscriptRevision.version_number)
|
||||
.where(TranscriptRevision.job_id == job_id)
|
||||
.order_by(TranscriptRevision.version_number.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).first()
|
||||
next_version = 1 if latest_version is None else latest_version + 1
|
||||
query = _transcript_job_query(job_id=job_id)
|
||||
result = await _session.exec(query)
|
||||
return result.all()
|
||||
|
||||
revision = TranscriptRevision(
|
||||
job_id=job_id,
|
||||
version_number=next_version,
|
||||
provider=provider,
|
||||
prompt_name=prompt_name,
|
||||
model=model,
|
||||
source=source,
|
||||
text=text,
|
||||
error_detail=error_detail,
|
||||
)
|
||||
_session.add(revision)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(revision,))
|
||||
return revision
|
||||
|
||||
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(
|
||||
|
||||
@@ -121,24 +121,12 @@ async def _finalize_transcribed(
|
||||
"""Transaction B: transcript + TRANSCRIBED in one commit."""
|
||||
if session is None:
|
||||
async with services.jobs._session_scope() as local_session:
|
||||
prompt_name = result.prompt_name or DEFAULT_PROMPT_FILE
|
||||
await services.transcriptions.upsert_transcript_by_job(
|
||||
await services.transcriptions.create_transcript_for_job(
|
||||
job_id=job.id,
|
||||
text=result.text,
|
||||
error_detail=None,
|
||||
provider=result.provider,
|
||||
prompt_name=prompt_name,
|
||||
model=result.model,
|
||||
session=local_session,
|
||||
)
|
||||
await services.transcriptions.append_transcript_revision(
|
||||
job_id=job.id,
|
||||
text=result.text,
|
||||
error_detail=None,
|
||||
provider=result.provider,
|
||||
prompt_name=prompt_name,
|
||||
model=result.model,
|
||||
source="ai",
|
||||
prompt_name=result.prompt_name,
|
||||
session=local_session,
|
||||
)
|
||||
updated_job = await services.jobs.mark_job_status(
|
||||
@@ -149,24 +137,12 @@ async def _finalize_transcribed(
|
||||
await local_session.commit()
|
||||
return updated_job
|
||||
|
||||
prompt_name = result.prompt_name or DEFAULT_PROMPT_FILE
|
||||
await services.transcriptions.upsert_transcript_by_job(
|
||||
await services.transcriptions.create_transcript_for_job(
|
||||
job_id=job.id,
|
||||
text=result.text,
|
||||
error_detail=None,
|
||||
provider=result.provider,
|
||||
prompt_name=prompt_name,
|
||||
model=result.model,
|
||||
session=session,
|
||||
)
|
||||
await services.transcriptions.append_transcript_revision(
|
||||
job_id=job.id,
|
||||
text=result.text,
|
||||
error_detail=None,
|
||||
provider=result.provider,
|
||||
prompt_name=prompt_name,
|
||||
model=result.model,
|
||||
source="ai",
|
||||
prompt_name=result.prompt_name,
|
||||
session=session,
|
||||
)
|
||||
updated_job = await services.jobs.mark_job_status(
|
||||
@@ -189,24 +165,11 @@ 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:
|
||||
provider_name = services.transcriptions.settings.provider.value
|
||||
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),
|
||||
provider=provider_name,
|
||||
prompt_name=DEFAULT_PROMPT_FILE,
|
||||
model=services.transcriptions.settings.provider_model,
|
||||
session=local_session,
|
||||
)
|
||||
await services.transcriptions.append_transcript_revision(
|
||||
job_id=job.id,
|
||||
text=None,
|
||||
error_detail=format_error_detail(error),
|
||||
provider=provider_name,
|
||||
prompt_name=DEFAULT_PROMPT_FILE,
|
||||
model=services.transcriptions.settings.provider_model,
|
||||
source="ai",
|
||||
session=local_session,
|
||||
)
|
||||
updated_job = await services.jobs.update_job_state(
|
||||
@@ -217,24 +180,11 @@ async def _finalize_retry(
|
||||
)
|
||||
await local_session.commit()
|
||||
else:
|
||||
provider_name = services.transcriptions.settings.provider.value
|
||||
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),
|
||||
provider=provider_name,
|
||||
prompt_name=DEFAULT_PROMPT_FILE,
|
||||
model=services.transcriptions.settings.provider_model,
|
||||
session=session,
|
||||
)
|
||||
await services.transcriptions.append_transcript_revision(
|
||||
job_id=job.id,
|
||||
text=None,
|
||||
error_detail=format_error_detail(error),
|
||||
provider=provider_name,
|
||||
prompt_name=DEFAULT_PROMPT_FILE,
|
||||
model=services.transcriptions.settings.provider_model,
|
||||
source="ai",
|
||||
session=session,
|
||||
)
|
||||
updated_job = await services.jobs.update_job_state(
|
||||
@@ -260,24 +210,11 @@ 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:
|
||||
provider_name = services.transcriptions.settings.provider.value
|
||||
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),
|
||||
provider=provider_name,
|
||||
prompt_name=DEFAULT_PROMPT_FILE,
|
||||
model=services.transcriptions.settings.provider_model,
|
||||
session=local_session,
|
||||
)
|
||||
await services.transcriptions.append_transcript_revision(
|
||||
job_id=job.id,
|
||||
text=None,
|
||||
error_detail=format_error_detail(error),
|
||||
provider=provider_name,
|
||||
prompt_name=DEFAULT_PROMPT_FILE,
|
||||
model=services.transcriptions.settings.provider_model,
|
||||
source="ai",
|
||||
session=local_session,
|
||||
)
|
||||
updated_job = await services.jobs.mark_job_status(
|
||||
@@ -288,24 +225,11 @@ async def _finalize_failed(
|
||||
await local_session.commit()
|
||||
return updated_job
|
||||
|
||||
provider_name = services.transcriptions.settings.provider.value
|
||||
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),
|
||||
provider=provider_name,
|
||||
prompt_name=DEFAULT_PROMPT_FILE,
|
||||
model=services.transcriptions.settings.provider_model,
|
||||
session=session,
|
||||
)
|
||||
await services.transcriptions.append_transcript_revision(
|
||||
job_id=job.id,
|
||||
text=None,
|
||||
error_detail=format_error_detail(error),
|
||||
provider=provider_name,
|
||||
prompt_name=DEFAULT_PROMPT_FILE,
|
||||
model=services.transcriptions.settings.provider_model,
|
||||
source="ai",
|
||||
session=session,
|
||||
)
|
||||
updated_job = await services.jobs.mark_job_status(
|
||||
|
||||
@@ -1,14 +1,45 @@
|
||||
"""UI page registration exports."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from nicegui import app as nicegui_app
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
||||
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
||||
|
||||
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
|
||||
|
||||
_THEME_COLORS: dict[str, str] = {
|
||||
"primary": "#6f97e8",
|
||||
"secondary": "#92b5f5",
|
||||
"accent": "#7fc0de",
|
||||
"dark": "#22304a",
|
||||
"dark_page": "#1a2538",
|
||||
"positive": "#86c8ad",
|
||||
"negative": "#d98a9a",
|
||||
"info": "#7ebdda",
|
||||
"warning": "#e2c083",
|
||||
}
|
||||
|
||||
|
||||
def _register_global_styles(app: FastAPI) -> None:
|
||||
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
|
||||
return
|
||||
|
||||
nicegui_app.colors(**_THEME_COLORS)
|
||||
|
||||
css_path = Path(__file__).resolve().parent / "static" / "colors.css"
|
||||
if css_path.exists():
|
||||
ui.add_css(css_path, shared=True)
|
||||
|
||||
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
|
||||
|
||||
|
||||
def register_pages(app: FastAPI) -> None:
|
||||
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
||||
_register_global_styles(app)
|
||||
register_upload_page()
|
||||
register_jobs_page()
|
||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False)
|
||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=True)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Reusable UI component exports."""
|
||||
|
||||
from transcription.ui.components.app_shell import NAV_ITEMS
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||
|
||||
__all__ = ["NAV_ITEMS", "render_document_panzoom", "render_navigation_header"]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Reusable app shell primitives for page-level layout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||
("Upload", "/upload", "upload_file"),
|
||||
("Jobs", "/jobs", "work_history"),
|
||||
)
|
||||
|
||||
|
||||
def _is_active_path(*, current_path: str, item_path: str) -> bool:
|
||||
if item_path == "/jobs":
|
||||
return current_path == "/jobs" or current_path.startswith("/jobs/")
|
||||
return current_path == item_path
|
||||
|
||||
|
||||
def _button_props(*, icon: str, is_active: bool) -> str:
|
||||
if is_active:
|
||||
return f"icon={icon} no-caps unelevated color=primary text-color=white"
|
||||
return f"icon={icon} no-caps outline color=secondary text-color=secondary"
|
||||
|
||||
|
||||
def _button_classes(*, is_active: bool) -> str:
|
||||
base = "w-full sm:w-auto min-h-[40px] px-3 rounded-lg text-body2 text-weight-medium"
|
||||
if is_active:
|
||||
return f"{base}"
|
||||
return f"{base}"
|
||||
|
||||
|
||||
def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) -> None:
|
||||
is_active = _is_active_path(current_path=current_path, item_path=path)
|
||||
button = ui.button(
|
||||
label,
|
||||
icon=icon,
|
||||
on_click=lambda _=None, route=path: ui.navigate.to(route),
|
||||
)
|
||||
button.props(_button_props(icon=icon, is_active=is_active)).classes(_button_classes(is_active=is_active))
|
||||
|
||||
|
||||
def _normalize_path(current_path: str | None) -> str:
|
||||
normalized = (current_path or "").strip()
|
||||
if not normalized:
|
||||
return "/upload"
|
||||
return normalized.rstrip("/") or "/"
|
||||
|
||||
|
||||
def render_navigation_header(*, current_path: str | None = None) -> None:
|
||||
"""Render a shared app header with links for top-level pages."""
|
||||
normalized_path = _normalize_path(current_path)
|
||||
|
||||
with (
|
||||
ui.header(elevated=True).props("bordered").classes("bg-dark text-white q-px-sm q-py-xs"),
|
||||
ui.row().classes("w-full items-center justify-end q-gutter-xs"),
|
||||
ui.element("div").classes("w-full grid grid-cols-2 gap-2 sm:flex sm:justify-start sm:gap-2"),
|
||||
):
|
||||
for label, path, icon in NAV_ITEMS:
|
||||
_render_nav_button(label=label, path=path, icon=icon, current_path=normalized_path)
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Panzoom-backed document preview component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from uuid import uuid4
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.config import get_settings
|
||||
from transcription.models import Document
|
||||
|
||||
PANGOZOOM_CDN_URL = "https://unpkg.com/@panzoom/[email protected]/dist/panzoom.min.js"
|
||||
UPLOADS_URL_PREFIX = "/uploads"
|
||||
|
||||
|
||||
def _document_url(document: Document) -> str:
|
||||
file_path = Path(document.file_path)
|
||||
upload_dir = get_settings().upload_dir
|
||||
|
||||
relative_path: Path
|
||||
try:
|
||||
relative_path = file_path.resolve().relative_to(upload_dir.resolve())
|
||||
except ValueError:
|
||||
parts = file_path.parts
|
||||
if "uploads" in parts:
|
||||
uploads_index = parts.index("uploads")
|
||||
relative_path = Path(*parts[uploads_index + 1 :])
|
||||
else:
|
||||
relative_path = Path(file_path.name)
|
||||
|
||||
encoded_relative_path = "/".join(quote(part) for part in relative_path.parts)
|
||||
return f"{UPLOADS_URL_PREFIX}/{encoded_relative_path}"
|
||||
|
||||
|
||||
def _document_kind(document: Document) -> str:
|
||||
suffix = Path(document.file_path).suffix.lower()
|
||||
if suffix == ".pdf":
|
||||
return "pdf"
|
||||
return "image"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _register_panzoom_assets() -> None:
|
||||
ui.add_head_html(
|
||||
f'<script src="{PANGOZOOM_CDN_URL}"></script>',
|
||||
shared=True,
|
||||
)
|
||||
ui.add_head_html(
|
||||
"""
|
||||
<style>
|
||||
.document-panzoom-host {
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.document-panzoom-surface {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.document-panzoom-media {
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: block;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.document-panzoom-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
pointer-events: none;
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
""",
|
||||
shared=True,
|
||||
)
|
||||
|
||||
|
||||
def _attach_panzoom(host_id: str) -> None:
|
||||
ui.run_javascript(
|
||||
f"""
|
||||
(function() {{
|
||||
if (!window.Panzoom) return;
|
||||
window.__transcriptionPanzoom = window.__transcriptionPanzoom || {{}};
|
||||
const host = document.getElementById({host_id!r});
|
||||
if (!host) return;
|
||||
const target = host.querySelector('[data-panzoom-target]');
|
||||
const media = host.querySelector('[data-panzoom-media]');
|
||||
if (!target) return;
|
||||
|
||||
const computeFitScale = () => {{
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
if (hostRect.width <= 0 || hostRect.height <= 0) return 1;
|
||||
|
||||
if (media && media.tagName === 'IMG') {{
|
||||
if (media.naturalWidth <= 0 || media.naturalHeight <= 0) return 1;
|
||||
return Math.min(
|
||||
hostRect.width / media.naturalWidth,
|
||||
hostRect.height / media.naturalHeight
|
||||
);
|
||||
}}
|
||||
|
||||
if (target.tagName === 'IFRAME') {{
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
if (targetRect.width <= 0 || targetRect.height <= 0) return 1;
|
||||
return hostRect.height / targetRect.height;
|
||||
}}
|
||||
|
||||
return 1;
|
||||
}};
|
||||
|
||||
const initPanzoom = () => {{
|
||||
if (window.__transcriptionPanzoom[{host_id!r}]) {{
|
||||
window.__transcriptionPanzoom[{host_id!r}].destroy();
|
||||
}}
|
||||
|
||||
if (host.__transcriptionWheelHandler) {{
|
||||
host.removeEventListener('wheel', host.__transcriptionWheelHandler);
|
||||
host.__transcriptionWheelHandler = null;
|
||||
}}
|
||||
|
||||
let fitScale = computeFitScale();
|
||||
|
||||
if (!Number.isFinite(fitScale) || fitScale <= 0) {{
|
||||
fitScale = 1;
|
||||
}}
|
||||
|
||||
const startScale = fitScale;
|
||||
const minScale = 0.01;
|
||||
const instance = Panzoom(target, {{
|
||||
maxScale: 32,
|
||||
minScale: minScale,
|
||||
startScale: startScale,
|
||||
step: 0.18,
|
||||
contain: 'inside',
|
||||
roundPixels: true,
|
||||
overflow: 'hidden',
|
||||
}});
|
||||
|
||||
window.__transcriptionPanzoom[{host_id!r}] = instance;
|
||||
|
||||
requestAnimationFrame(() => {{
|
||||
instance.reset({{ animate: false, force: true }});
|
||||
instance.setOptions({{ contain: undefined }});
|
||||
}});
|
||||
|
||||
const wheelHandler = (event) => instance.zoomWithWheel(event);
|
||||
host.__transcriptionWheelHandler = wheelHandler;
|
||||
host.addEventListener('wheel', wheelHandler, {{ passive: false }});
|
||||
}};
|
||||
|
||||
if (media && media.tagName === 'IMG' && !media.complete) {{
|
||||
media.addEventListener('load', initPanzoom, {{ once: true }});
|
||||
return;
|
||||
}}
|
||||
|
||||
if ((media && media.tagName === 'IMG' && (host.clientWidth <= 0 || host.clientHeight <= 0)) ||
|
||||
(target.tagName === 'IFRAME' && (host.clientWidth <= 0 || host.clientHeight <= 0))) {{
|
||||
requestAnimationFrame(initPanzoom);
|
||||
return;
|
||||
}}
|
||||
|
||||
initPanzoom();
|
||||
}})();
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def render_document_panzoom(*, document: Document, height: str = "640px") -> None:
|
||||
"""Render a document preview with pan and zoom interactions."""
|
||||
_register_panzoom_assets()
|
||||
|
||||
host_id = f"document-panzoom-{uuid4().hex}"
|
||||
document_url = _document_url(document)
|
||||
document_kind = _document_kind(document)
|
||||
|
||||
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
|
||||
with ui.row().classes("w-full items-center justify-between q-gutter-sm"):
|
||||
with ui.column().classes("q-gutter-none"):
|
||||
ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
|
||||
ui.label(document.filename).classes("text-caption text-grey-4")
|
||||
|
||||
with ui.button_group().props("flat outline"):
|
||||
ui.button(
|
||||
"Zoom In",
|
||||
icon="add",
|
||||
on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.zoomIn()"),
|
||||
)
|
||||
ui.button(
|
||||
"Zoom Out",
|
||||
icon="remove",
|
||||
on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.zoomOut()"),
|
||||
)
|
||||
ui.button(
|
||||
"Reset",
|
||||
icon="restart_alt",
|
||||
on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.reset()"),
|
||||
)
|
||||
|
||||
ui.label("Scroll, pinch, or use the buttons to inspect the document.").classes(
|
||||
"text-caption text-grey-4 q-mt-sm"
|
||||
)
|
||||
|
||||
with (
|
||||
ui.element("div")
|
||||
.classes("w-full document-panzoom-host bg-blue-grey-9 rounded-borders q-mt-md")
|
||||
.style(f"height: {height};") as host
|
||||
):
|
||||
host.props(f"id={host_id}")
|
||||
with ui.element("div").classes("document-panzoom-surface"):
|
||||
if document_kind == "pdf":
|
||||
ui.html(
|
||||
f'<iframe class="document-panzoom-iframe" '
|
||||
f'src="{document_url}" title="{document.filename}" '
|
||||
"data-panzoom-target></iframe>"
|
||||
)
|
||||
else:
|
||||
ui.html(
|
||||
f'<img class="document-panzoom-media" '
|
||||
f'src="{document_url}" alt="{document.filename}" '
|
||||
"data-panzoom-target data-panzoom-media />"
|
||||
)
|
||||
|
||||
_attach_panzoom(host_id)
|
||||
@@ -2,46 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
from collections.abc import Awaitable
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from collections.abc import Sequence
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.models import Document
|
||||
from transcription.models import Job
|
||||
from transcription.models import Transcript
|
||||
from transcription.models import TranscriptRevision
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RevisionDisplayRow:
|
||||
id: str
|
||||
created: str
|
||||
version: str
|
||||
text: str
|
||||
error_detail: str | None
|
||||
|
||||
|
||||
def _extract_row_id(args: object) -> str | None:
|
||||
if isinstance(args, dict):
|
||||
if isinstance(args.get("row"), dict):
|
||||
row_id = args["row"].get("id")
|
||||
return str(row_id) if row_id is not None else None
|
||||
row_id = args.get("id")
|
||||
return str(row_id) if row_id is not None else None
|
||||
|
||||
if isinstance(args, list):
|
||||
for value in args:
|
||||
if isinstance(value, dict):
|
||||
row_id = value.get("id")
|
||||
if row_id is not None:
|
||||
return str(row_id)
|
||||
|
||||
return None
|
||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||
|
||||
|
||||
def _status_chip_classes(status: str) -> str:
|
||||
@@ -57,229 +25,61 @@ def _status_chip_classes(status: str) -> str:
|
||||
|
||||
|
||||
def _metadata_row(label: str, value: str) -> None:
|
||||
with ui.row().classes("w-full items-start justify-between no-wrap q-gutter-x-md"):
|
||||
ui.label(label).classes("text-caption text-grey-7 text-uppercase")
|
||||
ui.label(value).classes("text-body2 text-right")
|
||||
with ui.row().classes("w-full items-start justify-between q-gutter-x-md"):
|
||||
ui.label(label).classes("text-caption text-grey-5 text-uppercase w-28")
|
||||
ui.label(value).classes("text-body2 text-right text-grey-1 break-all")
|
||||
|
||||
|
||||
def _render_document_section(document: Document) -> None:
|
||||
with ui.card().classes("w-full bg-grey-1 q-pa-md"):
|
||||
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
|
||||
ui.label("Document").classes("text-subtitle1 text-weight-medium")
|
||||
ui.separator().classes("q-my-sm")
|
||||
ui.separator().classes("q-my-sm bg-blue-grey-7")
|
||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||
_metadata_row("Filename", document.filename)
|
||||
_metadata_row("File path", document.file_path)
|
||||
|
||||
|
||||
def _document_data_url(document: Document) -> tuple[str | None, str | None]:
|
||||
path = Path(document.file_path)
|
||||
if not path.exists() or not path.is_file():
|
||||
return None, "Document preview unavailable: file not found"
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
mime_type, _ = mimetypes.guess_type(path.name)
|
||||
if suffix in {".tif", ".tiff"}:
|
||||
mime_type = "image/tiff"
|
||||
if mime_type is None:
|
||||
return None, "Document preview unavailable: unsupported MIME type"
|
||||
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return f"data:{mime_type};base64,{encoded}", None
|
||||
ui.separator().classes("q-my-md bg-blue-grey-7")
|
||||
render_document_panzoom(document=document)
|
||||
|
||||
|
||||
def _render_document_preview(document: Document) -> None:
|
||||
source, error = _document_data_url(document)
|
||||
if error is not None or source is None:
|
||||
ui.label(error or "Document preview unavailable").classes("text-caption text-grey-7")
|
||||
return
|
||||
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("Transcripts").classes("text-subtitle1 text-weight-medium")
|
||||
ui.separator().classes("q-my-sm bg-blue-grey-7")
|
||||
|
||||
suffix = Path(document.file_path).suffix.lower()
|
||||
if suffix == ".pdf":
|
||||
ui.html(
|
||||
(
|
||||
'<iframe title="Document preview" '
|
||||
f'src="{source}" '
|
||||
'style="width:100%;height:520px;border:1px solid #ddd;border-radius:8px;"></iframe>'
|
||||
)
|
||||
)
|
||||
ui.label("Zoom controls are currently available for image files.").classes("text-caption text-grey-7 q-mt-sm")
|
||||
return
|
||||
|
||||
zoom_percent = {"value": 100}
|
||||
|
||||
with ui.element("div").style(
|
||||
"width:100%;height:520px;overflow:auto;border:1px solid #ddd;border-radius:8px;padding:8px;background:#fafafa;"
|
||||
):
|
||||
image = ui.image(source).classes("rounded-borders").style("width:100%;max-width:none;")
|
||||
|
||||
zoom_label = ui.label("Zoom: 100%").classes("text-caption text-grey-7 q-mt-sm")
|
||||
|
||||
def _apply_zoom() -> None:
|
||||
image.style(f"width:{zoom_percent['value']}%;max-width:none;")
|
||||
image.update()
|
||||
zoom_label.text = f"Zoom: {zoom_percent['value']}%"
|
||||
zoom_label.update()
|
||||
|
||||
def _zoom_in() -> None:
|
||||
zoom_percent["value"] = min(300, zoom_percent["value"] + 25)
|
||||
_apply_zoom()
|
||||
|
||||
def _zoom_out() -> None:
|
||||
zoom_percent["value"] = max(50, zoom_percent["value"] - 25)
|
||||
_apply_zoom()
|
||||
|
||||
def _zoom_reset() -> None:
|
||||
zoom_percent["value"] = 100
|
||||
_apply_zoom()
|
||||
|
||||
with ui.row().classes("q-gutter-sm q-mt-xs"):
|
||||
ui.button("-", on_click=_zoom_out)
|
||||
ui.button("+", on_click=_zoom_in)
|
||||
ui.button("Reset", on_click=_zoom_reset)
|
||||
|
||||
|
||||
def _build_display_rows(transcript: Transcript, revisions: list[TranscriptRevision]) -> list[RevisionDisplayRow]:
|
||||
ordered = sorted(revisions, key=lambda revision: revision.version_number)
|
||||
rows: list[RevisionDisplayRow] = []
|
||||
|
||||
if ordered:
|
||||
first = ordered[0]
|
||||
rows.append(
|
||||
RevisionDisplayRow(
|
||||
id="original",
|
||||
created=first.created_at.isoformat(),
|
||||
version="original",
|
||||
text=first.text or "",
|
||||
error_detail=first.error_detail,
|
||||
)
|
||||
)
|
||||
for revision in ordered[1:]:
|
||||
rows.append(
|
||||
RevisionDisplayRow(
|
||||
id=str(revision.version_number),
|
||||
created=revision.created_at.isoformat(),
|
||||
version=str(revision.version_number),
|
||||
text=revision.text or "",
|
||||
error_detail=revision.error_detail,
|
||||
)
|
||||
)
|
||||
else:
|
||||
rows.append(
|
||||
RevisionDisplayRow(
|
||||
id="original",
|
||||
created=transcript.created_at.isoformat(),
|
||||
version="original",
|
||||
text=transcript.text or "",
|
||||
error_detail=transcript.error_detail,
|
||||
)
|
||||
)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _render_transcript_versioned_section(
|
||||
*,
|
||||
document: Document | None,
|
||||
transcript: Transcript | None,
|
||||
revisions: list[TranscriptRevision],
|
||||
on_update: Callable[[str], Awaitable[None]] | None,
|
||||
) -> None:
|
||||
with ui.card().classes("w-full q-pa-md"):
|
||||
ui.label("Transcript").classes("text-subtitle1 text-weight-medium")
|
||||
ui.separator().classes("q-my-sm")
|
||||
|
||||
if transcript is None:
|
||||
ui.label("Transcript not available yet.").classes("text-body2 text-grey-8")
|
||||
if not transcripts:
|
||||
ui.label("Transcript history is not available yet.").classes("text-body2 text-grey-3")
|
||||
return
|
||||
|
||||
for transcript in transcripts:
|
||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||
model_name = transcript.model
|
||||
if model_name is None and revisions:
|
||||
model_name = revisions[0].model
|
||||
_metadata_row("Revision", str(transcript.revision))
|
||||
_metadata_row("Provider", transcript.provider)
|
||||
_metadata_row("Model", model_name or "unknown")
|
||||
_metadata_row("Prompt", transcript.prompt_name)
|
||||
_metadata_row("Created", transcript.created_at.isoformat())
|
||||
|
||||
display_rows = _build_display_rows(transcript, revisions)
|
||||
rows_by_id = {row.id: row for row in display_rows}
|
||||
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")
|
||||
|
||||
ui.separator().classes("q-my-sm")
|
||||
ui.label("Versions").classes("text-subtitle2 text-weight-medium")
|
||||
table = ui.table(
|
||||
columns=[
|
||||
{"name": "created", "label": "Created", "field": "created", "align": "left"},
|
||||
{"name": "version", "label": "Version", "field": "version", "align": "left"},
|
||||
],
|
||||
rows=[
|
||||
{
|
||||
"id": row.id,
|
||||
"created": row.created,
|
||||
"version": row.version,
|
||||
}
|
||||
for row in display_rows
|
||||
],
|
||||
row_key="id",
|
||||
).classes("w-full")
|
||||
|
||||
default_selected = display_rows[-1].id
|
||||
selected_label = ui.label(f"Selected version: {rows_by_id[default_selected].version}").classes(
|
||||
"text-caption text-grey-7"
|
||||
)
|
||||
|
||||
ui.separator().classes("q-my-sm")
|
||||
with ui.row().classes("w-full no-wrap items-start q-gutter-md"):
|
||||
if document is not None:
|
||||
with ui.column().classes("w-1/2"):
|
||||
ui.label("Document Preview").classes("text-subtitle2 text-weight-medium")
|
||||
_render_document_preview(document)
|
||||
|
||||
with ui.column().classes("w-1/2"):
|
||||
editor = (
|
||||
ui.textarea(label="Transcript text", value=rows_by_id[default_selected].text)
|
||||
.props("autogrow outlined")
|
||||
.classes("w-full")
|
||||
)
|
||||
error_label = ui.label("").classes("text-body2 text-red-10")
|
||||
|
||||
def _set_selected(version_id: str) -> None:
|
||||
selected = rows_by_id.get(version_id)
|
||||
if selected is None:
|
||||
return
|
||||
selected_label.text = f"Selected version: {selected.version}"
|
||||
editor.value = selected.text
|
||||
editor.update()
|
||||
error_label.text = selected.error_detail or ""
|
||||
error_label.update()
|
||||
|
||||
def _on_row_click(event) -> None: # noqa: ANN001
|
||||
row_id = _extract_row_id(event.args)
|
||||
if row_id is None:
|
||||
return
|
||||
_set_selected(row_id)
|
||||
|
||||
table.on("rowClick", _on_row_click)
|
||||
_set_selected(default_selected)
|
||||
|
||||
if on_update is not None:
|
||||
ui.button("Update", on_click=lambda: on_update(editor.value or ""))
|
||||
ui.separator().classes("q-my-md bg-blue-grey-7")
|
||||
|
||||
|
||||
def render_job_detail(
|
||||
*,
|
||||
job: Job,
|
||||
document: Document | None,
|
||||
transcript: Transcript | None,
|
||||
revisions: list[TranscriptRevision],
|
||||
on_update: Callable[[str], Awaitable[None]] | None = 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"):
|
||||
with ui.card().classes("w-full q-pa-lg"):
|
||||
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-lg"):
|
||||
with ui.row().classes("w-full items-center justify-between q-gutter-md"):
|
||||
with ui.column().classes("q-gutter-none"):
|
||||
ui.label("Job overview").classes("text-h6 text-weight-bold")
|
||||
ui.label(str(job.id)).classes("text-caption text-grey-7")
|
||||
ui.label(str(job.id)).classes("text-caption text-grey-5")
|
||||
status_chip_classes = (
|
||||
"q-px-sm q-py-xs rounded-borders "
|
||||
"text-weight-medium text-capitalize "
|
||||
@@ -287,7 +87,7 @@ def render_job_detail(
|
||||
)
|
||||
ui.label(status_text).classes(status_chip_classes)
|
||||
|
||||
ui.separator().classes("q-my-md")
|
||||
ui.separator().classes("q-my-md bg-blue-grey-7")
|
||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||
_metadata_row("Created", job.created_at.isoformat())
|
||||
_metadata_row("Updated", job.updated_at.isoformat())
|
||||
@@ -296,9 +96,4 @@ def render_job_detail(
|
||||
if document is not None:
|
||||
_render_document_section(document)
|
||||
|
||||
_render_transcript_versioned_section(
|
||||
document=document,
|
||||
transcript=transcript,
|
||||
revisions=revisions,
|
||||
on_update=on_update,
|
||||
)
|
||||
_render_transcript_section(transcripts)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Common logic for generating table widgets."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from nicegui import events
|
||||
from nicegui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_row_id(args: Any) -> str | None:
|
||||
if isinstance(args, dict):
|
||||
@@ -37,6 +40,7 @@ def _bind_row_click_handler(
|
||||
on_row_click_id(row_id)
|
||||
|
||||
table.on("rowClick", handle_row_click)
|
||||
logger.debug("Row click handler bound to table")
|
||||
|
||||
|
||||
def build_table(
|
||||
@@ -63,6 +67,7 @@ def build_table(
|
||||
.classes(classes)
|
||||
.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:
|
||||
_bind_row_click_handler(table, on_row_click_id=on_row_click_id)
|
||||
return table
|
||||
|
||||
@@ -4,35 +4,31 @@ 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.models import TranscriptRevision
|
||||
from transcription.services import ServiceBundle
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
from transcription.ui.components.error_presenter import summarize_error
|
||||
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.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 ..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 [
|
||||
|
||||
def register_page() -> None:
|
||||
"""Register jobs list and detail routes."""
|
||||
|
||||
@ui.page("/jobs")
|
||||
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.refreshable
|
||||
async def render_table() -> None:
|
||||
jobs = [
|
||||
JobTableRow(
|
||||
id=job.id,
|
||||
status=job.status.value,
|
||||
@@ -41,58 +37,20 @@ async def fetch_job_rows() -> list[JobTableRow]:
|
||||
created_at=job.created_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, list[TranscriptRevision]]:
|
||||
"""Return job, document, transcript snapshot, and revisions 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()
|
||||
revisions = (
|
||||
await session.exec(
|
||||
select(TranscriptRevision)
|
||||
.where(TranscriptRevision.job_id == job.id)
|
||||
.order_by(TranscriptRevision.version_number)
|
||||
)
|
||||
).all()
|
||||
return job, document, transcript, list(revisions)
|
||||
|
||||
|
||||
def register_page() -> None:
|
||||
"""Register jobs list and detail routes."""
|
||||
|
||||
@ui.page("/jobs")
|
||||
async def jobs_page() -> None:
|
||||
ui.label("Transcription Jobs")
|
||||
status = ui.label("Ready")
|
||||
|
||||
@ui.refreshable
|
||||
async def render_table() -> None:
|
||||
jobs = await fetch_job_rows()
|
||||
render_jobs_table(jobs)
|
||||
|
||||
async def refresh() -> None:
|
||||
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)
|
||||
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
|
||||
await render_table()
|
||||
ui.link("Back to upload", "/upload")
|
||||
|
||||
@ui.page("/jobs/{job_id}")
|
||||
async def job_detail_page(job_id: str) -> None:
|
||||
ui.label("Job Detail")
|
||||
content = ui.column().classes("w-full")
|
||||
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.button(icon="arrow_back", on_click=ui.navigate.back)
|
||||
try:
|
||||
parsed_id = UUID(job_id)
|
||||
except ValueError:
|
||||
@@ -100,64 +58,14 @@ def register_page() -> None:
|
||||
ui.link("Back to jobs", "/jobs")
|
||||
return
|
||||
|
||||
async def refresh_content() -> None:
|
||||
content.clear()
|
||||
job, document, transcript, revisions = await fetch_job_detail(parsed_id)
|
||||
if job is None:
|
||||
with content:
|
||||
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
|
||||
|
||||
services = ServiceBundle()
|
||||
|
||||
async def update_transcript_text(value: str) -> None:
|
||||
try:
|
||||
update_text = value.strip()
|
||||
async with get_session() as session:
|
||||
current_transcript = (
|
||||
await session.exec(select(Transcript).where(Transcript.job_id == parsed_id))
|
||||
).first()
|
||||
provider_name = current_transcript.provider if current_transcript is not None else "openrouter"
|
||||
prompt_name = (
|
||||
current_transcript.prompt_name if current_transcript is not None else "transcribe_document.md"
|
||||
)
|
||||
model_name = current_transcript.model if current_transcript is not None else None
|
||||
|
||||
await services.transcriptions.upsert_transcript_by_job(
|
||||
job_id=parsed_id,
|
||||
text=update_text,
|
||||
error_detail=None,
|
||||
provider=provider_name,
|
||||
prompt_name=prompt_name,
|
||||
model=model_name,
|
||||
session=session,
|
||||
)
|
||||
await services.transcriptions.append_transcript_revision(
|
||||
job_id=parsed_id,
|
||||
text=update_text,
|
||||
error_detail=None,
|
||||
provider=provider_name,
|
||||
prompt_name=prompt_name,
|
||||
model=model_name,
|
||||
source="user",
|
||||
session=session,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
ui.notify("Transcript updated", type="positive")
|
||||
await refresh_content()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Transcript update failed", operation="jobs.detail.update")
|
||||
|
||||
with content:
|
||||
render_job_detail(
|
||||
job=job,
|
||||
document=document,
|
||||
transcript=transcript,
|
||||
revisions=revisions,
|
||||
on_update=update_transcript_text,
|
||||
)
|
||||
|
||||
await refresh_content()
|
||||
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")
|
||||
|
||||
@@ -8,6 +8,7 @@ from nicegui import ui
|
||||
from transcription.app_state import resolve_session_factory
|
||||
from transcription.db import get_session
|
||||
from transcription.services.store import create_upload_job
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.upload import render_upload_widget
|
||||
from transcription.worker import resolve_worker_notifier
|
||||
|
||||
@@ -17,6 +18,7 @@ def register_page() -> None:
|
||||
|
||||
@ui.page("/upload", title="Upload Document")
|
||||
def upload_page(request: Request) -> None:
|
||||
render_navigation_header(current_path="/upload")
|
||||
session_factory = resolve_session_factory(request.app.state)
|
||||
|
||||
async def submit_upload(filename: str, file_bytes: bytes):
|
||||
@@ -29,6 +31,3 @@ def register_page() -> None:
|
||||
|
||||
notify_worker = resolve_worker_notifier(request.app.state)
|
||||
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
|
||||
|
||||
with ui.row():
|
||||
ui.link("View jobs", "/jobs")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
:root {
|
||||
/* Soft blue-night palette tokens */
|
||||
--ctp-rosewater: #f2dde5;
|
||||
--ctp-flamingo: #edcfd8;
|
||||
--ctp-pink: #dcc7de;
|
||||
--ctp-mauve: #a9bde5;
|
||||
--ctp-red: #d98a9a;
|
||||
--ctp-maroon: #d39aa5;
|
||||
--ctp-peach: #d7af8c;
|
||||
--ctp-yellow: #e2c083;
|
||||
--ctp-green: #86c8ad;
|
||||
--ctp-teal: #77bfbe;
|
||||
--ctp-sky: #7ebdda;
|
||||
--ctp-sapphire: #74aed0;
|
||||
--ctp-blue: #92b5f5;
|
||||
--ctp-lavender: #6f97e8;
|
||||
--ctp-text: #d8e2f5;
|
||||
--ctp-subtext1: #bfcae0;
|
||||
--ctp-subtext0: #a9b6cf;
|
||||
--ctp-overlay2: #95a3bf;
|
||||
--ctp-overlay1: #7c8ca9;
|
||||
--ctp-overlay0: #657490;
|
||||
--ctp-surface2: #4d5f7c;
|
||||
--ctp-surface1: #394a65;
|
||||
--ctp-surface0: #2a3954;
|
||||
--ctp-base: #1f2b42;
|
||||
--ctp-mantle: #1a2538;
|
||||
--ctp-crust: #141e30;
|
||||
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import pytest
|
||||
from sqlmodel import select
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.models import Job, JobStatus, Transcript, TranscriptRevision
|
||||
from transcription.models import Job, JobStatus, Transcript
|
||||
from transcription.providers.base import TranscriptionResult
|
||||
from transcription.services.store import create_upload_job
|
||||
from transcription.services.upload import create_upload_job
|
||||
from transcription.worker import process_next_queued_job
|
||||
|
||||
|
||||
@@ -16,37 +16,24 @@ from transcription.worker import process_next_queued_job
|
||||
class TestPipelineSuccessFlow:
|
||||
"""Verify end-to-end success lifecycle behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_then_worker_persists_transcribed_terminal_state(self, async_session, tmp_path: Path, monkeypatch):
|
||||
def test_upload_then_worker_persists_transcribed_terminal_state(self, session, tmp_path: Path, monkeypatch):
|
||||
"""Upload followed by worker processing persists transcript and transcribed status."""
|
||||
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
||||
upload_result = await create_upload_job(
|
||||
upload_result = create_upload_job(
|
||||
filename="pipeline.jpg",
|
||||
file_bytes=b"pipeline-bytes",
|
||||
session=async_session,
|
||||
session=session,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
async def _fake_transcribe(_path: str) -> TranscriptionResult:
|
||||
return TranscriptionResult(
|
||||
text="Pipeline transcript",
|
||||
provider="openrouter",
|
||||
prompt_name="transcribe_document.md",
|
||||
model="test-model",
|
||||
)
|
||||
def _fake_transcribe(_path: str) -> TranscriptionResult:
|
||||
return TranscriptionResult(text="Pipeline transcript", provider="openrouter", model="test-model")
|
||||
|
||||
monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _fake_transcribe)
|
||||
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
|
||||
|
||||
processed = await process_next_queued_job(session=async_session)
|
||||
job = await async_session.get(Job, upload_result.job_id)
|
||||
transcript = (await async_session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id))).first()
|
||||
revisions = (
|
||||
await async_session.exec(
|
||||
select(TranscriptRevision)
|
||||
.where(TranscriptRevision.job_id == upload_result.job_id)
|
||||
.order_by(TranscriptRevision.version_number)
|
||||
)
|
||||
).all()
|
||||
processed = process_next_queued_job(session=session)
|
||||
job = session.get(Job, upload_result.job_id)
|
||||
transcript = session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id)).first()
|
||||
|
||||
assert processed is True
|
||||
assert job is not None
|
||||
@@ -54,43 +41,30 @@ class TestPipelineSuccessFlow:
|
||||
assert transcript is not None
|
||||
assert transcript.text == "Pipeline transcript"
|
||||
assert transcript.error_detail is None
|
||||
assert transcript.model == "test-model"
|
||||
assert len(revisions) == 1
|
||||
assert revisions[0].version_number == 1
|
||||
assert revisions[0].source == "ai"
|
||||
assert revisions[0].text == "Pipeline transcript"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestPipelineFailureFlow:
|
||||
"""Verify end-to-end failure lifecycle behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_then_worker_persists_failed_terminal_state(self, async_session, tmp_path: Path, monkeypatch):
|
||||
def test_upload_then_worker_persists_failed_terminal_state(self, session, tmp_path: Path, monkeypatch):
|
||||
"""Upload followed by worker processing persists error detail and failed status."""
|
||||
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
||||
upload_result = await create_upload_job(
|
||||
upload_result = create_upload_job(
|
||||
filename="pipeline.jpg",
|
||||
file_bytes=b"pipeline-bytes",
|
||||
session=async_session,
|
||||
session=session,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
async def _fake_transcribe(_path: str) -> TranscriptionResult:
|
||||
def _fake_transcribe(_path: str) -> TranscriptionResult:
|
||||
raise RuntimeError("pipeline provider failure")
|
||||
|
||||
monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _fake_transcribe)
|
||||
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
|
||||
|
||||
processed = await process_next_queued_job(session=async_session)
|
||||
job = await async_session.get(Job, upload_result.job_id)
|
||||
transcript = (await async_session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id))).first()
|
||||
revisions = (
|
||||
await async_session.exec(
|
||||
select(TranscriptRevision)
|
||||
.where(TranscriptRevision.job_id == upload_result.job_id)
|
||||
.order_by(TranscriptRevision.version_number)
|
||||
)
|
||||
).all()
|
||||
processed = process_next_queued_job(session=session)
|
||||
job = session.get(Job, upload_result.job_id)
|
||||
transcript = session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id)).first()
|
||||
|
||||
assert processed is True
|
||||
assert job is not None
|
||||
@@ -100,8 +74,3 @@ class TestPipelineFailureFlow:
|
||||
assert "pipeline provider failure" in transcript.error_detail
|
||||
assert "[internal_unexpected_error]" in transcript.error_detail
|
||||
assert "error_id=" in transcript.error_detail
|
||||
assert len(revisions) == 1
|
||||
assert revisions[0].version_number == 1
|
||||
assert revisions[0].source == "ai"
|
||||
assert revisions[0].text is None
|
||||
assert "pipeline provider failure" in (revisions[0].error_detail or "")
|
||||
|
||||
@@ -1,71 +1,96 @@
|
||||
"""Tests for transcription.db — async schema bootstrap/runtime behavior."""
|
||||
"""Tests for transcription.db — schema bootstrap and session factory."""
|
||||
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy import text
|
||||
import pytest
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
from sqlmodel.pool import StaticPool
|
||||
|
||||
|
||||
def _in_memory_engine():
|
||||
"""Create a fresh in-memory SQLite engine for isolated db tests."""
|
||||
return create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
|
||||
|
||||
class TestSchemaBootstrap:
|
||||
"""Verify async create_all produces the expected table set."""
|
||||
"""Verify create_all produces the expected table set."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_all_creates_expected_tables(self, default_settings):
|
||||
"""After async create_all(), document/job/transcript/revision tables exist."""
|
||||
# Ensure models are imported so metadata is populated.
|
||||
from transcription.models import Document, Job, Transcript, TranscriptRevision # noqa: F401
|
||||
def test_create_all_creates_expected_tables(self):
|
||||
"""After create_all(), document, job, and transcript tables exist."""
|
||||
engine = _in_memory_engine()
|
||||
# Ensure models are imported so metadata is populated
|
||||
from transcription.models import Document, Job, Transcript # noqa: F401
|
||||
|
||||
from transcription.db.operations import create_all
|
||||
from transcription.db.runtime import get_engine
|
||||
import transcription.db as db_module
|
||||
|
||||
engine = get_engine(settings=default_settings)
|
||||
await create_all(engine=engine)
|
||||
|
||||
async with engine.begin() as connection:
|
||||
table_names = set(await connection.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names()))
|
||||
db_module.create_all(engine=engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
table_names = set(inspector.get_table_names())
|
||||
assert "document" in table_names
|
||||
assert "job" in table_names
|
||||
assert "transcript" in table_names
|
||||
assert "transcriptrevision" in table_names
|
||||
|
||||
|
||||
class TestSessionFactory:
|
||||
"""Verify async get_session yields a usable AsyncSession."""
|
||||
"""Verify get_session yields and cleans up sessions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_yields_session(self, default_settings):
|
||||
"""get_session() yields an AsyncSession with a live connection."""
|
||||
from transcription.db.runtime import get_session
|
||||
def test_get_session_yields_session(self):
|
||||
"""get_session() yields a usable Session object."""
|
||||
engine = _in_memory_engine()
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
async with get_session(settings=default_settings) as session:
|
||||
result = await session.exec(text("SELECT 1"))
|
||||
assert result.first()[0] == 1
|
||||
import transcription.db as db_module
|
||||
|
||||
with db_module.get_session(engine=engine) as session:
|
||||
assert isinstance(session, Session)
|
||||
|
||||
def test_session_is_closed_after_generator_exit(self):
|
||||
"""After the context manager exits, the session is closed."""
|
||||
engine = _in_memory_engine()
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
import transcription.db as db_module
|
||||
|
||||
with db_module.get_session(engine=engine) as session:
|
||||
# Session is usable inside the context
|
||||
session.execute(text("SELECT 1"))
|
||||
captured = session
|
||||
|
||||
# After exiting, the session's internal connection is released
|
||||
# (no active transaction bound to the session)
|
||||
assert captured._transaction is None
|
||||
|
||||
|
||||
class TestBootstrapPolicy:
|
||||
"""Verify startup schema bootstrap policy via Settings property."""
|
||||
"""Verify schema bootstrap policy defaults and overrides."""
|
||||
|
||||
def test_production_defaults_to_no_bootstrap(self):
|
||||
"""Production defaults to explicit non-bootstrap startup behavior."""
|
||||
from transcription.config import Settings
|
||||
from transcription.db import should_bootstrap_schema
|
||||
|
||||
settings = Settings(openrouter_api_key="test-key", environment="production")
|
||||
assert settings.should_bootstrap_schema is False
|
||||
assert should_bootstrap_schema(settings) is False
|
||||
|
||||
def test_development_defaults_to_bootstrap(self):
|
||||
"""Development defaults to schema bootstrap for local workflows."""
|
||||
from transcription.config import Settings
|
||||
from transcription.db import should_bootstrap_schema
|
||||
|
||||
settings = Settings(openrouter_api_key="test-key", environment="development")
|
||||
assert settings.should_bootstrap_schema is True
|
||||
assert should_bootstrap_schema(settings) is True
|
||||
|
||||
def test_explicit_override_wins(self):
|
||||
"""Explicit bootstrap_schema_on_startup overrides environment default."""
|
||||
from transcription.config import Settings
|
||||
from transcription.db import should_bootstrap_schema
|
||||
|
||||
settings = Settings(
|
||||
openrouter_api_key="test-key",
|
||||
environment="production",
|
||||
bootstrap_schema_on_startup=True,
|
||||
)
|
||||
assert settings.should_bootstrap_schema is True
|
||||
assert should_bootstrap_schema(settings) is True
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Tests for transcription.models — Document, Job, Transcript, TranscriptRevision models."""
|
||||
"""Tests for transcription.models — Document, Job, Transcript persistence and relationships."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
|
||||
from transcription.models import Document, Job, JobStatus, Transcript
|
||||
|
||||
|
||||
def _make_document(**overrides) -> Document:
|
||||
@@ -113,7 +113,7 @@ class TestTranscriptModel:
|
||||
"""A Transcript with text set and error_detail None persists correctly."""
|
||||
doc = _persist_document(session)
|
||||
job = _persist_job(session, doc)
|
||||
transcript = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="Dear Sir, ...")
|
||||
transcript = Transcript(job_id=job.id, text="Dear Sir, ...")
|
||||
session.add(transcript)
|
||||
session.commit()
|
||||
session.refresh(transcript)
|
||||
@@ -127,12 +127,7 @@ class TestTranscriptModel:
|
||||
"""A Transcript with text None and error_detail set persists correctly."""
|
||||
doc = _persist_document(session)
|
||||
job = _persist_job(session, doc)
|
||||
transcript = Transcript(
|
||||
job_id=job.id,
|
||||
provider="openrouter",
|
||||
prompt_name="transcribe_document.md",
|
||||
error_detail="Provider timeout",
|
||||
)
|
||||
transcript = Transcript(job_id=job.id, error_detail="Provider timeout")
|
||||
session.add(transcript)
|
||||
session.commit()
|
||||
session.refresh(transcript)
|
||||
@@ -147,101 +142,16 @@ class TestTranscriptModel:
|
||||
doc = _persist_document(session)
|
||||
job = _persist_job(session, doc)
|
||||
|
||||
t1 = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="First")
|
||||
t1 = Transcript(job_id=job.id, text="First")
|
||||
session.add(t1)
|
||||
session.commit()
|
||||
|
||||
t2 = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="Duplicate")
|
||||
t2 = Transcript(job_id=job.id, text="Duplicate")
|
||||
session.add(t2)
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
|
||||
|
||||
class TestTranscriptRevisionModel:
|
||||
"""Verify TranscriptRevision persistence and version uniqueness constraints."""
|
||||
|
||||
def test_revision_record_persists(self, session):
|
||||
"""A TranscriptRevision with version metadata persists correctly."""
|
||||
doc = _persist_document(session)
|
||||
job = _persist_job(session, doc)
|
||||
revision = TranscriptRevision(
|
||||
job_id=job.id,
|
||||
version_number=1,
|
||||
provider="openrouter",
|
||||
prompt_name="transcribe_document.md",
|
||||
model="google/gemini-2.5-flash",
|
||||
source="ai",
|
||||
text="Initial text",
|
||||
)
|
||||
session.add(revision)
|
||||
session.commit()
|
||||
session.refresh(revision)
|
||||
|
||||
fetched = session.get(TranscriptRevision, revision.id)
|
||||
assert fetched is not None
|
||||
assert fetched.version_number == 1
|
||||
assert fetched.text == "Initial text"
|
||||
assert fetched.source == "ai"
|
||||
|
||||
def test_job_version_pair_is_unique(self, session):
|
||||
"""Duplicate version_number for same job raises integrity error."""
|
||||
doc = _persist_document(session)
|
||||
job = _persist_job(session, doc)
|
||||
|
||||
first = TranscriptRevision(
|
||||
job_id=job.id,
|
||||
version_number=1,
|
||||
provider="openrouter",
|
||||
prompt_name="transcribe_document.md",
|
||||
source="ai",
|
||||
text="Initial",
|
||||
)
|
||||
duplicate = TranscriptRevision(
|
||||
job_id=job.id,
|
||||
version_number=1,
|
||||
provider="openrouter",
|
||||
prompt_name="transcribe_document.md",
|
||||
source="user",
|
||||
text="Edited",
|
||||
)
|
||||
session.add(first)
|
||||
session.commit()
|
||||
|
||||
session.add(duplicate)
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
|
||||
def test_same_version_number_allowed_for_different_jobs(self, session):
|
||||
"""Version numbers are scoped per job, not globally."""
|
||||
doc1 = _persist_document(session)
|
||||
job1 = _persist_job(session, doc1)
|
||||
doc2 = _make_document(filename="letter2.jpg", file_path="/uploads/letter2.jpg")
|
||||
session.add(doc2)
|
||||
session.commit()
|
||||
session.refresh(doc2)
|
||||
job2 = _persist_job(session, doc2)
|
||||
|
||||
r1 = TranscriptRevision(
|
||||
job_id=job1.id,
|
||||
version_number=1,
|
||||
provider="openrouter",
|
||||
prompt_name="transcribe_document.md",
|
||||
source="ai",
|
||||
text="Job1 v1",
|
||||
)
|
||||
r2 = TranscriptRevision(
|
||||
job_id=job2.id,
|
||||
version_number=1,
|
||||
provider="openrouter",
|
||||
prompt_name="transcribe_document.md",
|
||||
source="ai",
|
||||
text="Job2 v1",
|
||||
)
|
||||
session.add(r1)
|
||||
session.add(r2)
|
||||
session.commit()
|
||||
|
||||
|
||||
class TestRelationships:
|
||||
"""Verify SQLModel relationship navigation between models."""
|
||||
|
||||
@@ -259,12 +169,7 @@ class TestRelationships:
|
||||
"""job.transcript returns the linked Transcript."""
|
||||
doc = _persist_document(session)
|
||||
job = _persist_job(session, doc)
|
||||
transcript = Transcript(
|
||||
job_id=job.id,
|
||||
provider="openrouter",
|
||||
prompt_name="transcribe_document.md",
|
||||
text="Transcribed text",
|
||||
)
|
||||
transcript = Transcript(job_id=job.id, text="Transcribed text")
|
||||
session.add(transcript)
|
||||
session.commit()
|
||||
|
||||
@@ -272,32 +177,3 @@ class TestRelationships:
|
||||
assert job.transcript is not None
|
||||
assert isinstance(job.transcript, Transcript)
|
||||
assert job.transcript.text == "Transcribed text"
|
||||
|
||||
def test_job_exposes_transcript_revisions(self, session):
|
||||
"""job.transcript_revisions returns all linked revisions."""
|
||||
doc = _persist_document(session)
|
||||
job = _persist_job(session, doc)
|
||||
session.add(
|
||||
TranscriptRevision(
|
||||
job_id=job.id,
|
||||
version_number=1,
|
||||
provider="openrouter",
|
||||
prompt_name="transcribe_document.md",
|
||||
source="ai",
|
||||
text="v1",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
TranscriptRevision(
|
||||
job_id=job.id,
|
||||
version_number=2,
|
||||
provider="openrouter",
|
||||
prompt_name="transcribe_document.md",
|
||||
source="user",
|
||||
text="v2",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
session.refresh(job)
|
||||
assert len(job.transcript_revisions) == 2
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Shared fixtures for UI integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from transcription.app import create_app
|
||||
from transcription.config import Settings
|
||||
from transcription.config import _settings
|
||||
from transcription.db import get_session
|
||||
from transcription.models import Document
|
||||
from transcription.models import Job
|
||||
from transcription.models import JobStatus
|
||||
from transcription.models import Transcript
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_client(tmp_path: Path) -> tuple[FastAPI, TestClient]:
|
||||
"""Provide a real application and test client backed by in-memory SQLite."""
|
||||
settings = Settings(
|
||||
openrouter_api_key="test-key",
|
||||
database_url="sqlite:///:memory:",
|
||||
environment="test",
|
||||
upload_dir=tmp_path / "uploads",
|
||||
prompt_dir=tmp_path / "prompts",
|
||||
)
|
||||
_settings.set(settings)
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
yield app, client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
||||
"""Return a helper for inserting a document/job/transcript trio."""
|
||||
app, _ = app_client
|
||||
|
||||
def _seed(
|
||||
*,
|
||||
filename: str = "sample.pdf",
|
||||
status: JobStatus = JobStatus.TRANSCRIBED,
|
||||
transcript_text: str | None = "Sample transcript text",
|
||||
error_detail: str | None = None,
|
||||
) -> UUID:
|
||||
async def _insert() -> UUID:
|
||||
async with get_session(session_factory=app.state.runtime.session_factory) as session:
|
||||
document = Document(filename=filename, file_path=f"uploads/{filename}")
|
||||
session.add(document)
|
||||
await session.flush()
|
||||
|
||||
job = Job(document_id=document.id, status=status, retry_count=0)
|
||||
session.add(job)
|
||||
await session.flush()
|
||||
|
||||
if transcript_text is not None or error_detail is not None:
|
||||
session.add(
|
||||
Transcript(
|
||||
job_id=job.id,
|
||||
provider="openrouter",
|
||||
prompt_name="transcribe_document",
|
||||
text=transcript_text,
|
||||
error_detail=error_detail,
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
return job.id
|
||||
|
||||
return asyncio.run(_insert())
|
||||
|
||||
return _seed
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Tests for the jobs page route."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from transcription.ui import register_pages
|
||||
from transcription.ui.pages import jobs_page
|
||||
from transcription.ui.pages.jobs_page import JobTableRow
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -13,7 +16,16 @@ def client(monkeypatch):
|
||||
"""Provide a minimal app client with jobs data patched for rendering."""
|
||||
|
||||
async def _fetch_jobs_stub():
|
||||
return []
|
||||
return [
|
||||
JobTableRow(
|
||||
id=UUID("00000000-0000-0000-0000-000000000001"),
|
||||
status="queued",
|
||||
filename="sample.pdf",
|
||||
retry_count=2,
|
||||
created_at="2026-01-01T12:00:00+00:00",
|
||||
updated_at="2026-01-01T12:01:00+00:00",
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(jobs_page, "fetch_jobs", _fetch_jobs_stub)
|
||||
|
||||
@@ -27,11 +39,15 @@ def client(monkeypatch):
|
||||
class TestPageRendering:
|
||||
"""Verify the jobs page is available and includes the main controls."""
|
||||
|
||||
def test_jobs_page_renders_expected_controls(self, client):
|
||||
def test_jobs_page_renders_expected_controls(self, client, monkeypatch):
|
||||
"""GET /ui/jobs returns the page shell and jobs controls."""
|
||||
response = client.get("/ui/jobs")
|
||||
|
||||
async def _fetch_jobs_empty():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(jobs_page, "fetch_jobs", _fetch_jobs_empty)
|
||||
response = client.get("/ui/jobs")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Transcription Jobs" in response.text
|
||||
assert "Refresh" in response.text
|
||||
assert "Back to upload" in response.text
|
||||
assert "No jobs yet." in response.text
|
||||
|
||||
@@ -25,8 +25,9 @@ class TestPageRegistration:
|
||||
*,
|
||||
mount_path: str,
|
||||
show_welcome_message: bool,
|
||||
dark: bool,
|
||||
) -> None:
|
||||
calls.append(f"run_with:{mount_path}:{show_welcome_message}")
|
||||
calls.append(f"run_with:{mount_path}:{show_welcome_message}:{dark}")
|
||||
|
||||
monkeypatch.setattr("transcription.ui.register_upload_page", _record_upload)
|
||||
monkeypatch.setattr("transcription.ui.register_jobs_page", _record_jobs)
|
||||
@@ -35,4 +36,4 @@ class TestPageRegistration:
|
||||
app = FastAPI()
|
||||
register_pages(app)
|
||||
|
||||
assert calls == ["upload", "jobs", "run_with:/ui:False"]
|
||||
assert calls == ["upload", "jobs", "run_with:/ui:False:True"]
|
||||
|
||||
@@ -52,4 +52,4 @@ class TestPageRendering:
|
||||
assert response.status_code == 200
|
||||
assert "Upload Document" in response.text
|
||||
assert "Select document file" in response.text
|
||||
assert "View jobs" in response.text
|
||||
assert "Jobs" in response.text
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 969 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1010 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 6.0 MiB |