11 Commits
Author SHA1 Message Date
Jim Lancaster 761765636a Update job detail page to track revisions and display document image next to transcription text. 2026-06-28 18:02:19 -05:00
John Lancaster e2e421835f job_detail 2026-06-28 14:40:58 -05:00
John Lancaster 57c1d22bb9 jobs table 2026-06-28 14:36:14 -05:00
John Lancaster dba96e7a72 ui redirects 2026-06-28 14:19:03 -05:00
John Lancaster 912cfd44de fixture updates 2026-06-28 13:55:02 -05:00
John Lancaster 8d5fee886f pruned jobs page 2026-06-28 13:44:49 -05:00
John Lancaster 34468c521f debug tweaks 2026-06-28 13:42:44 -05:00
John Lancaster b682e092ea test_upload_page 2026-06-28 13:42:05 -05:00
John Lancaster bd33e338b3 worker runtime fixes 2026-06-28 13:41:54 -05:00
John Lancaster 57a988973f debug config 2026-06-28 13:25:52 -05:00
John Lancaster 9ad67d55e8 worker lifespan updates 2026-06-28 13:17:02 -05:00
33 changed files with 1272 additions and 369 deletions
+27
View File
@@ -0,0 +1,27 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Debug transcription app",
"type": "debugpy",
"request": "launch",
"module": "debugpy",
"args": [
"-m",
"uvicorn",
"transcription.app:create_app",
"--factory",
"--host",
// "127.0.0.1",
"0.0.0.0",
"--port",
"8080"
],
"justMyCode": true,
"console": "integratedTerminal",
"env": {
"PYTHONPATH": "${workspaceFolder}/src"
}
}
]
}
+6 -2
View File
@@ -10,6 +10,7 @@ The app lets you upload a document image/PDF, queues a background transcription
- Persist document + job records in SQLite - Persist document + job records in SQLite
- Process jobs in a background worker (`queued -> processing -> transcribed/failed`) - Process jobs in a background worker (`queued -> processing -> transcribed/failed`)
- Store transcript text (or failure detail) - Store transcript text (or failure detail)
- Track transcript revisions (AI-generated and manual updates)
- Show status and results in the NiceGUI interface - Show status and results in the NiceGUI interface
## Quick start ## Quick start
@@ -61,8 +62,11 @@ uv run uvicorn transcription.app:create_app --factory --reload
- **Job detail page** (`/ui/jobs/{job_id}`) - **Job detail page** (`/ui/jobs/{job_id}`)
- Shows job metadata and status. - Shows job metadata and status.
- Displays transcript text when successful. - Shows transcript metadata, including provider and model.
- Displays failure detail when transcription fails. - 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.
## Prompt artifacts ## Prompt artifacts
+23 -43
View File
@@ -2,11 +2,12 @@
from __future__ import annotations from __future__ import annotations
import asyncio from contextlib import AsyncExitStack
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from contextlib import suppress
from fastapi import FastAPI from fastapi import FastAPI
from fastapi import status
from fastapi.responses import RedirectResponse
from .api.errors import register_error_handlers from .api.errors import register_error_handlers
from .api.health import router as health_router from .api.health import router as health_router
@@ -17,42 +18,7 @@ from .db import dispose_database_runtime
from .db import initialize_database_runtime from .db import initialize_database_runtime
from .services import ServiceBundle from .services import ServiceBundle
from .ui import register_pages from .ui import register_pages
from .worker import run_worker_loop from .worker import worker_consumer_lifespan
def _start_worker(app: FastAPI) -> None:
stop_event = asyncio.Event()
wake_queue: asyncio.Queue[None] = asyncio.Queue()
worker_task = asyncio.create_task(
run_worker_loop(
session_factory=app.state.runtime.session_factory,
stop_event=stop_event,
wake_queue=wake_queue,
poll_interval_seconds=1.0,
)
)
wake_queue.put_nowait(None)
app.state.worker_stop_event = stop_event
app.state.worker_wake_queue = wake_queue
app.state.worker_task = worker_task
async def _stop_worker(app: FastAPI) -> None:
stop_event = getattr(app.state, "worker_stop_event", None)
wake_queue = getattr(app.state, "worker_wake_queue", None)
worker_task = getattr(app.state, "worker_task", None)
if stop_event is not None:
stop_event.set()
if wake_queue is not None:
wake_queue.put_nowait(None)
if worker_task is not None:
try:
await asyncio.wait_for(worker_task, timeout=2.0)
except TimeoutError:
worker_task.cancel()
with suppress(asyncio.CancelledError):
await worker_task
@asynccontextmanager @asynccontextmanager
@@ -70,17 +36,31 @@ async def _lifespan(app: FastAPI):
settings.upload_dir.mkdir(parents=True, exist_ok=True) settings.upload_dir.mkdir(parents=True, exist_ok=True)
settings.prompt_dir.mkdir(parents=True, exist_ok=True) settings.prompt_dir.mkdir(parents=True, exist_ok=True)
_start_worker(app) async with AsyncExitStack() as stack:
try: stack.push_async_callback(dispose_database_runtime)
stop_event, worker_notifier = await stack.enter_async_context(
worker_consumer_lifespan(
session_factory=app.state.runtime.session_factory,
poll_interval_seconds=1.0,
)
)
app.state.worker_stop_event = stop_event
app.state.worker_notifier = worker_notifier
yield yield
finally:
await _stop_worker(app)
await dispose_database_runtime()
def create_app() -> FastAPI: def create_app() -> FastAPI:
"""Create and configure the FastAPI application.""" """Create and configure the FastAPI application."""
app = FastAPI(title="Transcription", lifespan=_lifespan) app = FastAPI(title="Transcription", lifespan=_lifespan)
@app.get("/", include_in_schema=False)
async def root_redirect() -> RedirectResponse:
return RedirectResponse(url="/ui", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
@app.get("/ui", include_in_schema=False)
async def ui_redirect() -> RedirectResponse:
return RedirectResponse(url="/ui/upload", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
register_error_handlers(app) register_error_handlers(app)
register_pages(app) register_pages(app)
app.include_router(health_router) app.include_router(health_router)
+39
View File
@@ -0,0 +1,39 @@
"""Helpers for accessing lifespan-owned application state resources."""
from __future__ import annotations
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.db.runtime import DatabaseRuntime
from transcription.db.runtime import get_session_factory
from transcription.worker import WorkerNotifier
from transcription.worker import resolve_worker_notifier
def resolve_database_runtime(state: object) -> DatabaseRuntime | None:
"""Return database runtime from app-like state objects when available."""
runtime = getattr(state, "runtime", None)
return runtime if isinstance(runtime, DatabaseRuntime) else None
def require_database_runtime(state: object) -> DatabaseRuntime:
"""Return database runtime or raise when app lifespan has not initialized it."""
runtime = resolve_database_runtime(state)
if runtime is None:
raise RuntimeError("Database runtime is not initialized on application state")
return runtime
def resolve_session_factory(state: object) -> async_sessionmaker[AsyncSession]:
"""Return DB session factory from state when available, otherwise shared runtime."""
runtime = resolve_database_runtime(state)
if runtime is not None:
return runtime.session_factory
return get_session_factory()
def get_worker_notifier(app: FastAPI) -> WorkerNotifier:
"""Return app worker notifier, or a no-op fallback when unavailable."""
return resolve_worker_notifier(app.state)
+6
View File
@@ -58,3 +58,9 @@ def _ensure_sqlite_compat_columns(connection: Connection) -> None:
if "retry_count" not in columns: if "retry_count" not in columns:
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0")) 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") 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")
+28 -2
View File
@@ -1,7 +1,9 @@
"""SQLModel domain models for the transcription system. """SQLModel domain models for the transcription system.
Three models capture the MVP lifecycle: Core models capture the MVP lifecycle:
Document -> one-to-many -> Job -> one-to-one -> Transcript Document (1) -> (many) Job
Job (1) -> (1) Transcript
Job (1) -> (many) TranscriptRevision
""" """
from datetime import UTC from datetime import UTC
@@ -11,6 +13,7 @@ from typing import Optional
from uuid import UUID from uuid import UUID
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import UniqueConstraint
from sqlmodel import Field from sqlmodel import Field
from sqlmodel import Relationship from sqlmodel import Relationship
from sqlmodel import SQLModel from sqlmodel import SQLModel
@@ -48,6 +51,7 @@ class Job(SQLModel, table=True):
# --- relationships --- # --- relationships ---
document: Document = Relationship(back_populates="jobs") document: Document = Relationship(back_populates="jobs")
transcript: Optional["Transcript"] = Relationship(back_populates="job") transcript: Optional["Transcript"] = Relationship(back_populates="job")
transcript_revisions: list["TranscriptRevision"] = Relationship(back_populates="job")
@property @property
def filename(self) -> str: def filename(self) -> str:
@@ -65,6 +69,8 @@ class Transcript(SQLModel, table=True):
"""Name of the transcription provider used to generate this transcript.""" """Name of the transcription provider used to generate this transcript."""
prompt_name: str prompt_name: str
"""Name of the prompt used to generate this transcript.""" """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 text: str | None = None
"""The transcribed text. This may be None if the job failed or is still in progress.""" """The transcribed text. This may be None if the job failed or is still in progress."""
error_detail: str | None = None error_detail: str | None = None
@@ -73,3 +79,23 @@ class Transcript(SQLModel, table=True):
# --- relationships --- # --- relationships ---
job: Job = Relationship(back_populates="transcript") 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))
# --- relationships ---
job: Job = Relationship(back_populates="transcript_revisions")
+1
View File
@@ -34,6 +34,7 @@ class TranscriptionResult:
job_id=job_id, job_id=job_id,
provider=self.provider, provider=self.provider,
prompt_name=self.prompt_name, prompt_name=self.prompt_name,
model=self.model,
text=self.text, text=self.text,
) )
+115
View File
@@ -18,6 +18,7 @@ from transcription.config import get_settings
from transcription.errors import AppError from transcription.errors import AppError
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
from transcription.models import Transcript from transcription.models import Transcript
from transcription.models import TranscriptRevision
from transcription.providers import ProviderAuthError from transcription.providers import ProviderAuthError
from transcription.providers import ProviderError from transcription.providers import ProviderError
from transcription.providers import ProviderResponseError from transcription.providers import ProviderResponseError
@@ -63,6 +64,18 @@ class TranscriptionService(ServiceBase):
await self._finalize(session=_session, caller_session=session, refresh=(transcript,)) await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
return transcript return transcript
async def 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: async def read_transcript(self, transcript_id: UUID, *, session: AsyncSession | None = None) -> Transcript:
"""Read an existing transcript from the database.""" """Read an existing transcript from the database."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
@@ -80,6 +93,27 @@ class TranscriptionService(ServiceBase):
) )
return transcript 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: async def update_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> Transcript:
"""Update an existing transcript in the database.""" """Update an existing transcript in the database."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
@@ -87,12 +121,35 @@ class TranscriptionService(ServiceBase):
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 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: async def delete_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> None:
"""Delete a transcript from the database.""" """Delete a transcript from the database."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
await _session.delete(transcript) await _session.delete(transcript)
await self._finalize(session=_session, caller_session=session) 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( async def transcribe_document(
self, self,
image_path: str | Path, image_path: str | Path,
@@ -118,6 +175,7 @@ class TranscriptionService(ServiceBase):
error_detail: str | None, error_detail: str | None,
provider: str | None = None, provider: str | None = None,
prompt_name: str = DEFAULT_PROMPT_FILE, prompt_name: str = DEFAULT_PROMPT_FILE,
model: str | None = None,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> Transcript: ) -> Transcript:
"""Create or update a transcript for a job id.""" """Create or update a transcript for a job id."""
@@ -135,11 +193,68 @@ class TranscriptionService(ServiceBase):
if provider is not None: if provider is not None:
transcript.provider = provider transcript.provider = provider
transcript.prompt_name = prompt_name transcript.prompt_name = prompt_name
if model is not None:
transcript.model = model
_session.add(transcript) _session.add(transcript)
await self._finalize(session=_session, caller_session=session, refresh=(transcript,)) await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
return transcript return transcript
async def list_transcript_revisions_by_job(
self,
*,
job_id: UUID,
session: AsyncSession | None = None,
) -> list[TranscriptRevision]:
"""Return transcript revisions for a job ordered by version number."""
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)
async def append_transcript_revision(
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."""
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
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
async def transcribe_document_image( async def transcribe_document_image(
image_path: str | Path, image_path: str | Path,
+78 -2
View File
@@ -121,12 +121,24 @@ async def _finalize_transcribed(
"""Transaction B: transcript + TRANSCRIBED in one commit.""" """Transaction B: transcript + TRANSCRIBED in one commit."""
if session is None: if session is None:
async with services.jobs._session_scope() as local_session: async with services.jobs._session_scope() as local_session:
prompt_name = result.prompt_name or DEFAULT_PROMPT_FILE
await services.transcriptions.upsert_transcript_by_job( await services.transcriptions.upsert_transcript_by_job(
job_id=job.id, job_id=job.id,
text=result.text, text=result.text,
error_detail=None, error_detail=None,
provider=result.provider, provider=result.provider,
prompt_name=result.prompt_name, 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",
session=local_session, session=local_session,
) )
updated_job = await services.jobs.mark_job_status( updated_job = await services.jobs.mark_job_status(
@@ -137,12 +149,24 @@ async def _finalize_transcribed(
await local_session.commit() await local_session.commit()
return updated_job return updated_job
prompt_name = result.prompt_name or DEFAULT_PROMPT_FILE
await services.transcriptions.upsert_transcript_by_job( await services.transcriptions.upsert_transcript_by_job(
job_id=job.id, job_id=job.id,
text=result.text, text=result.text,
error_detail=None, error_detail=None,
provider=result.provider, provider=result.provider,
prompt_name=result.prompt_name, 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",
session=session, session=session,
) )
updated_job = await services.jobs.mark_job_status( updated_job = await services.jobs.mark_job_status(
@@ -165,11 +189,24 @@ async def _finalize_retry(
"""Transaction C: transcript error + QUEUED + retry increment in one commit.""" """Transaction C: transcript error + QUEUED + retry increment in one commit."""
if session is None: if session is None:
async with services.jobs._session_scope() as local_session: async with services.jobs._session_scope() as local_session:
provider_name = services.transcriptions.settings.provider.value
await services.transcriptions.upsert_transcript_by_job( await services.transcriptions.upsert_transcript_by_job(
job_id=job.id, job_id=job.id,
text=None, text=None,
error_detail=format_error_detail(error), error_detail=format_error_detail(error),
provider=provider_name,
prompt_name=DEFAULT_PROMPT_FILE, 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, session=local_session,
) )
updated_job = await services.jobs.update_job_state( updated_job = await services.jobs.update_job_state(
@@ -180,11 +217,24 @@ async def _finalize_retry(
) )
await local_session.commit() await local_session.commit()
else: else:
provider_name = services.transcriptions.settings.provider.value
await services.transcriptions.upsert_transcript_by_job( await services.transcriptions.upsert_transcript_by_job(
job_id=job.id, job_id=job.id,
text=None, text=None,
error_detail=format_error_detail(error), error_detail=format_error_detail(error),
provider=provider_name,
prompt_name=DEFAULT_PROMPT_FILE, 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, session=session,
) )
updated_job = await services.jobs.update_job_state( updated_job = await services.jobs.update_job_state(
@@ -210,11 +260,24 @@ async def _finalize_failed(
"""Transaction B: transcript error + FAILED in one commit.""" """Transaction B: transcript error + FAILED in one commit."""
if session is None: if session is None:
async with services.jobs._session_scope() as local_session: async with services.jobs._session_scope() as local_session:
provider_name = services.transcriptions.settings.provider.value
await services.transcriptions.upsert_transcript_by_job( await services.transcriptions.upsert_transcript_by_job(
job_id=job.id, job_id=job.id,
text=None, text=None,
error_detail=format_error_detail(error), error_detail=format_error_detail(error),
provider=provider_name,
prompt_name=DEFAULT_PROMPT_FILE, 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, session=local_session,
) )
updated_job = await services.jobs.mark_job_status( updated_job = await services.jobs.mark_job_status(
@@ -225,11 +288,24 @@ async def _finalize_failed(
await local_session.commit() await local_session.commit()
return updated_job return updated_job
provider_name = services.transcriptions.settings.provider.value
await services.transcriptions.upsert_transcript_by_job( await services.transcriptions.upsert_transcript_by_job(
job_id=job.id, job_id=job.id,
text=None, text=None,
error_detail=format_error_detail(error), error_detail=format_error_detail(error),
provider=provider_name,
prompt_name=DEFAULT_PROMPT_FILE, 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, session=session,
) )
updated_job = await services.jobs.mark_job_status( updated_job = await services.jobs.mark_job_status(
+290 -16
View File
@@ -2,29 +2,303 @@
from __future__ import annotations 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 nicegui import ui from nicegui import ui
from transcription.models import Document from transcription.models import Document
from transcription.models import Job from transcription.models import Job
from transcription.models import Transcript from transcription.models import Transcript
from transcription.models import TranscriptRevision
def render_job_detail(*, job: Job, document: Document | None, transcript: Transcript | None) -> None: @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
def _status_chip_classes(status: str) -> str:
if status == "queued":
return "bg-blue-1 text-blue-10"
if status == "processing":
return "bg-amber-1 text-amber-10"
if status == "transcribed":
return "bg-green-1 text-green-10"
if status == "failed":
return "bg-red-1 text-red-10"
return "bg-grey-2 text-grey-9"
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")
def _render_document_section(document: Document) -> None:
with ui.card().classes("w-full bg-grey-1 q-pa-md"):
ui.label("Document").classes("text-subtitle1 text-weight-medium")
ui.separator().classes("q-my-sm")
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
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
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")
return
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("Provider", transcript.provider)
_metadata_row("Model", model_name or "unknown")
_metadata_row("Prompt", transcript.prompt_name)
display_rows = _build_display_rows(transcript, revisions)
rows_by_id = {row.id: row for row in display_rows}
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 ""))
def render_job_detail(
*,
job: Job,
document: Document | None,
transcript: Transcript | None,
revisions: list[TranscriptRevision],
on_update: Callable[[str], Awaitable[None]] | None = None,
) -> None:
"""Render all sections for the job detail page.""" """Render all sections for the job detail page."""
ui.label(f"Job ID: {job.id}") status_text = job.status.value
ui.label(f"Status: {job.status.value}") with ui.column().classes("w-full max-w-4xl q-gutter-md"):
ui.label(f"Created: {job.created_at.isoformat()}") with ui.card().classes("w-full q-pa-lg"):
ui.label(f"Updated: {job.updated_at.isoformat()}") 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")
status_chip_classes = (
"q-px-sm q-py-xs rounded-borders "
"text-weight-medium text-capitalize "
f"{_status_chip_classes(status_text)}"
)
ui.label(status_text).classes(status_chip_classes)
if document is not None: ui.separator().classes("q-my-md")
ui.label(f"Filename: {document.filename}") with ui.column().classes("w-full q-gutter-y-xs"):
ui.label(f"File path: {document.file_path}") _metadata_row("Created", job.created_at.isoformat())
_metadata_row("Updated", job.updated_at.isoformat())
_metadata_row("Retries", str(job.retry_count))
if transcript is None: if document is not None:
ui.label("Transcript not available yet.") _render_document_section(document)
elif transcript.text:
ui.label("Transcript:") _render_transcript_versioned_section(
ui.markdown(transcript.text) document=document,
elif transcript.error_detail: transcript=transcript,
ui.label("Failure detail:") revisions=revisions,
ui.label(transcript.error_detail) on_update=on_update,
)
@@ -1,55 +0,0 @@
"""Reusable jobs table rendering helpers."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from uuid import UUID
from nicegui import ui
@dataclass(frozen=True)
class JobTableRow:
"""Read model consumed by the shared jobs table component."""
id: UUID
status: str
created_at: str
updated_at: str
def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, str]]:
"""Convert typed rows into table-compatible dictionaries."""
return [
{
"id": str(row.id),
"status": row.status,
"created_at": row.created_at,
"updated_at": row.updated_at,
}
for row in rows
]
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"""Render jobs table and per-row detail links."""
if not rows:
ui.label("No jobs yet.")
return
serialized_rows = _serialize_rows(rows)
ui.table(
columns=[
{"name": "id", "label": "Job ID", "field": "id"},
{"name": "status", "label": "Status", "field": "status"},
{"name": "created_at", "label": "Created", "field": "created_at"},
{"name": "updated_at", "label": "Updated", "field": "updated_at"},
],
rows=serialized_rows,
row_key="id",
).classes("w-full")
with ui.column().classes("gap-1"):
for row in serialized_rows:
ui.link(f"Open {row['id']}", f"/jobs/{row['id']}")
@@ -0,0 +1,4 @@
from .jobs import JobTableRow
from .jobs import render_jobs_table
__all__ = ["JobTableRow", "render_jobs_table"]
@@ -0,0 +1,68 @@
"""Common logic for generating table widgets."""
from collections.abc import Callable
from typing import Any
from nicegui import events
from nicegui import ui
def _extract_row_id(args: Any) -> 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
def _bind_row_click_handler(
table: Any,
*,
on_row_click_id: Callable[[str], None],
) -> None:
def handle_row_click(event: events.GenericEventArguments) -> None:
row_id = _extract_row_id(event.args)
if row_id is None:
return
on_row_click_id(row_id)
table.on("rowClick", handle_row_click)
def build_table(
rows: list[dict[str, Any]],
columns: list[dict[str, Any]],
*,
default_sort_by: str | None = None,
default_descending: bool = False,
classes: str = "app-table",
on_row_click_id: Callable[[str], None] | None = None,
) -> Any:
pagination: dict[str, Any] = {"rowsPerPage": 25}
if default_sort_by is not None:
pagination["sortBy"] = default_sort_by
pagination["descending"] = default_descending
table = (
ui.table(
rows=rows,
columns=columns,
row_key="id",
pagination=pagination,
)
.classes(classes)
.props('table-style="table-layout: fixed; width: 100%;"')
)
if on_row_click_id is not None:
_bind_row_click_handler(table, on_row_click_id=on_row_click_id)
return table
@@ -0,0 +1,75 @@
"""Jobs table rendering helpers."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from typing import Any
from uuid import UUID
from nicegui import ui
from .common import build_table
@dataclass(frozen=True, slots=True)
class JobTableRow:
"""Read model consumed by the jobs table component."""
id: UUID
status: str
filename: str
retry_count: int
created_at: str
updated_at: str
def _format_timestamp(value: str) -> str:
"""Return a friendly UTC timestamp for table display."""
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return value
parsed = parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
return parsed.astimezone().strftime("%b %d, %I:%M %p")
def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
return [
{
"id": str(row.id),
"status": row.status,
"filename": row.filename,
"retry_count": row.retry_count,
"created_at": _format_timestamp(row.created_at),
"updated_at": _format_timestamp(row.updated_at),
"created_sort": row.created_at,
"updated_sort": row.updated_at,
}
for row in rows
]
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"""Render jobs table and open a detail page when clicking a row."""
if not rows:
ui.label("No jobs yet.")
return
build_table(
rows=_serialize_rows(rows),
columns=[
{"name": "id", "label": "Job ID", "field": "id", "sortable": True},
{"name": "status", "label": "Status", "field": "status", "sortable": True},
{"name": "filename", "label": "Filename", "field": "filename", "sortable": True},
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
{"name": "created_at", "label": "Created", "field": "created_at", "sortable": True},
{"name": "updated_at", "label": "Updated", "field": "updated_at", "sortable": True},
],
default_sort_by="created_sort",
default_descending=True,
classes="app-table w-full",
on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"),
)
+7 -5
View File
@@ -9,11 +9,11 @@ from nicegui import ui
from nicegui.binding import bindable_dataclass from nicegui.binding import bindable_dataclass
from nicegui.events import UploadEventArguments from nicegui.events import UploadEventArguments
from transcription.services.documents import DocumentService from transcription.errors import AppError
from transcription.services.documents import UploadError
from transcription.services.documents import UploadJobResult from transcription.services.documents import UploadJobResult
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.error_presenter import summarize_error from transcription.ui.components.error_presenter import summarize_error
from transcription.worker import WorkerNotifier
type UploadSubmitter = Callable[[str, bytes], Awaitable[UploadJobResult]] type UploadSubmitter = Callable[[str, bytes], Awaitable[UploadJobResult]]
@@ -26,7 +26,7 @@ class UploadWidgetState:
message: str = "" message: str = ""
def render_upload_widget(*, service: DocumentService) -> None: def render_upload_widget(*, submitter: UploadSubmitter, notifier: WorkerNotifier | None = None) -> None:
"""Render upload controls and common status/error handling.""" """Render upload controls and common status/error handling."""
state = UploadWidgetState() state = UploadWidgetState()
status_label = ui.label("Upload a document to start transcription.") status_label = ui.label("Upload a document to start transcription.")
@@ -41,12 +41,14 @@ def render_upload_widget(*, service: DocumentService) -> None:
status_label.text = "Uploading..." status_label.text = "Uploading..."
try: try:
payload = await event.file.read() payload = await event.file.read()
result = await service.upload_file(filename=event.file.name, file_bytes=payload) result = await submitter(event.file.name, payload)
job_id = result.job_id job_id = result.job_id
state.message = f"Created job {job_id}" if job_id is not None else "Upload complete" state.message = f"Created job {job_id}" if job_id is not None else "Upload complete"
status_label.text = state.message status_label.text = state.message
if notifier is not None:
notifier.notify()
ui.notify(state.message, type="positive") ui.notify(state.message, type="positive")
except UploadError as exc: except AppError as exc:
state.message = summarize_error(exc, operation="upload.submit") state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}" status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit") show_error(exc, title="Upload failed", operation="upload.submit")
+87 -16
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from uuid import UUID from uuid import UUID
from nicegui import ui from nicegui import ui
from sqlalchemy.orm import selectinload
from sqlmodel import desc from sqlmodel import desc
from sqlmodel import select from sqlmodel import select
@@ -12,21 +13,31 @@ from transcription.db import get_session
from transcription.models import Document from transcription.models import Document
from transcription.models import Job from transcription.models import Job
from transcription.models import Transcript 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 show_error
from transcription.ui.components.error_presenter import summarize_error from transcription.ui.components.error_presenter import summarize_error
from transcription.ui.components.job_detail import render_job_detail from transcription.ui.components.job_detail import render_job_detail
from transcription.ui.components.job_table import JobTableRow from transcription.ui.components.table.jobs import JobTableRow
from transcription.ui.components.job_table import render_jobs_table from transcription.ui.components.table.jobs import render_jobs_table
async def fetch_jobs() -> list[JobTableRow]: async def fetch_job_rows() -> list[JobTableRow]:
"""Return jobs for display in most-recent-first order.""" """Return jobs for display in most-recent-first order."""
async with get_session() as session: async with get_session() as session:
jobs = (await session.exec(select(Job).order_by(desc(Job.created_at)))).all() jobs = (
await session.exec(
select(Job)
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
.order_by(desc(Job.created_at))
)
).all()
return [ return [
JobTableRow( JobTableRow(
id=job.id, id=job.id,
status=job.status.value, status=job.status.value,
filename=job.filename,
retry_count=job.retry_count,
created_at=job.created_at.isoformat(), created_at=job.created_at.isoformat(),
updated_at=job.updated_at.isoformat(), updated_at=job.updated_at.isoformat(),
) )
@@ -34,15 +45,22 @@ async def fetch_jobs() -> list[JobTableRow]:
] ]
async def fetch_job_detail(job_id: UUID) -> tuple[Job | None, Document | None, Transcript | None]: async def fetch_job_detail(job_id: UUID) -> tuple[Job | None, Document | None, Transcript | None, list[TranscriptRevision]]:
"""Return job, document, and transcript for detail view.""" """Return job, document, transcript snapshot, and revisions for detail view."""
async with get_session() as session: async with get_session() as session:
job = await session.get(Job, job_id) job = await session.get(Job, job_id)
if job is None: if job is None:
return None, None, None return None, None, None, []
document = await session.get(Document, job.document_id) document = await session.get(Document, job.document_id)
transcript = (await session.exec(select(Transcript).where(Transcript.job_id == job.id))).first() transcript = (await session.exec(select(Transcript).where(Transcript.job_id == job.id))).first()
return job, document, transcript 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: def register_page() -> None:
@@ -55,7 +73,7 @@ def register_page() -> None:
@ui.refreshable @ui.refreshable
async def render_table() -> None: async def render_table() -> None:
jobs = await fetch_jobs() jobs = await fetch_job_rows()
render_jobs_table(jobs) render_jobs_table(jobs)
async def refresh() -> None: async def refresh() -> None:
@@ -69,11 +87,12 @@ def register_page() -> None:
ui.button("Refresh", on_click=refresh) ui.button("Refresh", on_click=refresh)
await render_table() await render_table()
ui.link("Back to upload", "/") ui.link("Back to upload", "/upload")
@ui.page("/jobs/{job_id}") @ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str) -> None: async def job_detail_page(job_id: str) -> None:
ui.label("Job Detail") ui.label("Job Detail")
content = ui.column().classes("w-full")
try: try:
parsed_id = UUID(job_id) parsed_id = UUID(job_id)
except ValueError: except ValueError:
@@ -81,12 +100,64 @@ def register_page() -> None:
ui.link("Back to jobs", "/jobs") ui.link("Back to jobs", "/jobs")
return return
job, document, transcript = await fetch_job_detail(parsed_id) async def refresh_content() -> None:
if job is None: content.clear()
ui.label("Job not found") job, document, transcript, revisions = await fetch_job_detail(parsed_id)
ui.link("Back to jobs", "/jobs") if job is None:
return with content:
ui.label("Job not found")
return
render_job_detail(job=job, document=document, transcript=transcript) 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()
ui.link("Back to jobs", "/jobs") ui.link("Back to jobs", "/jobs")
+18 -5
View File
@@ -2,20 +2,33 @@
from __future__ import annotations from __future__ import annotations
from fastapi import Request
from nicegui import ui from nicegui import ui
from transcription.services.documents import DocumentService 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.upload import render_upload_widget from transcription.ui.components.upload import render_upload_widget
from transcription.worker import resolve_worker_notifier
def register_page() -> None: def register_page() -> None:
"""Register the upload page route.""" """Register the upload page route."""
service = DocumentService()
@ui.page("/upload", title="Upload Document") @ui.page("/upload", title="Upload Document")
def upload_page() -> None: def upload_page(request: Request) -> None:
render_upload_widget(service=service) session_factory = resolve_session_factory(request.app.state)
async def submit_upload(filename: str, file_bytes: bytes):
async with get_session(session_factory=session_factory) as session:
return await create_upload_job(
filename=filename,
file_bytes=file_bytes,
session=session,
)
notify_worker = resolve_worker_notifier(request.app.state)
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
with ui.row(): with ui.row():
ui.link("View jobs", "/jobs") ui.link("View jobs", "/jobs")
+74 -5
View File
@@ -8,6 +8,7 @@ from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from contextlib import contextmanager from contextlib import contextmanager
from contextlib import suppress from contextlib import suppress
from typing import Protocol
from uuid import UUID from uuid import UUID
from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker
@@ -27,6 +28,73 @@ from .services.workflows import process_next_queued_job as process_next_queued_j
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class WorkerNotifier(Protocol):
"""Abstraction for signaling the worker loop about new work."""
def notify(self) -> None:
"""Signal the worker loop that work may be available."""
class EventWorkerNotifier:
"""Worker notifier backed by an asyncio.Event."""
def __init__(self, wake_event: asyncio.Event):
self._wake_event = wake_event
def notify(self) -> None:
self._wake_event.set()
class NoopWorkerNotifier:
"""Fallback notifier used when worker signaling is unavailable."""
def notify(self) -> None:
return
def resolve_worker_notifier(state: object) -> WorkerNotifier:
"""Resolve notifier from app-like state objects with no-op fallback."""
notifier = getattr(state, "worker_notifier", None)
if isinstance(notifier, NoopWorkerNotifier):
return notifier
if notifier is None:
return NoopWorkerNotifier()
return notifier
@asynccontextmanager
async def worker_consumer_lifespan(
*,
session_factory: async_sessionmaker[AsyncSession] | None = None,
poll_interval_seconds: float = 1.0,
) -> AsyncGenerator[tuple[asyncio.Event, WorkerNotifier]]:
"""Start and stop the worker consumer loop for app lifespan."""
stop_event = asyncio.Event()
wake_event = asyncio.Event()
worker_notifier: WorkerNotifier = EventWorkerNotifier(wake_event)
worker_task = asyncio.create_task(
run_worker_loop(
session_factory=session_factory,
stop_event=stop_event,
wake_event=wake_event,
poll_interval_seconds=poll_interval_seconds,
)
)
worker_notifier.notify()
try:
yield stop_event, worker_notifier
finally:
stop_event.set()
worker_notifier.notify()
try:
await asyncio.wait_for(worker_task, timeout=2.0)
except TimeoutError:
worker_task.cancel()
with suppress(asyncio.CancelledError):
await worker_task
async def queue_consumer_loop(queue: asyncio.Queue[UUID], stop_event: asyncio.Event): async def queue_consumer_loop(queue: asyncio.Queue[UUID], stop_event: asyncio.Event):
"""Main worker loop that consumes jobs from the queue and processes them. """Main worker loop that consumes jobs from the queue and processes them.
@@ -65,12 +133,12 @@ async def run_worker_loop(
*, *,
session_factory: async_sessionmaker[AsyncSession] | None = None, session_factory: async_sessionmaker[AsyncSession] | None = None,
stop_event: asyncio.Event | None = None, stop_event: asyncio.Event | None = None,
wake_queue: asyncio.Queue[None] | None = None, wake_event: asyncio.Event | None = None,
poll_interval_seconds: float = 1.0, poll_interval_seconds: float = 1.0,
) -> None: ) -> None:
"""Run worker loop until stop_event is set. """Run worker loop until stop_event is set.
If wake_queue is provided, queue activity wakes the loop immediately while If wake_event is provided, signal activity wakes the loop immediately while
timeout-based wakeups preserve current polling behavior. timeout-based wakeups preserve current polling behavior.
""" """
while True: while True:
@@ -78,15 +146,16 @@ async def run_worker_loop(
logger.info("Worker stop event received") logger.info("Worker stop event received")
return return
if wake_queue is not None: if wake_event is not None:
with suppress(TimeoutError): with suppress(TimeoutError):
await asyncio.wait_for(wake_queue.get(), timeout=poll_interval_seconds) await asyncio.wait_for(wake_event.wait(), timeout=poll_interval_seconds)
wake_event.clear()
processed_any = False processed_any = False
while await process_next_queued_job(session_factory=session_factory): while await process_next_queued_job(session_factory=session_factory):
processed_any = True processed_any = True
if wake_queue is None and not processed_any: if wake_event is None and not processed_any:
await asyncio.sleep(poll_interval_seconds) await asyncio.sleep(poll_interval_seconds)
+22
View File
@@ -17,6 +17,9 @@ from transcription.db.operations import create_all
from transcription.db.runtime import dispose_database_runtime from transcription.db.runtime import dispose_database_runtime
from transcription.db.runtime import get_engine from transcription.db.runtime import get_engine
from transcription.db.runtime import get_session from transcription.db.runtime import get_session
from transcription.db.runtime import get_session_factory
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService
@pytest.fixture @pytest.fixture
@@ -47,3 +50,22 @@ async def async_session(default_settings: Settings):
yield async_session yield async_session
await dispose_database_runtime() await dispose_database_runtime()
@pytest.fixture
def default_session_factory(default_settings: Settings):
"""Provide a base fixture for tests that require database access."""
session_factory = get_session_factory(settings=default_settings)
return session_factory
@pytest.fixture
def job_service(default_session_factory) -> JobService:
"""Provide a JobService instance for testing."""
return JobService(session_factory=default_session_factory)
@pytest.fixture
def document_service(default_session_factory) -> DocumentService:
"""Provide a DocumentService instance for testing."""
return DocumentService(session_factory=default_session_factory)
+50 -19
View File
@@ -6,9 +6,9 @@ import pytest
from sqlmodel import select from sqlmodel import select
from transcription.config import Settings from transcription.config import Settings
from transcription.models import Job, JobStatus, Transcript from transcription.models import Job, JobStatus, Transcript, TranscriptRevision
from transcription.providers.base import TranscriptionResult from transcription.providers.base import TranscriptionResult
from transcription.services.upload import create_upload_job from transcription.services.store import create_upload_job
from transcription.worker import process_next_queued_job from transcription.worker import process_next_queued_job
@@ -16,24 +16,37 @@ from transcription.worker import process_next_queued_job
class TestPipelineSuccessFlow: class TestPipelineSuccessFlow:
"""Verify end-to-end success lifecycle behavior.""" """Verify end-to-end success lifecycle behavior."""
def test_upload_then_worker_persists_transcribed_terminal_state(self, session, tmp_path: Path, monkeypatch): @pytest.mark.asyncio
async def test_upload_then_worker_persists_transcribed_terminal_state(self, async_session, tmp_path: Path, monkeypatch):
"""Upload followed by worker processing persists transcript and transcribed status.""" """Upload followed by worker processing persists transcript and transcribed status."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path) settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
upload_result = create_upload_job( upload_result = await create_upload_job(
filename="pipeline.jpg", filename="pipeline.jpg",
file_bytes=b"pipeline-bytes", file_bytes=b"pipeline-bytes",
session=session, session=async_session,
settings=settings, settings=settings,
) )
def _fake_transcribe(_path: str) -> TranscriptionResult: async def _fake_transcribe(_path: str) -> TranscriptionResult:
return TranscriptionResult(text="Pipeline transcript", provider="openrouter", model="test-model") return TranscriptionResult(
text="Pipeline transcript",
provider="openrouter",
prompt_name="transcribe_document.md",
model="test-model",
)
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe) monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _fake_transcribe)
processed = process_next_queued_job(session=session) processed = await process_next_queued_job(session=async_session)
job = session.get(Job, upload_result.job_id) job = await async_session.get(Job, upload_result.job_id)
transcript = session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id)).first() 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()
assert processed is True assert processed is True
assert job is not None assert job is not None
@@ -41,30 +54,43 @@ class TestPipelineSuccessFlow:
assert transcript is not None assert transcript is not None
assert transcript.text == "Pipeline transcript" assert transcript.text == "Pipeline transcript"
assert transcript.error_detail is None 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 @pytest.mark.integration
class TestPipelineFailureFlow: class TestPipelineFailureFlow:
"""Verify end-to-end failure lifecycle behavior.""" """Verify end-to-end failure lifecycle behavior."""
def test_upload_then_worker_persists_failed_terminal_state(self, session, tmp_path: Path, monkeypatch): @pytest.mark.asyncio
async def test_upload_then_worker_persists_failed_terminal_state(self, async_session, tmp_path: Path, monkeypatch):
"""Upload followed by worker processing persists error detail and failed status.""" """Upload followed by worker processing persists error detail and failed status."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path) settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
upload_result = create_upload_job( upload_result = await create_upload_job(
filename="pipeline.jpg", filename="pipeline.jpg",
file_bytes=b"pipeline-bytes", file_bytes=b"pipeline-bytes",
session=session, session=async_session,
settings=settings, settings=settings,
) )
def _fake_transcribe(_path: str) -> TranscriptionResult: async def _fake_transcribe(_path: str) -> TranscriptionResult:
raise RuntimeError("pipeline provider failure") raise RuntimeError("pipeline provider failure")
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe) monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _fake_transcribe)
processed = process_next_queued_job(session=session) processed = await process_next_queued_job(session=async_session)
job = session.get(Job, upload_result.job_id) job = await async_session.get(Job, upload_result.job_id)
transcript = session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id)).first() 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()
assert processed is True assert processed is True
assert job is not None assert job is not None
@@ -74,3 +100,8 @@ class TestPipelineFailureFlow:
assert "pipeline provider failure" in transcript.error_detail assert "pipeline provider failure" in transcript.error_detail
assert "[internal_unexpected_error]" in transcript.error_detail assert "[internal_unexpected_error]" in transcript.error_detail
assert "error_id=" 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 "")
+30 -55
View File
@@ -1,96 +1,71 @@
"""Tests for transcription.db — schema bootstrap and session factory.""" """Tests for transcription.db — async schema bootstrap/runtime behavior."""
from sqlalchemy import inspect, text from sqlalchemy import inspect
from sqlmodel import Session, SQLModel, create_engine from sqlalchemy import text
from sqlmodel.pool import StaticPool import pytest
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: class TestSchemaBootstrap:
"""Verify create_all produces the expected table set.""" """Verify async create_all produces the expected table set."""
def test_create_all_creates_expected_tables(self): @pytest.mark.asyncio
"""After create_all(), document, job, and transcript tables exist.""" async def test_create_all_creates_expected_tables(self, default_settings):
engine = _in_memory_engine() """After async create_all(), document/job/transcript/revision tables exist."""
# Ensure models are imported so metadata is populated # Ensure models are imported so metadata is populated.
from transcription.models import Document, Job, Transcript # noqa: F401 from transcription.models import Document, Job, Transcript, TranscriptRevision # noqa: F401
import transcription.db as db_module from transcription.db.operations import create_all
from transcription.db.runtime import get_engine
db_module.create_all(engine=engine) 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()))
inspector = inspect(engine)
table_names = set(inspector.get_table_names())
assert "document" in table_names assert "document" in table_names
assert "job" in table_names assert "job" in table_names
assert "transcript" in table_names assert "transcript" in table_names
assert "transcriptrevision" in table_names
class TestSessionFactory: class TestSessionFactory:
"""Verify get_session yields and cleans up sessions.""" """Verify async get_session yields a usable AsyncSession."""
def test_get_session_yields_session(self): @pytest.mark.asyncio
"""get_session() yields a usable Session object.""" async def test_get_session_yields_session(self, default_settings):
engine = _in_memory_engine() """get_session() yields an AsyncSession with a live connection."""
SQLModel.metadata.create_all(engine) from transcription.db.runtime import get_session
import transcription.db as db_module async with get_session(settings=default_settings) as session:
result = await session.exec(text("SELECT 1"))
with db_module.get_session(engine=engine) as session: assert result.first()[0] == 1
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: class TestBootstrapPolicy:
"""Verify schema bootstrap policy defaults and overrides.""" """Verify startup schema bootstrap policy via Settings property."""
def test_production_defaults_to_no_bootstrap(self): def test_production_defaults_to_no_bootstrap(self):
"""Production defaults to explicit non-bootstrap startup behavior.""" """Production defaults to explicit non-bootstrap startup behavior."""
from transcription.config import Settings from transcription.config import Settings
from transcription.db import should_bootstrap_schema
settings = Settings(openrouter_api_key="test-key", environment="production") settings = Settings(openrouter_api_key="test-key", environment="production")
assert should_bootstrap_schema(settings) is False assert settings.should_bootstrap_schema is False
def test_development_defaults_to_bootstrap(self): def test_development_defaults_to_bootstrap(self):
"""Development defaults to schema bootstrap for local workflows.""" """Development defaults to schema bootstrap for local workflows."""
from transcription.config import Settings from transcription.config import Settings
from transcription.db import should_bootstrap_schema
settings = Settings(openrouter_api_key="test-key", environment="development") settings = Settings(openrouter_api_key="test-key", environment="development")
assert should_bootstrap_schema(settings) is True assert settings.should_bootstrap_schema is True
def test_explicit_override_wins(self): def test_explicit_override_wins(self):
"""Explicit bootstrap_schema_on_startup overrides environment default.""" """Explicit bootstrap_schema_on_startup overrides environment default."""
from transcription.config import Settings from transcription.config import Settings
from transcription.db import should_bootstrap_schema
settings = Settings( settings = Settings(
openrouter_api_key="test-key", openrouter_api_key="test-key",
environment="production", environment="production",
bootstrap_schema_on_startup=True, bootstrap_schema_on_startup=True,
) )
assert should_bootstrap_schema(settings) is True assert settings.should_bootstrap_schema is True
+131 -7
View File
@@ -1,11 +1,11 @@
"""Tests for transcription.models — Document, Job, Transcript persistence and relationships.""" """Tests for transcription.models — Document, Job, Transcript, TranscriptRevision models."""
from uuid import UUID from uuid import UUID
import pytest import pytest
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from transcription.models import Document, Job, JobStatus, Transcript from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
def _make_document(**overrides) -> Document: def _make_document(**overrides) -> Document:
@@ -113,7 +113,7 @@ class TestTranscriptModel:
"""A Transcript with text set and error_detail None persists correctly.""" """A Transcript with text set and error_detail None persists correctly."""
doc = _persist_document(session) doc = _persist_document(session)
job = _persist_job(session, doc) job = _persist_job(session, doc)
transcript = Transcript(job_id=job.id, text="Dear Sir, ...") transcript = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="Dear Sir, ...")
session.add(transcript) session.add(transcript)
session.commit() session.commit()
session.refresh(transcript) session.refresh(transcript)
@@ -127,7 +127,12 @@ class TestTranscriptModel:
"""A Transcript with text None and error_detail set persists correctly.""" """A Transcript with text None and error_detail set persists correctly."""
doc = _persist_document(session) doc = _persist_document(session)
job = _persist_job(session, doc) job = _persist_job(session, doc)
transcript = Transcript(job_id=job.id, error_detail="Provider timeout") transcript = Transcript(
job_id=job.id,
provider="openrouter",
prompt_name="transcribe_document.md",
error_detail="Provider timeout",
)
session.add(transcript) session.add(transcript)
session.commit() session.commit()
session.refresh(transcript) session.refresh(transcript)
@@ -142,16 +147,101 @@ class TestTranscriptModel:
doc = _persist_document(session) doc = _persist_document(session)
job = _persist_job(session, doc) job = _persist_job(session, doc)
t1 = Transcript(job_id=job.id, text="First") t1 = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="First")
session.add(t1) session.add(t1)
session.commit() session.commit()
t2 = Transcript(job_id=job.id, text="Duplicate") t2 = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="Duplicate")
session.add(t2) session.add(t2)
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
session.commit() 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: class TestRelationships:
"""Verify SQLModel relationship navigation between models.""" """Verify SQLModel relationship navigation between models."""
@@ -169,7 +259,12 @@ class TestRelationships:
"""job.transcript returns the linked Transcript.""" """job.transcript returns the linked Transcript."""
doc = _persist_document(session) doc = _persist_document(session)
job = _persist_job(session, doc) job = _persist_job(session, doc)
transcript = Transcript(job_id=job.id, text="Transcribed text") transcript = Transcript(
job_id=job.id,
provider="openrouter",
prompt_name="transcribe_document.md",
text="Transcribed text",
)
session.add(transcript) session.add(transcript)
session.commit() session.commit()
@@ -177,3 +272,32 @@ class TestRelationships:
assert job.transcript is not None assert job.transcript is not None
assert isinstance(job.transcript, Transcript) assert isinstance(job.transcript, Transcript)
assert job.transcript.text == "Transcribed text" 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
+29 -87
View File
@@ -1,95 +1,37 @@
"""Tests for transcription.ui.jobs_page.""" """Tests for the jobs page route."""
from uuid import uuid4
import pytest import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from transcription.models import Document, Job, Transcript from transcription.ui import register_pages
from transcription.ui.jobs_page import fetch_job_detail, fetch_jobs from transcription.ui.pages import jobs_page
@pytest.fixture
def client(monkeypatch):
"""Provide a minimal app client with jobs data patched for rendering."""
async def _fetch_jobs_stub():
return []
monkeypatch.setattr(jobs_page, "fetch_jobs", _fetch_jobs_stub)
app = FastAPI()
register_pages(app)
with TestClient(app) as test_client:
yield test_client
@pytest.mark.integration @pytest.mark.integration
class TestJobsListBehavior: class TestPageRendering:
"""Verify job list data and rendering helpers.""" """Verify the jobs page is available and includes the main controls."""
def test_fetch_jobs_returns_job_view_rows(self, session, monkeypatch): def test_jobs_page_renders_expected_controls(self, client):
"""fetch_jobs returns normalized JobView rows for UI consumption.""" """GET /ui/jobs returns the page shell and jobs controls."""
document = Document(filename="letter.jpg", file_path="uploads/letter.jpg") response = client.get("/ui/jobs")
session.add(document)
session.commit()
session.refresh(document)
job = Job(document_id=document.id) assert response.status_code == 200
session.add(job) assert "Transcription Jobs" in response.text
session.commit() assert "Refresh" in response.text
assert "Back to upload" in response.text
class _SessionContext:
def __enter__(self):
return session
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
rows = fetch_jobs()
assert len(rows) == 1
assert rows[0].id == job.id
assert rows[0].status == "queued"
@pytest.mark.integration
class TestJobDetailBehavior:
"""Verify job detail retrieval behavior."""
def test_fetch_job_detail_returns_related_records_when_present(self, session, monkeypatch):
"""fetch_job_detail returns job, document, and transcript when available."""
document = Document(filename="typed.jpg", file_path="uploads/typed.jpg")
session.add(document)
session.commit()
session.refresh(document)
job = Job(document_id=document.id)
session.add(job)
session.commit()
session.refresh(job)
transcript = Transcript(job_id=job.id, text="Transcript text")
session.add(transcript)
session.commit()
class _SessionContext:
def __enter__(self):
return session
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
fetched_job, fetched_document, fetched_transcript = fetch_job_detail(job.id)
assert fetched_job is not None
assert fetched_document is not None
assert fetched_transcript is not None
assert fetched_job.id == job.id
assert fetched_document.id == document.id
assert fetched_transcript.job_id == job.id
def test_fetch_job_detail_returns_nones_for_missing_job(self, session, monkeypatch):
"""fetch_job_detail returns triple None when job does not exist."""
class _SessionContext:
def __enter__(self):
return session
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
fetched_job, fetched_document, fetched_transcript = fetch_job_detail(uuid4())
assert fetched_job is None
assert fetched_document is None
assert fetched_transcript is None
+24 -12
View File
@@ -1,8 +1,7 @@
"""Tests for UI page registration wiring.""" """Tests for UI page registration wiring."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest import pytest
from fastapi import FastAPI
from transcription.ui import register_pages from transcription.ui import register_pages
@@ -11,16 +10,29 @@ from transcription.ui import register_pages
class TestPageRegistration: class TestPageRegistration:
"""Verify page registration and route wiring.""" """Verify page registration and route wiring."""
def test_register_pages_adds_expected_routes(self): def test_register_pages_wires_upload_jobs_and_mount(self, monkeypatch):
"""register_pages wires upload and jobs routes into the app.""" """register_pages registers pages and mounts NiceGUI at /ui."""
calls: list[str] = []
def _record_upload() -> None:
calls.append("upload")
def _record_jobs() -> None:
calls.append("jobs")
def _record_run_with(
_app: FastAPI,
*,
mount_path: str,
show_welcome_message: bool,
) -> None:
calls.append(f"run_with:{mount_path}:{show_welcome_message}")
monkeypatch.setattr("transcription.ui.register_upload_page", _record_upload)
monkeypatch.setattr("transcription.ui.register_jobs_page", _record_jobs)
monkeypatch.setattr("transcription.ui.ui.run_with", _record_run_with)
app = FastAPI() app = FastAPI()
register_pages(app) register_pages(app)
app.add_api_route("/healthz", lambda: {"status": "ok"}, methods=["GET"])
client = TestClient(app) assert calls == ["upload", "jobs", "run_with:/ui:False"]
ui_response = client.get("/ui")
health_response = client.get("/healthz")
assert ui_response.status_code == 200
assert health_response.status_code == 200
assert health_response.json() == {"status": "ok"}
+40 -38
View File
@@ -1,53 +1,55 @@
"""Tests for transcription.ui.upload_page.""" """Tests for the upload page route."""
from pathlib import Path from pathlib import Path
from uuid import uuid4
import pytest import pytest
from fastapi.testclient import TestClient
from transcription.services.upload import UploadError, UploadJobResult from transcription.app import create_app
from transcription.ui import upload_page from transcription.config import Settings
from transcription.config import _settings
@pytest.mark.unit @pytest.fixture
class TestUploadPageBehavior: def client(tmp_path: Path):
"""Verify upload page helper and submission behavior.""" """Provide a real app 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)
def test_accepted_upload_types_contains_supported_extensions(self): app = create_app()
"""accepted_upload_types includes all MVP-supported upload extensions.""" with TestClient(app) as test_client:
accepted = upload_page.accepted_upload_types() yield test_client
assert ".jpg" in accepted
assert ".jpeg" in accepted
assert ".png" in accepted
assert ".tif" in accepted
assert ".tiff" in accepted
assert ".pdf" in accepted
def test_submit_upload_calls_upload_service(self, monkeypatch):
"""submit_upload delegates file persistence and job creation to upload service."""
expected = UploadJobResult(
document_id=uuid4(),
job_id=uuid4(),
stored_path=Path("uploads/mock.jpg"),
original_filename="mock.jpg",
)
def fake_create_upload_job(*, filename: str, file_bytes: bytes): @pytest.mark.integration
assert filename == "mock.jpg" class TestPageRendering:
assert file_bytes == b"bytes" """Verify the upload page is available and includes the main controls."""
return expected
monkeypatch.setattr(upload_page, "create_upload_job", fake_create_upload_job) def test_root_redirects_to_ui(self, client):
"""GET / redirects to the UI mount point."""
response = client.get("/", follow_redirects=False)
result = upload_page.submit_upload(filename="mock.jpg", file_bytes=b"bytes") assert response.status_code == 307
assert result == expected assert response.headers["location"] == "/ui"
def test_submit_upload_surfaces_upload_error(self, monkeypatch): def test_ui_redirects_to_upload(self, client):
"""submit_upload raises UploadError for invalid upload payloads.""" """GET /ui redirects to the upload page."""
def fake_create_upload_job(*, filename: str, file_bytes: bytes): response = client.get("/ui", follow_redirects=False)
raise UploadError("invalid payload")
monkeypatch.setattr(upload_page, "create_upload_job", fake_create_upload_job) assert response.status_code == 307
assert response.headers["location"] == "/ui/upload"
with pytest.raises(UploadError): def test_upload_page_renders_expected_controls(self, client):
upload_page.submit_upload(filename="bad.jpg", file_bytes=b"") """GET /ui/upload returns the page shell and upload controls."""
response = client.get("/ui/upload")
assert response.status_code == 200
assert "Upload Document" in response.text
assert "Select document file" in response.text
assert "View jobs" in response.text
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB