Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
761765636a | ||
|
|
e2e421835f | ||
|
|
57c1d22bb9 | ||
|
|
dba96e7a72 | ||
|
|
912cfd44de | ||
|
|
8d5fee886f | ||
|
|
34468c521f | ||
|
|
b682e092ea | ||
|
|
bd33e338b3 | ||
|
|
57a988973f | ||
|
|
9ad67d55e8 |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}"),
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 "")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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"}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 969 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1010 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 6.0 MiB |