generated from john/python-template
Step 5 implemented
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""API route modules for the transcription app."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user