generated from john/python-template
job detail page stuff
This commit is contained in:
@@ -35,7 +35,10 @@ class JobService(ServiceBase):
|
|||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = (
|
query = (
|
||||||
select(Job)
|
select(Job)
|
||||||
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
.options(
|
||||||
|
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
||||||
|
selectinload(Job.transcripts), # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
.where(Job.id == job_id)
|
.where(Job.id == job_id)
|
||||||
.execution_options(populate_existing=True)
|
.execution_options(populate_existing=True)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,30 +16,41 @@ PANGOZOOM_CDN_URL = "https://unpkg.com/@panzoom/[email protected]/dist/panzoom.min.j
|
|||||||
UPLOADS_URL_PREFIX = "/uploads"
|
UPLOADS_URL_PREFIX = "/uploads"
|
||||||
|
|
||||||
|
|
||||||
def _document_url(document: Document) -> str:
|
def render_document_panzoom(*, document: Document) -> None:
|
||||||
file_path = Path(document.file_path)
|
"""Render a document preview with pan and zoom interactions."""
|
||||||
upload_dir = get_settings().upload_dir
|
_register_panzoom_assets()
|
||||||
|
|
||||||
relative_path: Path
|
host_id = f"document-panzoom-{uuid4().hex}"
|
||||||
try:
|
document_url = _document_url(document)
|
||||||
relative_path = file_path.resolve().relative_to(upload_dir.resolve())
|
document_kind = _document_kind(document)
|
||||||
except ValueError:
|
|
||||||
parts = file_path.parts
|
|
||||||
if "uploads" in parts:
|
|
||||||
uploads_index = parts.index("uploads")
|
|
||||||
relative_path = Path(*parts[uploads_index + 1 :])
|
|
||||||
else:
|
|
||||||
relative_path = Path(file_path.name)
|
|
||||||
|
|
||||||
encoded_relative_path = "/".join(quote(part) for part in relative_path.parts)
|
with ui.card().classes("w-full q-pa-md"):
|
||||||
return f"{UPLOADS_URL_PREFIX}/{encoded_relative_path}"
|
with ui.row().classes("w-full items-center justify-between no-wrap"):
|
||||||
|
ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
|
||||||
|
ui.label(document.filename).classes("text-caption text-grey-4 ellipsis").style(
|
||||||
|
"max-width: 60%; text-align: right;"
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
ui.element("div").classes("w-full document-panzoom-host rounded-borders q-mt-md")
|
||||||
|
# .style(f"height: {height};")
|
||||||
|
) as host:
|
||||||
|
host.props(f"id={host_id}")
|
||||||
|
with ui.element("div").classes("document-panzoom-surface"):
|
||||||
|
if document_kind == "pdf":
|
||||||
|
ui.html(
|
||||||
|
f'<iframe class="document-panzoom-iframe" '
|
||||||
|
f'src="{document_url}" title="{document.filename}" '
|
||||||
|
"data-panzoom-target></iframe>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ui.html(
|
||||||
|
f'<img class="document-panzoom-media" '
|
||||||
|
f'src="{document_url}" alt="{document.filename}" '
|
||||||
|
"data-panzoom-target data-panzoom-media />"
|
||||||
|
)
|
||||||
|
|
||||||
def _document_kind(document: Document) -> str:
|
_attach_panzoom(host_id)
|
||||||
suffix = Path(document.file_path).suffix.lower()
|
|
||||||
if suffix == ".pdf":
|
|
||||||
return "pdf"
|
|
||||||
return "image"
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
@@ -87,6 +98,32 @@ def _register_panzoom_assets() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _document_url(document: Document) -> str:
|
||||||
|
file_path = Path(document.file_path)
|
||||||
|
upload_dir = get_settings().upload_dir
|
||||||
|
|
||||||
|
relative_path: Path
|
||||||
|
try:
|
||||||
|
relative_path = file_path.resolve().relative_to(upload_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
parts = file_path.parts
|
||||||
|
if "uploads" in parts:
|
||||||
|
uploads_index = parts.index("uploads")
|
||||||
|
relative_path = Path(*parts[uploads_index + 1 :])
|
||||||
|
else:
|
||||||
|
relative_path = Path(file_path.name)
|
||||||
|
|
||||||
|
encoded_relative_path = "/".join(quote(part) for part in relative_path.parts)
|
||||||
|
return f"{UPLOADS_URL_PREFIX}/{encoded_relative_path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _document_kind(document: Document) -> str:
|
||||||
|
suffix = Path(document.file_path).suffix.lower()
|
||||||
|
if suffix == ".pdf":
|
||||||
|
return "pdf"
|
||||||
|
return "image"
|
||||||
|
|
||||||
|
|
||||||
def _attach_panzoom(host_id: str) -> None:
|
def _attach_panzoom(host_id: str) -> None:
|
||||||
ui.run_javascript(
|
ui.run_javascript(
|
||||||
f"""
|
f"""
|
||||||
@@ -172,41 +209,3 @@ def _attach_panzoom(host_id: str) -> None:
|
|||||||
}})();
|
}})();
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_document_panzoom(*, document: Document, height: str = "640px") -> None:
|
|
||||||
"""Render a document preview with pan and zoom interactions."""
|
|
||||||
_register_panzoom_assets()
|
|
||||||
|
|
||||||
host_id = f"document-panzoom-{uuid4().hex}"
|
|
||||||
document_url = _document_url(document)
|
|
||||||
document_kind = _document_kind(document)
|
|
||||||
|
|
||||||
with ui.card().classes("w-full q-pa-md"):
|
|
||||||
with ui.row().classes("w-full items-center justify-between no-wrap"):
|
|
||||||
ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
|
|
||||||
ui.label(document.filename).classes("text-caption text-grey-4 ellipsis").style(
|
|
||||||
"max-width: 60%; text-align: right;"
|
|
||||||
)
|
|
||||||
|
|
||||||
with (
|
|
||||||
ui.element("div")
|
|
||||||
.classes("w-full document-panzoom-host rounded-borders q-mt-md")
|
|
||||||
.style(f"height: {height};") as host
|
|
||||||
):
|
|
||||||
host.props(f"id={host_id}")
|
|
||||||
with ui.element("div").classes("document-panzoom-surface"):
|
|
||||||
if document_kind == "pdf":
|
|
||||||
ui.html(
|
|
||||||
f'<iframe class="document-panzoom-iframe" '
|
|
||||||
f'src="{document_url}" title="{document.filename}" '
|
|
||||||
"data-panzoom-target></iframe>"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
ui.html(
|
|
||||||
f'<img class="document-panzoom-media" '
|
|
||||||
f'src="{document_url}" alt="{document.filename}" '
|
|
||||||
"data-panzoom-target data-panzoom-media />"
|
|
||||||
)
|
|
||||||
|
|
||||||
_attach_panzoom(host_id)
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Reusable transcript UI components."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.models import Transcript
|
||||||
|
|
||||||
|
|
||||||
|
def render_transcript_revision_row(
|
||||||
|
*,
|
||||||
|
transcript: Transcript,
|
||||||
|
initially_expanded: bool = False,
|
||||||
|
classes: str = "w-full",
|
||||||
|
) -> Any:
|
||||||
|
"""Render one collapsible row for a single transcript revision."""
|
||||||
|
status_label = "Failed" if transcript.error_detail else "Transcribed"
|
||||||
|
header = f"Revision {transcript.revision} | {status_label}"
|
||||||
|
caption = f"{transcript.provider} | {transcript.prompt_name} | {_format_created_at(transcript.created_at)}"
|
||||||
|
|
||||||
|
expansion = ui.expansion(text=header, caption=caption, value=initially_expanded, group="group").classes(
|
||||||
|
f"{classes} rounded-borders bg-blue-grey-10"
|
||||||
|
)
|
||||||
|
|
||||||
|
with expansion, ui.column().classes("w-full q-gutter-y-sm q-pa-sm"):
|
||||||
|
_metadata_row(label="Provider", value=transcript.provider)
|
||||||
|
_metadata_row(label="Prompt", value=transcript.prompt_name)
|
||||||
|
_metadata_row(label="Created", value=_format_created_at(transcript.created_at))
|
||||||
|
|
||||||
|
if transcript.text:
|
||||||
|
with ui.card().classes("w-full q-pa-sm"):
|
||||||
|
ui.markdown(transcript.text)
|
||||||
|
|
||||||
|
if transcript.error_detail:
|
||||||
|
with ui.card().classes("w-full bg-red-1 text-red-10 q-pa-sm"):
|
||||||
|
ui.label("Failure detail").classes("text-caption text-uppercase")
|
||||||
|
ui.label(transcript.error_detail).classes("text-body2")
|
||||||
|
|
||||||
|
return expansion
|
||||||
|
|
||||||
|
|
||||||
|
def _format_created_at(value: datetime) -> str:
|
||||||
|
"""Return a compact UTC-like timestamp for row captions."""
|
||||||
|
return value.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_row(*, label: str, value: str) -> None:
|
||||||
|
with ui.row().classes("w-md items-start justify-between q-gutter-x-md"):
|
||||||
|
ui.label(label).classes("text-caption text-grey-5 text-uppercase")
|
||||||
|
ui.label(value).classes("text-body2 text-right text-grey-1 break-all")
|
||||||
@@ -8,13 +8,14 @@ from fastapi import Request
|
|||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.app_state import resolve_session_factory
|
from transcription.app_state import resolve_session_factory
|
||||||
|
from transcription.models import JobStatus
|
||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
from transcription.services.transcription import TranscriptionService
|
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.job_detail import render_job_detail
|
|
||||||
from transcription.ui.components.table.jobs import render_jobs_table
|
from transcription.ui.components.table.jobs import render_jobs_table
|
||||||
|
|
||||||
|
from ..components.document_panzoom import render_document_panzoom
|
||||||
from ..components.table.jobs import JobTableRow
|
from ..components.table.jobs import JobTableRow
|
||||||
|
from ..components.transcript import render_transcript_revision_row
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None:
|
def register_page() -> None:
|
||||||
@@ -48,22 +49,22 @@ def register_page() -> None:
|
|||||||
async def job_detail_page(job_id: str, request: Request) -> None:
|
async def job_detail_page(job_id: str, request: Request) -> None:
|
||||||
session_factory = resolve_session_factory(request.app.state)
|
session_factory = resolve_session_factory(request.app.state)
|
||||||
jobs_service = JobService(session_factory=session_factory)
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
transcription_service = TranscriptionService(session_factory=session_factory)
|
|
||||||
render_navigation_header(current_path="/jobs")
|
render_navigation_header(current_path="/jobs")
|
||||||
ui.button(icon="arrow_back", on_click=ui.navigate.back)
|
|
||||||
try:
|
|
||||||
parsed_id = UUID(job_id)
|
|
||||||
except ValueError:
|
|
||||||
ui.label("Invalid job id")
|
|
||||||
ui.link("Back to jobs", "/jobs")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
job = await jobs_service.read_job(job_id=UUID(job_id))
|
||||||
job = await jobs_service.read_job(job_id=parsed_id)
|
|
||||||
except ValueError:
|
|
||||||
ui.label("Job not found")
|
|
||||||
ui.link("Back to jobs", "/jobs")
|
|
||||||
return
|
|
||||||
|
|
||||||
transcripts = list(await transcription_service.list_transcripts_by_job(parsed_id))
|
with ui.splitter(value=30).classes("w-full h-[calc(100vh-64px)]") as splitter:
|
||||||
render_job_detail(job=job, document=job.document, transcripts=transcripts)
|
with splitter.before, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
|
||||||
|
render_document_panzoom(document=job.document)
|
||||||
|
with splitter.after, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
|
||||||
|
with ui.row():
|
||||||
|
ui.button(icon="arrow_back", on_click=ui.navigate.back)
|
||||||
|
with ui.row().classes("w-full items-center justify-between"):
|
||||||
|
ui.label(f"{job.id}").classes("text-h6 text-weight-bold")
|
||||||
|
match job.status:
|
||||||
|
case JobStatus.TRANSCRIBED:
|
||||||
|
ui.chip(job.status.value.upper(), color="green", text_color="white").props("outline")
|
||||||
|
case _:
|
||||||
|
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
|
||||||
|
for i, transcript in enumerate(job.transcripts):
|
||||||
|
render_transcript_revision_row(transcript=transcript, initially_expanded=(i == 0))
|
||||||
|
|||||||
Reference in New Issue
Block a user