V4.10
Quality Gate / gate (push) Failing after 11s

This commit is contained in:
Jim Lancaster
2026-08-22 10:19:30 -05:00
parent bf2f3ac09c
commit cf49c3c127
35 changed files with 1029 additions and 161 deletions
@@ -0,0 +1,35 @@
---
description: Require documentation updates whenever code changes alter contracts, behavior, or scope.
applyTo: 'src/transcription/**/*.py'
---
# Documentation Sync Requirements
Keep docs in sync in the same change whenever implementation alters a documented contract, behavior, or roadmap decision.
## Update documentation when any of these change
1. **Schema/Data contract**
- Models, fields, enums, constraints, indexes, relationships, loading semantics.
- **Required doc update:** `docs/schema.md`.
2. **Configuration contract**
- `Settings` keys, defaults, required/optional environment values.
- **Required doc update:** `.env.example` and any directly related setup docs.
3. **User-visible UI behavior**
- Page flow, routes, button/action behavior, labels, status wording, empty/error states.
- **Required doc update:** relevant `docs/ui/pages/*.md` docs and feature docs when applicable.
4. **Error handling semantics**
- Error categories, retry behavior, envelope structure, translation boundaries.
- **Required doc update:** `docs/error_handling.md` and `docs/invariant/error_handling.md`.
5. **Roadmap/scope decisions**
- Version targets, sequencing, deferrals, and accepted alternatives.
- **Required doc update:** `docs/roadmap_plan.md` and related backlog docs (for example `docs/ver4.8/feature_backlog_v4_8.md`).
## Working rule
If none of the categories above changed, documentation edits are optional.
If any category changed, update docs in the same PR/change set rather than deferring.
@@ -134,6 +134,13 @@ Atomicity rules:
- If model fields, enums, constraints, indexes, or relationship-loading semantics change, update `docs/schema.md` in the same change. - If model fields, enums, constraints, indexes, or relationship-loading semantics change, update `docs/schema.md` in the same change.
- If `Settings` fields or defaults change in `src/transcription/config.py`, update `.env.example` in the same change so keys/defaults remain synchronized and no stale settings remain documented. - If `Settings` fields or defaults change in `src/transcription/config.py`, update `.env.example` in the same change so keys/defaults remain synchronized and no stale settings remain documented.
## Schema Drift and Legacy Compatibility Policy
- Prefer schema migration or startup reconciliation over runtime compatibility paths in service writes.
- Do not add legacy read/write compatibility code in service workflows by default.
- If drift is discovered and a migration decision is ambiguous (for example, one-way destructive DDL, uncertain data retention impact, or unknown deployment sequence), pause and ask the user to choose migration vs compatibility before coding.
- If a temporary compatibility path is explicitly approved, document an expiration/removal plan in the same change.
# Service Composition # Service Composition
A service method may read across models it does not own, using eager loads from its own A service method may read across models it does not own, using eager loads from its own
+212
View File
@@ -0,0 +1,212 @@
# Roadmap Review: Recommendations & Version Plan
This is a review/consulting deliverable. It organizes your near-term changes,
nice-to-haves, and long-range goals into version buckets, building on
`./docs/ver4.8/feature_backlog_v4_8.md` (already-scoped image work) and
folding in the additional items from your original list.
Versioning convention (per your direction): **4.84.11** are incremental
feature/fix releases; **5.x and 6.x are reserved for major changes** to the
app (a shared data-model overhaul, and the hosting migration, respectively).
Final deliverable will be saved to `./docs` in the repo per your instruction.
---
## V4.8 — Bug fixes + Image Experience
**Bug fixes (do first within this release — data correctness, not features):**
1. **Stale Error Detail after resubmit.** Root cause found in `db/models.py`:
`Source.latest_error_detail` sorts all `execution_attempts` on the latest
job by `attempt_number` descending and returns the **first attempt with
any `error_detail`**, even if that's an older attempt and the latest
attempt succeeded. Fix: only report error detail from the latest attempt,
don't fall through to earlier ones.
2. **"View Jobs" ≠ "View Sources" behavior.** `/documents/{id}/sources`
already redirects to the filtered Transcription Pipeline Jobs page;
`/documents/{id}/jobs` renders its own bare two-field custom page instead.
Fix: make `/documents/{id}/jobs` redirect the same way, filtered by document.
**Image Experience (Track A from the V4.8 backlog doc — already scoped, high
value/low risk, storage layer already exists):**
3. **Homepage Image Gallery** (recommended first feature in that doc):
`list_homepage_images()` already returns every stored image; only the
multi-image carousel UI and optional slideshow rotation are missing.
4. **Pan and Zoom on Source Detail**: recover `document_panzoom.py` from git
history (deleted in V4.6 Phase 5, `6a3ee26`), but vendor the Panzoom
library locally instead of the unpkg CDN, and reuse `resolve_media_url`
from `media_urls.py` instead of recreating its old helper. Scope to Source
Detail only — do not add it to the shared `dark_room_viewer`.
**Note:** the backlog doc's model-performance/telemetry item still applies as
stated there — it must wait for V4.7 Phase 4 to land first, since `duration_ms`
today mixes provider latency with preprocessing/DB commit time.
---
## V4.9 — Detail Page Parity Pass
Small, low-risk structural-parity fixes across Person/Job/Document detail pages:
- Hide "Maiden Name" on Person detail when empty.
- Job Record detail "Document Links" box → mirror the Document detail page's
"Sources & Pipeline Jobs" box (clickable name, Sources count, single
"View Sources" button, no "+Add Job").
- Transcription Pipeline Jobs page: add a Document Name column; resolve
Created vs. Updated by keeping **Updated** as the primary/visible field
(more actionable) and de-emphasizing Created rather than deleting it outright.
- Person detail "Linked Documents" → 3-column table (Document Name, Role,
Number of Pages), with clickable document names, dropping the "Open" button.
---
## V4.10 — Settings Consolidation & Small Enhancements
- **Settings page → tabs**, not sub-pages (per your confirmation). Convert the
existing stacked cards (Document Types, Person Roles, Prompts) into
`ui.tabs`/`ui.tab_panels`, and add a new **Home Page Text** tab. One route,
no navigation overhead, scales cleanly as more settings are added.
- **Google Maps links** for Person birth/death locations — cheap, no
scraping, a formatted place string becomes a Maps search link.
- **FamilySearch ID lookup** (scope confirmed as narrow): on the Person
create/edit form, add a FamilySearch ID field and an "Auto-fill from
FamilySearch" button that fetches only birth date/place, death date/place,
and marriage date/spouse for that specific ID — a single-record lookup, not
a crawler. This keeps FamilySearch.org as a companion reference rather than
a data source the app tries to replace. Because it's ID-driven and
single-record, it's a much smaller, safer feature than open-ended scraping —
worth doing at this scope, revisit if FamilySearch's page structure changes
and breaks the parser.
---
## V4.11 — Bigger UI Features
- **Tags** (supersedes "collections"): many-to-many tagging for documents,
reusing the Settings registry pattern (autocomplete against existing tags,
managed like Document Types/Person Roles). Since you want dedicated UI to
browse/filter by tag, include a tag-filter view (e.g. a "Tags" entry point
showing documents grouped/filterable by tag) as part of this version, not
deferred — that's the UI surface that makes tags actually replace collections
day-to-day, not just a data field.
- **UI theme selection**: kept simple, per your confirmation — 24 curated
themes (e.g. Archival/Sepia, Light, Dark, High-contrast), a single stored
preference, swapped via existing CSS variables/Tailwind tokens in
`theme.py`. No open-ended theme builder.
- **Edit Transcription sub-page**: source image + transcription text +
editable revision side-by-side, keeping the existing Save Revision/Reset
buttons. You noted the exact interaction/layout needs to be clarified when
this is scoped — treat this as a design-first item: confirm the two/three
column breakpoint behavior and what happens on narrow windows before
building.
- **Source detail page reflow** (move Candidate Machine Transcriptions to the
bottom of the image column; align Source Metadata's top edge with
Transcription Text): do this *after* the Edit Transcription sub-page above,
since that page will reuse/rearrange the same boxes — avoids reflowing twice.
---
## V5.0 — Unified Photos Table (major data-model change)
Per your feedback, this replaces the earlier "Track B" idea with a single
shared model: a **`photos` table** holding an image reference plus a text
`description` field, used by **both** the homepage gallery and Person
portraits — rather than building two separate, one-off metadata layers.
Rough shape (subject to the further discussion you flagged before any
implementation):
- `photos`: id, image path/reference, description, and whatever
owner/context linkage is needed (e.g. a polymorphic or nullable
`person_id` plus a `context`/`kind` discriminator such as `"homepage"` vs
`"portrait"` — exact shape is an open design question, not decided here).
- Person gets **multiple portraits** instead of the current single
`portrait_path` string column — needs a migration to move existing
single-portrait data into the new table.
- Homepage images gain **descriptions**, using the same table instead of a
separate sidecar/caption mechanism.
- Because this is a genuine shared-schema decision (one table, two
consumers, plus a migration of existing single-portrait data), this is
correctly a 5.x "major change" rather than a small add-on — **flagged for
a follow-up design discussion before implementation starts**, specifically
on the exact `photos` table shape and how "which photos belong to which
person/context" should be modeled.
---
## V6.0 — Server Hosting Migration
Your stated approach (Postgres in Docker, app in Docker, Cloudflare Tunnel) is
the standard, low-maintenance way to get secure remote access without exposing
ports or running your own VPN/reverse-proxy TLS setup. Sequential stages
(each de-risks the next):
**Stage 0 — Prerequisites**
- Confirm data access already goes through the service layer (it does, per
`.github/instructions/services.instructions.md`) — this is what makes the
DB swap and later auth additive rather than a rewrite.
- Confirm upload/file storage path (`settings.upload_dir`) is Docker-volume-friendly.
- Confirm DB URL and upload dir are both env-overridable (`.env`/`.env.example`
already exist).
**Stage 1 — Containerize** against the *existing* SQLite file first (smallest
possible change) to validate the container boundary (networking, volumes,
permissions) before also switching databases. `Dockerfile`/`docker-compose.yml`
already exist in the repo — confirm current intent vs. extend for production.
**Stage 2 — Migrate to PostgreSQL.** Since the app uses SQLModel/SQLAlchemy,
this should mostly be a connection string/dialect change plus a data migration
script. Do this as its own isolated step so a regression is attributable to
the DB swap alone. Resolve the Postgres connection through a small
factory/indirection point rather than a single global engine constant — this
costs nothing now and keeps the door open for per-user databases later (see
Beyond, below).
**Stage 3 — Expose via Cloudflare Tunnel.** Add a `cloudflared` container
pointed at the app's internal port (no public port exposure needed). Put the
tunnel hostname behind Cloudflare Access (free tier, email OTP/SSO) as the
**first layer of remote-access auth**, even before the app has its own login
system — gets secure remote access working quickly and buys time to do
Stage 4 and later user-auth properly instead of rushed.
**Stage 4 — Backups & operational hygiene.** Automated Postgres backups
(scheduled `pg_dump`) and uploaded-file backups *before* exposing this to the
internet — this is the point where a data-loss incident would be most
damaging. Basic container health checks/restart policies in compose.
---
## V6.1 — Reporting Features
Independent of hosting — can be built any time, including in parallel with
V6.0:
- **Person timelines**: a query/aggregation feature (documents by person,
ordered by document date), no hosting dependency.
- **AI-written biographies/family histories**: likely reuses the existing
prompt/provider abstraction (`services/prompts.py`) already built for
transcription, rather than needing new infrastructure.
---
## Beyond / Pie-in-the-sky (informational only — no version assigned)
Not being built now, but the plan above is checked against these so nothing
forecloses them later:
- **Individual user logins + roles** (admin/editor/contributor/view-only):
Cloudflare Access (V6.0 Stage 3) is a stopgap, not this. When you get here,
add a real `User`/`Role` model and auth middleware. Because the codebase
already funnels data access through the service layer, adding a
"current user + role check" later is additive — this is the reason to keep
service-layer discipline intact through all the versions above, so
authorization can be bolted on without touching every page.
- **Per-user databases**: protected for by the Stage 2 connection-factory
recommendation above (schema-per-tenant or database-per-tenant becomes
much easier if the DB connection is already resolved through an
indirection point rather than hardcoded at startup).
---
## Open items for you
- V5.0 unified `photos` table: needs the follow-up design discussion you
flagged (exact schema, how photos link to homepage vs. person context)
before implementation.
- Confirm this version numbering/grouping matches your intent before work starts.
+3
View File
@@ -177,6 +177,9 @@ Index:
Constraint: Constraint:
- `UniqueConstraint(job_id, source_id)` named `uq_job_source_job_source` - `UniqueConstraint(job_id, source_id)` named `uq_job_source_job_source`
Runtime reconciliation:
- Startup database operations remove retired V4.6 `job_source` evidence columns (`raw_transcription`, `ai_metadata`, `raw_api_response`, `error_detail`, `executed_at`) when present so persisted schema matches this contract.
### `ExecutionAttempt` ### `ExecutionAttempt`
| Field | Type | Notes | | Field | Type | Notes |
+1
View File
@@ -21,6 +21,7 @@ The application root and `/ui` redirect to `/ui/homepage`.
- Missing text displays `No homepage text saved yet.` - Missing text displays `No homepage text saved yet.`
- Missing image displays the viewer's empty state. - Missing image displays the viewer's empty state.
- **Edit Home Page** opens the edit route. - **Edit Home Page** opens the edit route.
- The same Home Text content is also editable from **Settings → Home Page Text**.
## Edit Behavior ## Edit Behavior
+4 -3
View File
@@ -19,8 +19,9 @@ Jobs manages transcription processing runs. A Job belongs to one Document, links
- The title is **Transcription Pipeline Jobs**. - The title is **Transcription Pipeline Jobs**.
- **Create job** opens Job creation and **Refresh** reloads the table. - **Create job** opens Job creation and **Refresh** reloads the table.
- Columns are Job ID, Status, Source Filename, Retries, Created, and Updated. - Columns are Job ID, Status, Document Name, Retries, and Updated.
- Search covers Job ID, filename, and status. - Updated is the primary date/sort field.
- Search covers Job ID, document name, and status.
- Status is displayed as a semantic status chip. - Status is displayed as a semantic status chip.
- Selecting a row opens Job Detail. - Selecting a row opens Job Detail.
- No records displays `No job records found in repository.` - No records displays `No job records found in repository.`
@@ -43,7 +44,7 @@ Jobs manages transcription processing runs. A Job belongs to one Document, links
- The heading shows Job ID and a status badge. - The heading shows Job ID and a status badge.
- Execution Logistics shows provider, model, prompt, retry count, and last update. - Execution Logistics shows provider, model, prompt, retry count, and last update.
- Document Links open the parent Document and Job-filtered Sources. - Document Links show a clickable Document Name, Sources count, and a single **View Sources** action using document filtering.
- Queued and processing Jobs show an auto-refresh notice and reload every four seconds. - Queued and processing Jobs show an auto-refresh notice and reload every four seconds.
- Polling stops when the Job becomes terminal or a refresh fails. - Polling stops when the Job becomes terminal or a refresh fails.
- Queued and processing Jobs expose **Cancel**. - Queued and processing Jobs expose **Cancel**.
+5 -2
View File
@@ -54,9 +54,12 @@ Rules:
- The header provides **New Document**, **Edit Person**, and **Delete**. - The header provides **New Document**, **Edit Person**, and **Delete**.
- **New Document** opens Document creation with this Person requested for author preselection. - **New Document** opens Document creation with this Person requested for author preselection.
- The portrait viewer resolves supported relative upload paths and absolute HTTP/data URLs. - The portrait viewer resolves supported relative upload paths and absolute HTTP/data URLs.
- Biographical Record shows names, compact birth/death dates, places, and an **Open in FamilySearch** link when an ID exists. - Biographical Record shows names, compact birth/death dates, and places.
- Birth and death place values are clickable links to Google Maps when present.
- FamilySearch ID is shown as a metadata value and is clickable to the FamilySearch person details route when present.
- Maiden Name is only shown in Biographical Record when a value exists.
- Biography has an explicit empty value. - Biography has an explicit empty value.
- Linked Documents show Document name, relationship role, and an action to open Document Detail. - Linked Documents render as a table with **Document Name**, **Role**, and **Number of Pages**; selecting a row opens Document Detail.
- No links shows both an empty state and guidance to link from a Document workflow. - No links shows both an empty state and guidance to link from a Document workflow.
- System Logistics shows created and updated timestamps. - System Logistics shows created and updated timestamps.
+35
View File
@@ -0,0 +1,35 @@
# Settings Page Contract
## Purpose
Settings manages installation-local registries and editable text assets from one route.
## Route
| Route | Purpose |
| --- | --- |
| `/settings` | Manage Document Types, Person Roles, Prompts, and Home Page Text. |
## Behavior
- The page title is **Settings**.
- Configuration surfaces are grouped as tabs:
- **Document Types**
- **Person Roles**
- **Prompts**
- **Home Page Text**
- Document Types and Person Roles support Add/Edit/Delete with existing guardrails.
- Prompts exposes only `transcribe_document.md` for editing and restore-from-backup.
- Home Page Text edits the same Markdown content rendered on `/homepage`.
## Acceptance Checklist
- `/ui/settings` renders all four tabs.
- Registry and prompt workflows keep existing validation and error handling.
- Saving Home Page Text persists content for the homepage view.
## Implementation Anchors
- `src/transcription/ui/pages/settings_page.py`
- `src/transcription/ui/homepage_store.py`
- `tests/ui/test_pages_registration.py`
-25
View File
@@ -64,24 +64,6 @@ Homepage images are **filesystem-only with no metadata store**, so a caption has
This is cheap **only if** item 3 is being done at the same time, since both need the same metadata layer. Designing that layer twice would be wasteful; design it once or not at all. This is cheap **only if** item 3 is being done at the same time, since both need the same metadata layer. Designing that layer twice would be wasteful; design it once or not at all.
### 5. Model-Performance Rollup (V4.6 review log [54])
**Practicality: high, but blocked. Effort: M.**
Run-time telemetry is already captured and is per page: `execution_attempt.duration_ms` is a required non-null field written on all three paths in `workflows.py` (success 278, `TimeoutError` 295, general failure 330), with failures using a monotonic clock. Verified against the live database: 80 rows across 80 distinct (job, source, attempt) combinations, one row per page - the largest job has 60 attempts across 60 distinct pages - and zero nulls. Token counts live on the same row in `normalized_metadata.usage`, so tokens-per-second is already derivable without a join.
What is missing is **aggregation**. The figure is visible only for the latest attempt of one source at a time (`sources_page.py:400`), rendered raw as `"27612 ms"`. There is no rollup by model, prompt, or document.
The gap is concrete: calibrating the provider timeout on 2026-08-18 required hand-written SQL against the database, because the application could not answer "which model is slow."
Proposed shape: median / p95 / max duration, tokens per second, and a timeout rate, grouped by model. **Blocked on V4.7 Phase 4.**
### 6. Desaturated Background Wallpaper
**Practicality: low. Recommendation: do not build, or gate behind a setting defaulted off.**
Trivial to implement (`ui.add_css` with a CSS `filter`), but this is a dense archival data application - transcripts, JSON evidence panels, data tables. A background image behind all of that costs contrast and legibility on every page, for aesthetic gain only.
## Suggested Grouping ## Suggested Grouping
If V4.8 is scoped as one release, the natural split is: If V4.8 is scoped as one release, the natural split is:
@@ -98,12 +80,5 @@ Item 6 is not recommended.
- Should Track B happen at all, or is one portrait per person sufficient? - Should Track B happen at all, or is one portrait per person sufficient?
- Should the slideshow interval be configurable, or fixed? - Should the slideshow interval be configurable, or fixed?
- Should the model-performance rollup be its own page, or a panel on an existing one?
- Should vendored Panzoom be committed to the repository, or fetched at build time? - Should vendored Panzoom be committed to the repository, or fetched at build time?
## Related Local References
- [Architecture & Code Review Report](../architecture_code_review_2026-08-17.md)
- `.github/instructions/ui.instructions.md`
- `src/transcription/ui/homepage_store.py` - existing multi-image storage
- `src/transcription/ui/components/media_urls.py` - canonical URL resolution
+2
View File
@@ -25,6 +25,7 @@ from .db import create_all
from .db import dispose_database_runtime from .db import dispose_database_runtime
from .db import initialize_database_runtime from .db import initialize_database_runtime
from .db import normalize_legacy_status_spellings from .db import normalize_legacy_status_spellings
from .db import reconcile_legacy_job_source_columns
from .services import ServiceBundle from .services import ServiceBundle
from .ui import register_pages from .ui import register_pages
from .worker import worker_consumer_lifespan from .worker import worker_consumer_lifespan
@@ -43,6 +44,7 @@ async def _lifespan(app: FastAPI):
if settings.should_bootstrap_schema: if settings.should_bootstrap_schema:
await create_all(engine=app.state.runtime.engine) await create_all(engine=app.state.runtime.engine)
await reconcile_legacy_job_source_columns(engine=app.state.runtime.engine)
await normalize_legacy_status_spellings(engine=app.state.runtime.engine) await normalize_legacy_status_spellings(engine=app.state.runtime.engine)
settings.upload_dir.mkdir(parents=True, exist_ok=True) settings.upload_dir.mkdir(parents=True, exist_ok=True)
+2
View File
@@ -1,5 +1,6 @@
from .operations import create_all from .operations import create_all
from .operations import normalize_legacy_status_spellings from .operations import normalize_legacy_status_spellings
from .operations import reconcile_legacy_job_source_columns
from .runtime import dispose_database_runtime from .runtime import dispose_database_runtime
from .runtime import initialize_database_runtime from .runtime import initialize_database_runtime
from .session import session_scope from .session import session_scope
@@ -10,6 +11,7 @@ __all__ = [
"dispose_database_runtime", "dispose_database_runtime",
"initialize_database_runtime", "initialize_database_runtime",
"normalize_legacy_status_spellings", "normalize_legacy_status_spellings",
"reconcile_legacy_job_source_columns",
"session_scope", "session_scope",
"transaction_scope", "transaction_scope",
] ]
+3 -3
View File
@@ -345,10 +345,10 @@ class Source(SQLModel, table=True):
if latest is None: if latest is None:
return None return None
attempts = _loaded_attribute(latest, "execution_attempts") or () attempts = _loaded_attribute(latest, "execution_attempts") or ()
for attempt in sorted(attempts, key=lambda item: item.attempt_number, reverse=True): if not attempts:
if attempt.error_detail:
return attempt.error_detail
return None return None
latest_attempt = max(attempts, key=lambda item: item.attempt_number)
return latest_attempt.error_detail
@property @property
def document_name(self) -> str | None: def document_name(self) -> str | None:
+41
View File
@@ -73,6 +73,47 @@ async def normalize_legacy_status_spellings(*, engine: AsyncEngine | None = None
return fixed_rows return fixed_rows
LEGACY_JOB_SOURCE_COLUMNS = (
"raw_transcription",
"ai_metadata",
"raw_api_response",
"error_detail",
"executed_at",
)
async def reconcile_legacy_job_source_columns(*, engine: AsyncEngine | None = None) -> int:
"""Remove stale V4.6 ``job_source`` evidence columns from existing databases.
Runtime models define ``job_source`` as a queue/projection table only. If an
older database still carries the retired evidence columns, writes can fail
on stale constraints (for example ``executed_at NOT NULL``).
"""
active_engine = engine or resolve_engine()
if not hasattr(active_engine, "begin"):
return 0
def _reconcile(sync_connection) -> int:
inspector = sqlalchemy_inspect(sync_connection)
table_names = set(inspector.get_table_names())
if "job_source" not in table_names:
return 0
present_columns = {column["name"] for column in inspector.get_columns("job_source")}
dropped = 0
for column_name in LEGACY_JOB_SOURCE_COLUMNS:
if column_name not in present_columns:
continue
sync_connection.execute(text(f'alter table "job_source" drop column "{column_name}"'))
dropped += 1
return dropped
async with active_engine.begin() as connection:
dropped_columns = await connection.run_sync(_reconcile)
if dropped_columns:
logger.warning("Dropped %s legacy job_source column(s) during startup reconciliation", dropped_columns)
return dropped_columns
async def seed_registry_defaults(*, engine: AsyncEngine | None = None) -> None: async def seed_registry_defaults(*, engine: AsyncEngine | None = None) -> None:
"""Seed default registry rows for role and document type taxonomies.""" """Seed default registry rows for role and document type taxonomies."""
active_engine = engine or resolve_engine() active_engine = engine or resolve_engine()
+2 -1
View File
@@ -13,6 +13,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..db.loading import orm_attribute from ..db.loading import orm_attribute
from ..db.loading import selectinload from ..db.loading import selectinload
from ..db.models import ExecutionAttempt from ..db.models import ExecutionAttempt
from ..db.models import Document
from ..db.models import Job from ..db.models import Job
from ..db.models import JobSource from ..db.models import JobSource
from ..db.models import JobSourceStatus from ..db.models import JobSourceStatus
@@ -65,7 +66,7 @@ class JobService(ServiceBase):
query = ( query = (
select(Job) select(Job)
.options( .options(
selectinload(Job.document), selectinload(Job.document).selectinload(orm_attribute(Document.sources)),
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)), selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
) )
.where(Job.id == job_id) .where(Job.id == job_id)
+3 -1
View File
@@ -202,7 +202,9 @@ class PeopleService(ServiceBase):
query = ( query = (
select(Person) select(Person)
.options( .options(
selectinload(Person.document_people).selectinload(orm_attribute(DocumentPerson.document)), selectinload(Person.document_people)
.selectinload(orm_attribute(DocumentPerson.document))
.selectinload(orm_attribute(Document.sources)),
selectinload(Person.document_people).selectinload(orm_attribute(DocumentPerson.role_ref)), selectinload(Person.document_people).selectinload(orm_attribute(DocumentPerson.role_ref)),
) )
.where(Person.id == person_id) .where(Person.id == person_id)
@@ -9,6 +9,13 @@ def metadata_row(label: str, value: str):
ui.label(value).classes("font-semibold ui-text-primary") ui.label(value).classes("font-semibold ui-text-primary")
def metadata_link_row(label: str, value: str, url: str, *, new_tab: bool = True):
"""Render a metadata row where the value is a clickable link."""
with ui.row().classes("justify-between w-full border-b ui-border-subtle pb-1 text-xs"):
ui.label(label).classes("ui-text-muted")
ui.link(value, url, new_tab=new_tab).classes("font-semibold ui-link-primary")
def archival_badge(text: str): def archival_badge(text: str):
"""Standardized Aged Sepia badge.""" """Standardized Aged Sepia badge."""
return ui.badge(text).classes("text-[10px] ui-badge-secondary") return ui.badge(text).classes("text-[10px] ui-badge-secondary")
@@ -0,0 +1,113 @@
"""Panzoom-backed media preview component for source detail."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from uuid import uuid4
from nicegui import ui
from transcription.ui.resources import read_js
def render_document_panzoom(*, media_url: str | None, filename: str, count_label: str = "1 Source Linked") -> None:
"""Render source media with pan/zoom interactions."""
del count_label
if not media_url:
with ui.column().classes(
"w-full items-center justify-center border ui-border-viewer "
"ui-bg-viewer-overlay-soft rounded-sm p-8 ui-media-placeholder"
):
ui.label("No source media available for inspection.").classes("ui-text-muted text-xs italic")
return
_register_panzoom_assets()
host_id = f"document-panzoom-{uuid4().hex}"
media_kind = "pdf" if Path(filename).suffix.lower() == ".pdf" else "image"
with ui.column().classes("w-full gap-2"):
with ui.row().classes("w-full items-center justify-between no-wrap"):
ui.label(filename).classes("text-xs ui-text-muted ellipsis document-panzoom-filename")
ui.label("Use mouse wheel to zoom and drag to pan.").classes("text-xs ui-text-muted")
with ui.element("div").classes("w-full document-panzoom-host") as host:
host.props(f"id={host_id}")
with ui.element("div").classes("document-panzoom-surface"):
if media_kind == "pdf":
ui.html(
f'<iframe class="document-panzoom-iframe" src="{media_url}" title="{filename}" '
"data-panzoom-target></iframe>"
)
else:
ui.html(
f'<img class="document-panzoom-media" src="{media_url}" alt="{filename}" '
"data-panzoom-target data-panzoom-media />"
)
_attach_panzoom(host_id=host_id)
@lru_cache(maxsize=1)
def _register_panzoom_assets() -> None:
ui.add_head_html(f"<script>{read_js('vendor/panzoom.min.js')}</script>", shared=True)
def _attach_panzoom(*, host_id: str) -> None:
ui.run_javascript(
f"""
(function() {{
if (!window.Panzoom) return;
window.__transcriptionPanzoom = window.__transcriptionPanzoom || {{}};
const host = document.getElementById({host_id!r});
if (!host) return;
const target = host.querySelector('[data-panzoom-target]');
const media = host.querySelector('[data-panzoom-media]');
if (!target) return;
const cleanup = () => {{
const existing = window.__transcriptionPanzoom[{host_id!r}];
if (existing?.resizeObserver) existing.resizeObserver.disconnect();
if (existing?.wheelHandler) host.removeEventListener('wheel', existing.wheelHandler);
if (existing?.instance) existing.instance.destroy();
}};
const buildInstance = () => {{
cleanup();
if (media && media.naturalWidth > 0 && media.naturalHeight > 0) {{
host.style.setProperty('--panzoom-media-aspect', `${{media.naturalWidth}} / ${{media.naturalHeight}}`);
}}
const instance = Panzoom(target, {{
maxScale: 256,
minScale: 1,
step: 0.2,
roundPixels: false,
panOnlyWhenZoomed: true,
overflow: 'hidden',
}});
const wheelHandler = (event) => instance.zoomWithWheel(event);
host.addEventListener('wheel', wheelHandler, {{ passive: false }});
const resizeObserver = new ResizeObserver(() => instance.reset({{ animate: false }}));
resizeObserver.observe(host);
window.__transcriptionPanzoom[{host_id!r}] = {{
instance,
wheelHandler,
resizeObserver,
}};
}};
const initWhenReady = () => {{
if (media && media.tagName === 'IMG' && !media.complete) {{
media.addEventListener('load', buildInstance, {{ once: true }});
return;
}}
buildInstance();
}};
initWhenReady();
}})();
"""
)
@@ -1,6 +1,7 @@
"""Presentation-only formatting shared by archival UI surfaces.""" """Presentation-only formatting shared by archival UI surfaces."""
import re import re
from urllib.parse import quote_plus
from datetime import date from datetime import date
from uuid import UUID from uuid import UUID
@@ -60,3 +61,8 @@ def person_selector_label(person: Person) -> str:
def family_search_url(family_search_id: str) -> str: def family_search_url(family_search_id: str) -> str:
"""Build the fixed FamilySearch details URL for a validated identifier.""" """Build the fixed FamilySearch details URL for a validated identifier."""
return f"https://www.familysearch.org/tree/person/details/{family_search_id}" return f"https://www.familysearch.org/tree/person/details/{family_search_id}"
def google_maps_search_url(place: str) -> str:
"""Build a Google Maps search URL for a place label."""
return f"https://www.google.com/maps/search/?api=1&query={quote_plus(place)}"
+14 -14
View File
@@ -23,7 +23,7 @@ class JobTableRow:
id: UUID id: UUID
status: str status: str
filename: str document_name: str
retry_count: int retry_count: int
date_created: str date_created: str
date_updated: str date_updated: str
@@ -44,7 +44,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
{ {
"id": str(row.id), "id": str(row.id),
"status": row.status.lower(), "status": row.status.lower(),
"filename": row.filename, "document_name": row.document_name,
"retry_count": row.retry_count, "retry_count": row.retry_count,
"date_created": _format_timestamp(row.date_created), "date_created": _format_timestamp(row.date_created),
"date_updated": _format_timestamp(row.date_updated), "date_updated": _format_timestamp(row.date_updated),
@@ -71,6 +71,7 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"field": "id", "field": "id",
"sortable": True, "sortable": True,
"classes": "font-mono text-xs", "classes": "font-mono text-xs",
"style": "width: 30%;",
}, },
{ {
"name": "status", "name": "status",
@@ -78,36 +79,35 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"field": "status", "field": "status",
"sortable": True, "sortable": True,
"classes": "font-mono", "classes": "font-mono",
"style": "width: 15%;",
}, },
{ {
"name": "filename", "name": "document_name",
"label": "Source Filename", "label": "Document Name",
"field": "filename", "field": "document_name",
"sortable": True, "sortable": True,
"classes": "font-mono text-xs", "classes": "font-serif text-left ui-table-cell-wrap",
"align": "left",
"style": "width: 35%;",
}, },
{ {
"name": "retry_count", "name": "retry_count",
"label": "Retries", "label": "Retries",
"field": "retry_count", "field": "retry_count",
"sortable": True, "sortable": True,
}, "style": "width: 8%;",
{
"name": "date_created",
"label": "Created",
"field": "created_sort",
"sortable": True,
}, },
{ {
"name": "date_updated", "name": "date_updated",
"label": "Updated", "label": "Updated",
"field": "updated_sort", "field": "updated_sort",
"sortable": True, "sortable": True,
"style": "width: 12%;",
}, },
], ],
default_sort_by="created_sort", default_sort_by="updated_sort",
default_descending=True, default_descending=True,
search_placeholder="Search jobs by ID, filename, or status...", search_placeholder="Search jobs by ID, document, or status...",
on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"), on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"),
) )
+4 -48
View File
@@ -242,58 +242,14 @@ def register_page() -> None: # noqa: PLR0915
_render_bento_relations_zone(document) _render_bento_relations_zone(document)
@ui.page("/documents/{document_id}/jobs") @ui.page("/documents/{document_id}/jobs")
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None: async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
document_service = DocumentService(session_factory=session_factory) _ = session_factory
render_navigation_header(current_path="/documents") return RedirectResponse(url=f"/ui/jobs?document_id={document_id}")
parsed_doc_id = parsed_record_id(document_id, noun="Document")
if parsed_doc_id is None:
return
try:
document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError:
render_record_not_found("Document")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.jobs")
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
with section_header_row():
page_header(f"Jobs for {document.name}")
with ui.row().classes("gap-2"):
ui.button(
"Back to Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
icon="arrow_back",
).props("flat")
ui.button(
"Create Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("ui-btn-primary")
if not document.jobs:
with archival_card(extra_classes="p-6 text-center"):
render_empty_state("No transcription processing jobs created yet.")
return
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
with archival_card(extra_classes="p-3"), ui.row().classes("w-full items-center justify-between"):
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value)
ui.label(f"Job ID: {job.id}").classes("text-xs font-mono ui-text-primary")
ui.button(
"Open Job",
on_click=lambda _=None, jid=job.id: ui.navigate.to(f"/jobs/{jid}"),
icon="open_in_new",
).props("flat dense").classes("text-xs ui-link-primary")
@ui.page("/documents/{document_id}/sources") @ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse: async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
_ = session_factory _ = session_factory
return RedirectResponse(url=f"/sources?document_id={document_id}") return RedirectResponse(url=f"/ui/sources?document_id={document_id}")
@ui.page("/documents/{document_id}/edit") @ui.page("/documents/{document_id}/edit")
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None: async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
+102 -8
View File
@@ -2,6 +2,9 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from nicegui import events from nicegui import events
from nicegui import ui from nicegui import ui
@@ -12,17 +15,78 @@ from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.upload_panel import IMAGE_UPLOAD_EXTENSIONS from transcription.ui.components.upload_panel import IMAGE_UPLOAD_EXTENSIONS
from transcription.ui.components.upload_panel import render_upload_picker from transcription.ui.components.upload_panel import render_upload_picker
from transcription.ui.components.viewers import dark_room_viewer from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.homepage_store import latest_homepage_image from transcription.ui.homepage_store import list_homepage_images
from transcription.ui.homepage_store import read_homepage_markdown from transcription.ui.homepage_store import read_homepage_markdown
from transcription.ui.homepage_store import save_homepage_markdown from transcription.ui.homepage_store import save_homepage_markdown
from transcription.ui.homepage_store import store_homepage_image from transcription.ui.homepage_store import store_homepage_image
from transcription.ui.theme import page_header from transcription.ui.theme import page_header
def _render_homepage_view(*, markdown_text: str, image_path) -> None: def _shift_gallery_index(*, image_paths: list[Path], active_index: list[int], step: int) -> None:
if len(image_paths) < 2:
active_index[0] = 0
return
active_index[0] = (active_index[0] + step) % len(image_paths)
def _render_homepage_gallery(
*,
image_paths: list[Path],
active_index: list[int],
enable_rotation: bool = False,
rotate_enabled: list[bool] | None = None,
on_change: Callable[[], None] | None = None,
) -> None:
if not image_paths:
render_empty_state("No homepage image uploaded yet.")
return
if active_index[0] >= len(image_paths):
active_index[0] = len(image_paths) - 1
if active_index[0] < 0:
active_index[0] = 0
current_path = image_paths[active_index[0]]
dark_room_viewer(str(current_path), count_label="Homepage Image")
def move(step: int) -> None:
_shift_gallery_index(image_paths=image_paths, active_index=active_index, step=step)
if on_change is not None:
on_change()
with ui.row().classes("w-full items-center justify-between mt-2"):
previous = ui.button(
"Previous",
on_click=lambda: move(-1),
icon="chevron_left",
).props("flat dense")
following = ui.button(
"Next",
on_click=lambda: move(1),
icon="chevron_right",
).props("flat dense icon-right")
if len(image_paths) < 2:
previous.props("disable")
following.props("disable")
ui.label(f"{active_index[0] + 1} of {len(image_paths)}").classes("text-xs ui-text-muted")
if enable_rotation and rotate_enabled is not None:
def set_rotation(enabled: bool) -> None:
rotate_enabled[0] = enabled
if on_change is not None:
on_change()
ui.checkbox(
"Rotate every 10 minutes",
value=rotate_enabled[0],
on_change=lambda event: set_rotation(bool(event.value)),
).classes("text-xs")
def _render_homepage_view(*, markdown_text: str, render_image_panel: Callable[[], None]) -> None:
with ui.grid().classes("w-full grid-cols-12 gap-4"): with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-4"): with ui.column().classes("col-span-12 lg:col-span-4"):
dark_room_viewer(str(image_path) if image_path else None, count_label="Homepage Image") render_image_panel()
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"), archival_card(title="Home Text"): with ui.column().classes("col-span-12 lg:col-span-5 gap-4"), archival_card(title="Home Text"):
if markdown_text: if markdown_text:
@@ -42,6 +106,7 @@ def _render_homepage_editor(*, render_image_panel, markdown_input, on_upload) ->
on_upload=on_upload, on_upload=on_upload,
label="Upload image", label="Upload image",
extensions=IMAGE_UPLOAD_EXTENSIONS, extensions=IMAGE_UPLOAD_EXTENSIONS,
multiple=True,
) )
render_image_panel() render_image_panel()
@@ -61,6 +126,17 @@ def register_page() -> None:
@ui.page("/homepage", title="VibeScribe Home") @ui.page("/homepage", title="VibeScribe Home")
def homepage_page() -> None: def homepage_page() -> None:
render_navigation_header(current_path="/homepage") render_navigation_header(current_path="/homepage")
image_paths = list_homepage_images()
active_index = [len(image_paths) - 1 if image_paths else 0]
@ui.refreshable
def render_image_panel() -> None:
with archival_card(title="Homepage Images"):
_render_homepage_gallery(
image_paths=image_paths,
active_index=active_index,
on_change=render_image_panel.refresh,
)
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"): with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row(): with section_header_row():
@@ -70,26 +146,44 @@ def register_page() -> None:
on_click=lambda: ui.navigate.to("/homepage/edit"), on_click=lambda: ui.navigate.to("/homepage/edit"),
icon="edit", icon="edit",
).classes("ui-btn-primary text-xs") ).classes("ui-btn-primary text-xs")
_render_homepage_view( _render_homepage_view(
markdown_text=read_homepage_markdown().strip(), markdown_text=read_homepage_markdown().strip(),
image_path=latest_homepage_image(), render_image_panel=render_image_panel,
) )
@ui.page("/homepage/edit", title="Edit Homepage") @ui.page("/homepage/edit", title="Edit Homepage")
def homepage_edit_page() -> None: def homepage_edit_page() -> None:
render_navigation_header(current_path="/homepage") render_navigation_header(current_path="/homepage")
preview_image = [latest_homepage_image()] preview_images = [*list_homepage_images()]
active_index = [len(preview_images) - 1 if preview_images else 0]
rotate_enabled = [False]
markdown_input = [None] markdown_input = [None]
@ui.refreshable @ui.refreshable
def render_image_panel() -> None: def render_image_panel() -> None:
dark_room_viewer(str(preview_image[0]) if preview_image[0] else None, count_label="Homepage Image") with archival_card(title="Homepage Images"):
_render_homepage_gallery(
image_paths=preview_images,
active_index=active_index,
enable_rotation=True,
rotate_enabled=rotate_enabled,
on_change=render_image_panel.refresh,
)
def rotate_gallery() -> None:
if not rotate_enabled[0]:
return
_shift_gallery_index(image_paths=preview_images, active_index=active_index, step=1)
render_image_panel.refresh()
ui.timer(interval=600, callback=rotate_gallery)
async def on_upload(event: events.UploadEventArguments) -> None: async def on_upload(event: events.UploadEventArguments) -> None:
payload = await event.file.read() payload = await event.file.read()
preview_image[0] = await store_homepage_image(filename=event.file.name, file_bytes=payload) stored = await store_homepage_image(filename=event.file.name, file_bytes=payload)
preview_images.append(stored)
active_index[0] = len(preview_images) - 1
ui.notify(f"Uploaded {event.file.name}", type="positive") ui.notify(f"Uploaded {event.file.name}", type="positive")
render_image_panel.refresh() render_image_panel.refresh()
+24 -13
View File
@@ -53,13 +53,16 @@ def register_page() -> None: # noqa: PLR0915
"""Register jobs list and detail routes.""" """Register jobs list and detail routes."""
@ui.page("/jobs") @ui.page("/jobs")
async def jobs_page(session_factory: SessionFactoryDep) -> None: async def jobs_page(session_factory: SessionFactoryDep, document_id: str | None = None) -> None:
jobs_service = JobService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory)
parsed_document_id = parse_uuid(document_id)
is_document_context = parsed_document_id is not None
render_navigation_header(current_path="/jobs") render_navigation_header(current_path="/jobs")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"): with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with section_header_row(): with section_header_row():
page_header("Transcription Pipeline Jobs") page_header("Jobs for Document" if is_document_context else "Transcription Pipeline Jobs")
if not is_document_context:
with ui.row().classes("items-center gap-2"): with ui.row().classes("items-center gap-2"):
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes( ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes(
"ui-btn-primary" "ui-btn-primary"
@@ -68,18 +71,21 @@ def register_page() -> None: # noqa: PLR0915
@ui.refreshable @ui.refreshable
async def render_table() -> None: async def render_table() -> None:
jobs = [ jobs = list(await jobs_service.list_jobs())
if parsed_document_id is not None:
jobs = [job for job in jobs if job.document_id == parsed_document_id]
rows = [
JobTableRow( JobTableRow(
id=job.id, id=job.id,
status=job.status.value, status=job.status.value,
filename=job.filename, document_name=job.document.name if job.document is not None else "Unknown document",
retry_count=job.retry_count, retry_count=job.retry_count,
date_created=job.date_created.isoformat(), date_created=job.date_created.isoformat(),
date_updated=job.date_updated.isoformat(), date_updated=job.date_updated.isoformat(),
) )
for job in await jobs_service.list_jobs() for job in jobs
] ]
render_jobs_table(jobs) render_jobs_table(rows)
await render_table() await render_table()
@@ -564,18 +570,23 @@ def _render_job_logistics(job: Job) -> None:
def _render_job_document_links(job: Job) -> None: def _render_job_document_links(job: Job) -> None:
with archival_card(title="Document Links"): with archival_card(title="Document Links"):
ui.label("Navigate to related archival records:").classes("text-xs ui-text-muted mb-3") document_name = job.document.name if job.document is not None else str(job.document_id)
with ui.column().classes("w-full gap-2"): source_count = len(job.document.sources) if job.document is not None else len(job.job_sources)
with ui.row().classes("w-full items-center justify-between gap-2"):
ui.label("Document Name:").classes("text-xs font-semibold ui-text-primary")
ui.button( ui.button(
"View Linked Document", document_name,
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"), on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
icon="description", icon="description",
).classes("ui-btn-primary text-xs w-full") ).props("flat dense no-caps").classes("ui-link-primary text-xs")
metadata_row("Sources:", str(source_count))
with ui.row().classes("w-full gap-2 mt-2"):
ui.button( ui.button(
"View Linked Sources", "View Sources",
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"), on_click=lambda: ui.navigate.to(f"/sources?document_id={job.document_id}"),
icon="description", icon="description",
).props("flat text-xs").classes("ui-link-primary w-full") ).props("flat dense text-xs").classes("ui-link-primary")
def _latest_prompt_name(job: Job) -> str | None: def _latest_prompt_name(job: Job) -> str | None:
+83 -22
View File
@@ -20,11 +20,13 @@ from transcription.services.people import store_person_portrait
from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card from transcription.ui.components.cards import archival_card
from transcription.ui.components.confirm_delete import render_delete_actions from transcription.ui.components.confirm_delete import render_delete_actions
from transcription.ui.components.data_display import metadata_link_row
from transcription.ui.components.data_display import metadata_row from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import run_ui_action from transcription.ui.components.error_presenter import run_ui_action
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.formatters import compact_date from transcription.ui.components.formatters import compact_date
from transcription.ui.components.formatters import family_search_url from transcription.ui.components.formatters import family_search_url
from transcription.ui.components.formatters import google_maps_search_url
from transcription.ui.components.formatters import parse_iso_date from transcription.ui.components.formatters import parse_iso_date
from transcription.ui.components.guards import parsed_record_id from transcription.ui.components.guards import parsed_record_id
from transcription.ui.components.guards import render_record_not_found from transcription.ui.components.guards import render_record_not_found
@@ -32,6 +34,7 @@ from transcription.ui.components.media_urls import resolve_media_url
from transcription.ui.components.primitives import destructive_button from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.common import build_table
from transcription.ui.components.table.people import PersonTableRow from transcription.ui.components.table.people import PersonTableRow
from transcription.ui.components.table.people import render_people_table from transcription.ui.components.table.people import render_people_table
from transcription.ui.components.upload_panel import IMAGE_UPLOAD_EXTENSIONS from transcription.ui.components.upload_panel import IMAGE_UPLOAD_EXTENSIONS
@@ -467,17 +470,32 @@ def _render_person_biographical_zone(person: Person) -> None:
with archival_card(title="Biographical Record"): with archival_card(title="Biographical Record"):
metadata_row("Full Name:", person.full_name) metadata_row("Full Name:", person.full_name)
metadata_row("Display Name:", person.display_name or "Not set") metadata_row("Display Name:", person.display_name or "Not set")
metadata_row("Maiden Name:", person.maiden_name or "Not set") if person.maiden_name:
metadata_row("Maiden Name:", person.maiden_name)
metadata_row("Birth Date:", compact_date(person.birth_date, person.birth_date_raw)) metadata_row("Birth Date:", compact_date(person.birth_date, person.birth_date_raw))
metadata_row("Birth Place:", person.birth_place or "Not set") if person.birth_place:
metadata_link_row(
"Birth Place:",
person.birth_place,
google_maps_search_url(person.birth_place),
)
else:
metadata_row("Birth Place:", "Not set")
metadata_row("Death Date:", compact_date(person.death_date, person.death_date_raw)) metadata_row("Death Date:", compact_date(person.death_date, person.death_date_raw))
metadata_row("Death Place:", person.death_place or "Not set") if person.death_place:
metadata_link_row(
"Death Place:",
person.death_place,
google_maps_search_url(person.death_place),
)
else:
metadata_row("Death Place:", "Not set")
if person.family_search_id: if person.family_search_id:
ui.link( metadata_link_row(
"Open in FamilySearch", "FamilySearch ID:",
person.family_search_id,
family_search_url(person.family_search_id), family_search_url(person.family_search_id),
new_tab=True, )
).classes("mt-2 text-xs font-semibold ui-link-primary")
with archival_card(title="System Logistics"): with archival_card(title="System Logistics"):
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted") ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
@@ -499,21 +517,64 @@ def _render_linked_documents(person: Person) -> None:
render_empty_state("Link this person from a Document workflow.") render_empty_state("Link this person from a Document workflow.")
return return
with ui.column().classes("w-full gap-2"): rows = sorted(
for link in person.document_people: (
doc = link.document {
if doc is None: "id": str(link.document.id),
continue "document_name": link.document.name,
role_label = link.role_ref.label if link.role_ref is not None else "Unknown role" "role": link.role_ref.label if link.role_ref is not None else "Unknown role",
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"): "page_count": len(link.document.sources),
with ui.column().classes("gap-0"): }
ui.label(doc.name).classes("text-xs font-semibold ui-text-primary") for link in person.document_people
ui.label(f"Role: {role_label}").classes("text-[10px] ui-text-muted") if link.document is not None
ui.button( ),
"Open", key=lambda row: str(row["document_name"]).casefold(),
on_click=lambda _=None, doc_id=doc.id: ui.navigate.to(f"/documents/{doc_id}"), )
icon="open_in_new",
).props("flat dense text-xs").classes("ui-link-primary") if not rows:
render_empty_state("No linked documents yet.", italic=True)
render_empty_state("Link this person from a Document workflow.")
return
table = build_table(
rows=rows,
columns=[
{
"name": "document_name",
"label": "Document Name",
"field": "document_name",
"sortable": True,
"classes": "text-left ui-table-cell-wrap",
"align": "left",
},
{
"name": "role",
"label": "Role",
"field": "role",
"sortable": True,
"classes": "text-left ui-table-cell-wrap",
"align": "left",
},
{
"name": "page_count",
"label": "Number of Pages",
"field": "page_count",
"sortable": True,
"align": "center",
},
],
default_sort_by="document_name",
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"),
show_search=False,
)
table.add_slot(
"body-cell-document_name",
r"""
<q-td :props="props">
<span class="ui-link-primary font-semibold">{{ props.value }}</span>
</q-td>
""",
)
# --- Utilities & Input Binding Helpers --- # --- Utilities & Input Binding Helpers ---
+54 -1
View File
@@ -18,6 +18,8 @@ from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.registry import render_registry_table from transcription.ui.components.table.registry import render_registry_table
from transcription.ui.homepage_store import read_homepage_markdown
from transcription.ui.homepage_store import save_homepage_markdown
from transcription.ui.theme import page_header from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
@@ -282,7 +284,7 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
) )
if not summaries_outcome.ok: if not summaries_outcome.ok:
return return
summaries = summaries_outcome.value or () summaries = tuple(summary for summary in (summaries_outcome.value or ()) if summary.name == "transcribe_document.md")
if not summaries: if not summaries:
render_empty_state("No editable Markdown prompts were found.") render_empty_state("No editable Markdown prompts were found.")
@@ -345,9 +347,52 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
if not summary.has_backup: if not summary.has_backup:
recovery.props("disable") recovery.props("disable")
@ui.refreshable
async def render_home_page_text() -> None:
with archival_card("Home Page Text"):
ui.label("Edit the homepage Markdown shown on /homepage.").classes("text-xs ui-text-muted mb-3")
load_outcome = await run_ui_action(
operation="settings.homepage.read",
title="Home Page Text unavailable",
action=lambda: _read_home_page_text(settings),
)
if not load_outcome.ok:
return
editor = (
ui.textarea("Homepage markdown", value=load_outcome.value or "")
.props("outlined autogrow")
.classes("w-full")
)
async def save_home_text() -> None:
save_outcome = await run_ui_action(
operation="settings.homepage.write",
title="Home Page Text save failed",
action=lambda: _write_home_page_text(settings, str(editor.value or "")),
)
if not save_outcome.ok:
return
ui.notify("Home Page Text saved", type="positive")
render_home_page_text.refresh()
with ui.row().classes("items-center gap-2"):
ui.button("Save home text", icon="save", on_click=save_home_text).classes("ui-btn-primary")
with ui.tabs().classes("w-full") as tabs:
document_types_tab = ui.tab("Document Types")
person_roles_tab = ui.tab("Person Roles")
prompts_tab = ui.tab("Prompts")
home_page_text_tab = ui.tab("Home Page Text")
with ui.tab_panels(tabs, value=document_types_tab).classes("w-full"):
with ui.tab_panel(document_types_tab):
await render_document_types() await render_document_types()
with ui.tab_panel(person_roles_tab):
await render_person_roles() await render_person_roles()
with ui.tab_panel(prompts_tab):
await render_prompts() await render_prompts()
with ui.tab_panel(home_page_text_tab):
await render_home_page_text()
def _selected_table_row(table: Any) -> dict[str, Any] | None: def _selected_table_row(table: Any) -> dict[str, Any] | None:
@@ -371,3 +416,11 @@ async def _write_prompt(prompts: PromptStore, name: str, content: str) -> None:
async def _recover_prompt(prompts: PromptStore, name: str) -> None: async def _recover_prompt(prompts: PromptStore, name: str) -> None:
prompts.recover_prompt(name) prompts.recover_prompt(name)
async def _read_home_page_text(settings: Settings) -> str:
return read_homepage_markdown(settings=settings)
async def _write_home_page_text(settings: Settings, markdown_text: str) -> None:
save_homepage_markdown(markdown_text, settings=settings)
+6 -2
View File
@@ -25,6 +25,7 @@ from transcription.ui.components.confirm_delete import render_delete_actions
from transcription.ui.components.confirm_delete import render_delete_blocked_notice from transcription.ui.components.confirm_delete import render_delete_blocked_notice
from transcription.ui.components.data_display import archival_badge from transcription.ui.components.data_display import archival_badge
from transcription.ui.components.data_display import metadata_row from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.error_presenter import run_ui_action from transcription.ui.components.error_presenter import run_ui_action
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.formatters import parse_uuid from transcription.ui.components.formatters import parse_uuid
@@ -37,7 +38,6 @@ from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.sources import SourceTableRow from transcription.ui.components.table.sources import SourceTableRow
from transcription.ui.components.table.sources import render_sources_table from transcription.ui.components.table.sources import render_sources_table
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.runtime import resolve_runtime_settings from transcription.ui.runtime import resolve_runtime_settings
from transcription.ui.theme import page_header from transcription.ui.theme import page_header
@@ -268,7 +268,11 @@ def _render_source_viewer_zone(source: Source, *, settings: Settings, request: R
upload_dir=settings.upload_dir, upload_dir=settings.upload_dir,
base_url=str(request.base_url), base_url=str(request.base_url),
) )
dark_room_viewer(media_url, count_label=f"Page {source.page_number}") render_document_panzoom(
media_url=media_url,
filename=source.filename,
count_label=f"Page {source.page_number}",
)
def _render_source_navigation(previous_id: UUID | None, next_id: UUID | None) -> None: def _render_source_navigation(previous_id: UUID | None, next_id: UUID | None) -> None:
+6
View File
@@ -19,6 +19,12 @@ def read_svg(relative_path: str) -> str:
return _read_static(relative_path, suffix=".svg") return _read_static(relative_path, suffix=".svg")
@cache
def read_js(relative_path: str) -> str:
"""Read and cache a JavaScript resource relative to ``ui/static``."""
return _read_static(relative_path, suffix=".js")
def _read_static(relative_path: str, *, suffix: str) -> str: def _read_static(relative_path: str, *, suffix: str) -> str:
resource_path = PurePosixPath(relative_path) resource_path = PurePosixPath(relative_path)
if resource_path.is_absolute() or ".." in resource_path.parts or resource_path.suffix != suffix: if resource_path.is_absolute() or ".." in resource_path.parts or resource_path.suffix != suffix:
+35
View File
@@ -460,6 +460,41 @@ input:focus-visible,
min-height: 31.25rem; min-height: 31.25rem;
} }
.document-panzoom-host {
width: 100%;
max-width: 100%;
aspect-ratio: var(--panzoom-media-aspect, 4 / 3);
overflow: hidden;
border: 1px solid var(--theme-viewer-border);
border-radius: 0.125rem;
background: var(--theme-viewer);
}
.document-panzoom-surface {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.document-panzoom-media {
width: 100%;
height: 100%;
object-fit: contain;
transform-origin: center center;
user-select: none;
-webkit-user-drag: none;
}
.document-panzoom-iframe {
width: 100%;
height: 100%;
border: 0;
transform-origin: center center;
}
@media (max-width: 700px) { @media (max-width: 700px) {
.app-shell { .app-shell {
padding-inline: 0.75rem; padding-inline: 0.75rem;
File diff suppressed because one or more lines are too long
+42
View File
@@ -18,6 +18,7 @@ from transcription.db import create_all
from transcription.db import dispose_database_runtime from transcription.db import dispose_database_runtime
from transcription.db import initialize_database_runtime from transcription.db import initialize_database_runtime
from transcription.db import normalize_legacy_status_spellings from transcription.db import normalize_legacy_status_spellings
from transcription.db import reconcile_legacy_job_source_columns
from transcription.db import session_scope from transcription.db import session_scope
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import DocumentType from transcription.db.models import DocumentType
@@ -211,6 +212,47 @@ async def test_normalize_legacy_status_spellings_repairs_job_source_status_rows(
await dispose_database_runtime() await dispose_database_runtime()
@pytest.mark.asyncio
async def test_reconcile_legacy_job_source_columns_drops_executed_at(tmp_path):
settings = Settings(
openrouter_api_key="test-key",
database=SqliteSettings(path=str(tmp_path / "legacy-column.db")),
environment="test",
)
runtime = initialize_database_runtime(settings=settings)
try:
await create_all(engine=runtime.engine)
async with runtime.engine.begin() as connection:
await connection.execute(text("PRAGMA foreign_keys=OFF"))
await connection.execute(text('alter table "job_source" rename to "job_source_current"'))
await connection.execute(
text(
'create table "job_source" ('
'id char(32) not null primary key, '
'job_id char(32) not null, '
'source_id char(32) not null, '
'status varchar(11) not null, '
'executed_at datetime not null, '
'constraint "uq_job_source_job_source" unique ("job_id", "source_id"), '
'foreign key("job_id") references "job" ("id"), '
'foreign key("source_id") references "source" ("id")'
")"
)
)
await connection.execute(text('drop table "job_source_current"'))
await connection.execute(text("PRAGMA foreign_keys=ON"))
dropped = await reconcile_legacy_job_source_columns(engine=runtime.engine)
assert dropped == 1
async with runtime.engine.connect() as connection:
columns = await connection.run_sync(lambda c: [col["name"] for col in inspect(c).get_columns("job_source")])
assert "executed_at" not in columns
finally:
await dispose_database_runtime()
def test_metadata_has_no_unresolvable_table_cycle(): def test_metadata_has_no_unresolvable_table_cycle():
"""create_all must be able to order every table, including on PostgreSQL.""" """create_all must be able to order every table, including on PostgreSQL."""
with warnings.catch_warnings(): with warnings.catch_warnings():
+5 -4
View File
@@ -131,7 +131,7 @@ class TestDocumentsPageRendering:
assert "Edit Document" in response.text assert "Edit Document" in response.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_document_jobs_page_renders_job_links(self, app_client): async def test_document_jobs_page_redirects_to_filtered_jobs(self, app_client):
_, client = app_client _, client = app_client
async with session_scope() as session: async with session_scope() as session:
@@ -143,13 +143,14 @@ class TestDocumentsPageRendering:
session.add(job) session.add(job)
await session.commit() await session.commit()
doc_id = str(doc.id) doc_id = str(doc.id)
job_id = str(job.id) _ = str(job.id)
response = client.get(f"/ui/documents/{doc_id}/jobs") response = client.get(f"/ui/documents/{doc_id}/jobs")
assert response.status_code == 200 assert response.status_code == 200
assert "Jobs for Doc With Job" in response.text assert "Jobs for Document" in response.text
assert f"Job ID: {job_id}" in response.text assert "Create job" not in response.text
assert "Refresh" not in response.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_document_edit_page_prefills_existing_values(self, app_client, seed_person_and_document): async def test_document_edit_page_prefills_existing_values(self, app_client, seed_person_and_document):
+7
View File
@@ -3,6 +3,7 @@ from datetime import date
from transcription.db.models import Person from transcription.db.models import Person
from transcription.ui.components.formatters import compact_date from transcription.ui.components.formatters import compact_date
from transcription.ui.components.formatters import family_search_url from transcription.ui.components.formatters import family_search_url
from transcription.ui.components.formatters import google_maps_search_url
from transcription.ui.components.formatters import person_selector_label from transcription.ui.components.formatters import person_selector_label
@@ -32,3 +33,9 @@ def test_family_search_url_uses_fixed_person_details_route():
assert family_search_url("G8T4-MDQ") == ( assert family_search_url("G8T4-MDQ") == (
"https://www.familysearch.org/tree/person/details/G8T4-MDQ" "https://www.familysearch.org/tree/person/details/G8T4-MDQ"
) )
def test_google_maps_search_url_encodes_place_query():
assert google_maps_search_url("New York, NY") == (
"https://www.google.com/maps/search/?api=1&query=New+York%2C+NY"
)
+36 -3
View File
@@ -54,8 +54,38 @@ class TestJobsPageRendering:
response = client.get("/ui/jobs") response = client.get("/ui/jobs")
assert response.status_code == 200 assert response.status_code == 200
assert "Document Name" in response.text
assert "Source Filename" not in response.text
assert "Updated" in response.text
assert "Created" not in response.text
assert str(job_id) in response.text assert str(job_id) in response.text
assert "seeded-document-page.png" in response.text
@pytest.mark.asyncio
async def test_jobs_page_filters_for_document_context(self, app_client):
_, client = app_client
async with session_scope() as session:
first = Document(name="First Doc")
second = Document(name="Second Doc")
session.add_all([first, second])
await session.flush()
first_job = Job(document_id=first.id, status=JobStatus.QUEUED, provider="openai", model="gpt-4o")
second_job = Job(document_id=second.id, status=JobStatus.QUEUED, provider="openai", model="gpt-4o")
session.add_all([first_job, second_job])
await session.commit()
first_id = str(first.id)
first_job_id = str(first_job.id)
second_job_id = str(second_job.id)
response = client.get(f"/ui/jobs?document_id={first_id}")
assert response.status_code == 200
assert "Jobs for Document" in response.text
assert "Create job" not in response.text
assert "Refresh" not in response.text
assert first_job_id in response.text
assert second_job_id not in response.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_job_create_page_shows_empty_document_warning_when_no_docs(self, app_client): async def test_job_create_page_shows_empty_document_warning_when_no_docs(self, app_client):
@@ -95,8 +125,11 @@ class TestJobsPageRendering:
assert "Execution Logistics".upper() in response.text.upper() assert "Execution Logistics".upper() in response.text.upper()
assert "openai" in response.text assert "openai" in response.text
assert "gpt-4o" in response.text assert "gpt-4o" in response.text
assert "View Linked Document" in response.text assert "Document Name:" in response.text
assert "View Linked Sources" in response.text assert "Sources:" in response.text
assert "View Sources" in response.text
assert "View Linked Document" not in response.text
assert "View Linked Sources" not in response.text
assert "updates automatically while the job is active" in response.text assert "updates automatically while the job is active" in response.text
@pytest.mark.asyncio @pytest.mark.asyncio
+5
View File
@@ -24,3 +24,8 @@ class TestPageRegistration:
assert sources_response.status_code == 200 assert sources_response.status_code == 200
assert jobs_response.status_code == 200 assert jobs_response.status_code == 200
assert settings_response.status_code == 200 assert settings_response.status_code == 200
assert "Document Types" in settings_response.text
assert "Person Roles" in settings_response.text
assert "Prompts" in settings_response.text
assert "Home Page Text" in settings_response.text
assert "README.md" not in settings_response.text
+52 -2
View File
@@ -12,6 +12,7 @@ from transcription.db.models import Document
from transcription.db.models import DocumentPerson from transcription.db.models import DocumentPerson
from transcription.db.models import Person from transcription.db.models import Person
from transcription.db.models import PersonRole from transcription.db.models import PersonRole
from transcription.db.models import Source
@pytest.mark.integration @pytest.mark.integration
@@ -55,6 +56,9 @@ class TestPeoplePageRendering:
assert "Birth date (YYYY-MM-DD)" not in response.text assert "Birth date (YYYY-MM-DD)" not in response.text
assert "Death date (YYYY-MM-DD)" not in response.text assert "Death date (YYYY-MM-DD)" not in response.text
assert "FamilySearch ID" in response.text assert "FamilySearch ID" in response.text
assert "Auto-fill from FamilySearch" not in response.text
assert "Marriage date (FamilySearch)" not in response.text
assert "Spouse (FamilySearch)" not in response.text
assert "Biography" in response.text assert "Biography" in response.text
assert "Save person" in response.text assert "Save person" in response.text
@@ -92,11 +96,16 @@ class TestPeoplePageRendering:
assert "1906-12-09" in response.text assert "1906-12-09" in response.text
assert "Death Date:" in response.text assert "Death Date:" in response.text
assert "1992-01-01" in response.text assert "1992-01-01" in response.text
assert "Open Birth Place in Google Maps" not in response.text
assert "Open Death Place in Google Maps" not in response.text
assert "google.com/maps/search/?api=1&amp;query=New+York" in response.text
assert "google.com/maps/search/?api=1&amp;query=Arlington" in response.text
assert "biography" in response.text.lower() assert "biography" in response.text.lower()
assert "Computer pioneer" in response.text assert "Computer pioneer" in response.text
assert "Created:" in response.text assert "Created:" in response.text
assert "Updated:" in response.text assert "Updated:" in response.text
assert "Open in FamilySearch" in response.text assert "Open in FamilySearch" not in response.text
assert "FamilySearch ID:" in response.text
assert "familysearch.org/tree/person/details/G8T4-MDQ" in response.text assert "familysearch.org/tree/person/details/G8T4-MDQ" in response.text
assert "New Document" in response.text assert "New Document" in response.text
assert "No linked documents yet." in response.text assert "No linked documents yet." in response.text
@@ -134,6 +143,28 @@ class TestPeoplePageRendering:
document = Document(name="Linked Document") document = Document(name="Linked Document")
session.add_all([person, document]) session.add_all([person, document])
await session.flush() await session.flush()
session.add_all(
[
Source(
document_id=document.id,
page_number=1,
upload_name="linked-page-1.png",
filename="linked-page-1.png",
file_path="/tmp/linked-page-1.png",
file_hash="1" * 64,
file_size_bytes=1,
),
Source(
document_id=document.id,
page_number=2,
upload_name="linked-page-2.png",
filename="linked-page-2.png",
file_path="/tmp/linked-page-2.png",
file_hash="2" * 64,
file_size_bytes=1,
),
]
)
session.add( session.add(
DocumentPerson( DocumentPerson(
@@ -148,8 +179,27 @@ class TestPeoplePageRendering:
response = client.get(f"/ui/people/{person_id}") response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Document Name" in response.text
assert "Number of Pages" in response.text
assert "Linked Document" in response.text assert "Linked Document" in response.text
assert "Role: Author" in response.text assert "Author" in response.text
assert '"page_count":2' in response.text
assert "Open" not in response.text
@pytest.mark.asyncio
async def test_person_detail_page_hides_empty_maiden_name(self, app_client):
_, client = app_client
async with session_scope() as session:
person = Person(full_name="No Maiden Name")
session.add(person)
await session.commit()
person_id = str(person.id)
response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200
assert "Maiden Name:" not in response.text
def test_person_detail_page_handles_invalid_id(self, app_client): def test_person_detail_page_handles_invalid_id(self, app_client):
_, client = app_client _, client = app_client
+48
View File
@@ -1,5 +1,7 @@
"""Tests for the sources page routes and Source model properties.""" """Tests for the sources page routes and Source model properties."""
from datetime import UTC
from datetime import datetime
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
@@ -10,6 +12,7 @@ from transcription.db import session_scope
from transcription.db.loading import orm_attribute from transcription.db.loading import orm_attribute
from transcription.db.loading import selectinload from transcription.db.loading import selectinload
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import ExecutionAttempt
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobSource from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus from transcription.db.models import JobSourceStatus
@@ -71,6 +74,51 @@ class TestSourceModelProperties:
assert source.latest_error_detail == "Timeout during OCR parsing" assert source.latest_error_detail == "Timeout during OCR parsing"
assert source.latest_job_source is not None assert source.latest_job_source is not None
@pytest.mark.asyncio
async def test_latest_error_detail_ignores_older_failures_when_latest_attempt_succeeds(self):
source = Source(
document_id=uuid4(),
page_number=1,
upload_name="page_one.png",
filename="stored_page_one.png",
file_path="/tmp/stored_page_one.png",
file_hash="b" * 64,
file_size_bytes=1,
)
job_source = JobSource(
job_id=uuid4(),
source_id=source.id,
status=JobSourceStatus.TRANSCRIBED,
)
failed_attempt = ExecutionAttempt(
job_source_id=job_source.id,
job_id=job_source.job_id,
source_id=source.id,
attempt_number=1,
status=JobSourceStatus.FAILED,
provider="openrouter",
error_detail="Provider timeout",
started_at=datetime.now(UTC),
finished_at=datetime.now(UTC),
duration_ms=10,
)
successful_attempt = ExecutionAttempt(
job_source_id=job_source.id,
job_id=job_source.job_id,
source_id=source.id,
attempt_number=2,
status=JobSourceStatus.TRANSCRIBED,
provider="openrouter",
error_detail=None,
started_at=datetime.now(UTC),
finished_at=datetime.now(UTC),
duration_ms=10,
)
job_source.execution_attempts = [failed_attempt, successful_attempt]
source.job_sources = [job_source]
assert source.latest_error_detail is None
# --- Integration Tests for Page Rendering --- # --- Integration Tests for Page Rendering ---