Step 5 implemented

This commit is contained in:
Jim Lancaster
2026-06-25 10:00:00 -05:00
parent abf5829c6b
commit 3e057c0eff
15 changed files with 1796 additions and 33 deletions
+1
View File
@@ -0,0 +1 @@
"""API route modules for the transcription app."""
+16
View File
@@ -0,0 +1,16 @@
"""Health endpoint routes."""
from fastapi import APIRouter
router = APIRouter()
def healthz() -> dict[str, str]:
"""Return a simple health status payload."""
return {"status": "ok"}
@router.get("/healthz")
def healthz_route() -> dict[str, str]:
"""Route wrapper for health status payload."""
return healthz()
+61
View File
@@ -0,0 +1,61 @@
"""Application factory and lifespan wiring for the transcription app."""
from __future__ import annotations
from contextlib import asynccontextmanager
from threading import Event, Thread
from fastapi import FastAPI
from transcription.api.health import router as health_router
from transcription.config import get_settings, setup_logging
from transcription.db import create_all
from transcription.ui import register_pages
from transcription.worker import run_worker_loop
def _start_worker(app: FastAPI) -> None:
stop_event = Event()
worker_thread = Thread(
target=run_worker_loop,
kwargs={"stop_event": stop_event, "poll_interval_seconds": 1.0},
daemon=True,
)
worker_thread.start()
app.state.worker_stop_event = stop_event
app.state.worker_thread = worker_thread
def _stop_worker(app: FastAPI) -> None:
stop_event = getattr(app.state, "worker_stop_event", None)
worker_thread = getattr(app.state, "worker_thread", None)
if stop_event is not None:
stop_event.set()
if worker_thread is not None:
worker_thread.join(timeout=2.0)
@asynccontextmanager
async def _lifespan(app: FastAPI):
setup_logging()
create_all()
settings = get_settings()
settings.upload_dir.mkdir(parents=True, exist_ok=True)
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
_start_worker(app)
try:
yield
finally:
_stop_worker(app)
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(title="Transcription", lifespan=_lifespan)
register_pages(app)
app.include_router(health_router)
return app
+16
View File
@@ -0,0 +1,16 @@
"""UI page registration exports."""
from fastapi import FastAPI
from nicegui import ui
from transcription.ui.jobs_page import register_page as register_jobs_page
from transcription.ui.upload_page import register_page as register_upload_page
def register_pages(app: FastAPI) -> None:
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
register_upload_page()
register_jobs_page()
ui.run_with(app, mount_path="/ui", show_welcome_message=False)
+135
View File
@@ -0,0 +1,135 @@
"""Jobs list and detail page registration."""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from nicegui import ui
from sqlmodel import select
from transcription.db import get_session
from transcription.models import Document, Job, Transcript
@dataclass(frozen=True)
class JobView:
"""Read model for rendering job rows in the UI."""
id: UUID
status: str
created_at: str
updated_at: str
def fetch_jobs() -> list[JobView]:
"""Return jobs for display in most-recent-first order."""
with get_session() as session:
jobs = session.exec(select(Job).order_by(Job.created_at.desc())).all()
return [
JobView(
id=job.id,
status=job.status.value,
created_at=job.created_at.isoformat(),
updated_at=job.updated_at.isoformat(),
)
for job in jobs
]
def fetch_job_detail(job_id: UUID) -> tuple[Job | None, Document | None, Transcript | None]:
"""Return job, document, and transcript for detail view."""
with get_session() as session:
job = session.get(Job, job_id)
if job is None:
return None, None, None
document = session.get(Document, job.document_id)
transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
return job, document, transcript
def register_page() -> None:
"""Register jobs list and detail routes."""
@ui.page("/jobs")
def jobs_page() -> None:
ui.label("Transcription Jobs")
status = ui.label("Ready")
table_container = ui.column()
def render_table() -> None:
table_container.clear()
jobs = fetch_jobs()
with table_container:
if not jobs:
ui.label("No jobs yet.")
return
rows = [
{
"id": str(job.id),
"status": job.status,
"created_at": job.created_at,
"updated_at": job.updated_at,
}
for job in jobs
]
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=rows,
row_key="id",
)
for row in rows:
ui.link(f"Open {row['id']}", f"/jobs/{row['id']}")
def refresh() -> None:
status.text = "Refreshing..."
try:
render_table()
status.text = "Refreshed"
except Exception as exc: # noqa: BLE001
status.text = f"Refresh failed: {exc}"
ui.button("Refresh", on_click=refresh)
render_table()
ui.link("Back to upload", "/")
@ui.page("/jobs/{job_id}")
def job_detail_page(job_id: str) -> None:
ui.label("Job Detail")
try:
parsed_id = UUID(job_id)
except ValueError:
ui.label("Invalid job id")
ui.link("Back to jobs", "/jobs")
return
job, document, transcript = fetch_job_detail(parsed_id)
if job is None:
ui.label("Job not found")
ui.link("Back to jobs", "/jobs")
return
ui.label(f"Job ID: {job.id}")
ui.label(f"Status: {job.status.value}")
ui.label(f"Created: {job.created_at.isoformat()}")
ui.label(f"Updated: {job.updated_at.isoformat()}")
if document is not None:
ui.label(f"Filename: {document.filename}")
ui.label(f"File path: {document.file_path}")
if transcript is None:
ui.label("Transcript not available yet.")
elif transcript.text:
ui.label("Transcript:")
ui.markdown(transcript.text)
elif transcript.error_detail:
ui.label("Failure detail:")
ui.label(transcript.error_detail)
ui.link("Back to jobs", "/jobs")
+62
View File
@@ -0,0 +1,62 @@
"""Upload page registration and handlers."""
from __future__ import annotations
from dataclasses import dataclass
from nicegui import ui
from nicegui.events import UploadEventArguments
from transcription.services.upload import UploadError, UploadJobResult, create_upload_job
@dataclass
class UploadPageState:
"""Simple state container for upload page feedback."""
loading: bool = False
message: str = ""
def accepted_upload_types() -> str:
"""Return accepted file type string for upload input."""
return ".jpg,.jpeg,.png,.tif,.tiff,.pdf"
def submit_upload(*, filename: str, file_bytes: bytes) -> UploadJobResult:
"""Create an upload job from incoming file data."""
return create_upload_job(filename=filename, file_bytes=file_bytes)
def register_page() -> None:
"""Register the upload page route."""
@ui.page("/")
def upload_page() -> None:
state = UploadPageState()
status_label = ui.label("Upload a document to start transcription.")
def on_upload(event: UploadEventArguments) -> None:
state.loading = True
status_label.text = "Uploading..."
try:
payload = event.content.read()
result = submit_upload(filename=event.name, file_bytes=payload)
state.message = f"Created job {result.job_id}"
status_label.text = state.message
ui.notify(state.message, type="positive")
except UploadError as exc:
state.message = str(exc)
status_label.text = f"Upload failed: {state.message}"
ui.notify(f"Upload failed: {state.message}", type="negative")
finally:
state.loading = False
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select document file",
).props(f"accept={accepted_upload_types()}")
with ui.row():
ui.link("View jobs", "/ui/jobs")