generated from john/python-template
@@ -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 `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
|
||||
|
||||
A service method may read across models it does not own, using eager loads from its own
|
||||
|
||||
@@ -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.8–4.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 — 2–4 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.
|
||||
@@ -177,6 +177,9 @@ Index:
|
||||
Constraint:
|
||||
- `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`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|
||||
@@ -21,6 +21,7 @@ The application root and `/ui` redirect to `/ui/homepage`.
|
||||
- Missing text displays `No homepage text saved yet.`
|
||||
- Missing image displays the viewer's empty state.
|
||||
- **Edit Home Page** opens the edit route.
|
||||
- The same Home Text content is also editable from **Settings → Home Page Text**.
|
||||
|
||||
## Edit Behavior
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@ Jobs manages transcription processing runs. A Job belongs to one Document, links
|
||||
|
||||
- The title is **Transcription Pipeline Jobs**.
|
||||
- **Create job** opens Job creation and **Refresh** reloads the table.
|
||||
- Columns are Job ID, Status, Source Filename, Retries, Created, and Updated.
|
||||
- Search covers Job ID, filename, and status.
|
||||
- Columns are Job ID, Status, Document Name, Retries, and Updated.
|
||||
- Updated is the primary date/sort field.
|
||||
- Search covers Job ID, document name, and status.
|
||||
- Status is displayed as a semantic status chip.
|
||||
- Selecting a row opens Job Detail.
|
||||
- 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.
|
||||
- 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.
|
||||
- Polling stops when the Job becomes terminal or a refresh fails.
|
||||
- Queued and processing Jobs expose **Cancel**.
|
||||
|
||||
@@ -54,9 +54,12 @@ Rules:
|
||||
- The header provides **New Document**, **Edit Person**, and **Delete**.
|
||||
- **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.
|
||||
- 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.
|
||||
- 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.
|
||||
- System Logistics shows created and updated timestamps.
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
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 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?
|
||||
|
||||
## 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
|
||||
|
||||
@@ -25,6 +25,7 @@ from .db import create_all
|
||||
from .db import dispose_database_runtime
|
||||
from .db import initialize_database_runtime
|
||||
from .db import normalize_legacy_status_spellings
|
||||
from .db import reconcile_legacy_job_source_columns
|
||||
from .services import ServiceBundle
|
||||
from .ui import register_pages
|
||||
from .worker import worker_consumer_lifespan
|
||||
@@ -43,6 +44,7 @@ async def _lifespan(app: FastAPI):
|
||||
|
||||
if settings.should_bootstrap_schema:
|
||||
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)
|
||||
|
||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .operations import create_all
|
||||
from .operations import normalize_legacy_status_spellings
|
||||
from .operations import reconcile_legacy_job_source_columns
|
||||
from .runtime import dispose_database_runtime
|
||||
from .runtime import initialize_database_runtime
|
||||
from .session import session_scope
|
||||
@@ -10,6 +11,7 @@ __all__ = [
|
||||
"dispose_database_runtime",
|
||||
"initialize_database_runtime",
|
||||
"normalize_legacy_status_spellings",
|
||||
"reconcile_legacy_job_source_columns",
|
||||
"session_scope",
|
||||
"transaction_scope",
|
||||
]
|
||||
|
||||
@@ -345,10 +345,10 @@ class Source(SQLModel, table=True):
|
||||
if latest is None:
|
||||
return None
|
||||
attempts = _loaded_attribute(latest, "execution_attempts") or ()
|
||||
for attempt in sorted(attempts, key=lambda item: item.attempt_number, reverse=True):
|
||||
if attempt.error_detail:
|
||||
return attempt.error_detail
|
||||
return None
|
||||
if not attempts:
|
||||
return None
|
||||
latest_attempt = max(attempts, key=lambda item: item.attempt_number)
|
||||
return latest_attempt.error_detail
|
||||
|
||||
@property
|
||||
def document_name(self) -> str | None:
|
||||
|
||||
@@ -73,6 +73,47 @@ async def normalize_legacy_status_spellings(*, engine: AsyncEngine | None = None
|
||||
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:
|
||||
"""Seed default registry rows for role and document type taxonomies."""
|
||||
active_engine = engine or resolve_engine()
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from ..db.loading import orm_attribute
|
||||
from ..db.loading import selectinload
|
||||
from ..db.models import ExecutionAttempt
|
||||
from ..db.models import Document
|
||||
from ..db.models import Job
|
||||
from ..db.models import JobSource
|
||||
from ..db.models import JobSourceStatus
|
||||
@@ -65,7 +66,7 @@ class JobService(ServiceBase):
|
||||
query = (
|
||||
select(Job)
|
||||
.options(
|
||||
selectinload(Job.document),
|
||||
selectinload(Job.document).selectinload(orm_attribute(Document.sources)),
|
||||
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
|
||||
)
|
||||
.where(Job.id == job_id)
|
||||
|
||||
@@ -202,7 +202,9 @@ class PeopleService(ServiceBase):
|
||||
query = (
|
||||
select(Person)
|
||||
.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)),
|
||||
)
|
||||
.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")
|
||||
|
||||
|
||||
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):
|
||||
"""Standardized Aged Sepia badge."""
|
||||
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."""
|
||||
|
||||
import re
|
||||
from urllib.parse import quote_plus
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
@@ -60,3 +61,8 @@ def person_selector_label(person: Person) -> str:
|
||||
def family_search_url(family_search_id: str) -> str:
|
||||
"""Build the fixed FamilySearch details URL for a validated identifier."""
|
||||
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)}"
|
||||
|
||||
@@ -23,7 +23,7 @@ class JobTableRow:
|
||||
|
||||
id: UUID
|
||||
status: str
|
||||
filename: str
|
||||
document_name: str
|
||||
retry_count: int
|
||||
date_created: str
|
||||
date_updated: str
|
||||
@@ -44,7 +44,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
|
||||
{
|
||||
"id": str(row.id),
|
||||
"status": row.status.lower(),
|
||||
"filename": row.filename,
|
||||
"document_name": row.document_name,
|
||||
"retry_count": row.retry_count,
|
||||
"date_created": _format_timestamp(row.date_created),
|
||||
"date_updated": _format_timestamp(row.date_updated),
|
||||
@@ -71,6 +71,7 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
|
||||
"field": "id",
|
||||
"sortable": True,
|
||||
"classes": "font-mono text-xs",
|
||||
"style": "width: 30%;",
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
@@ -78,36 +79,35 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
|
||||
"field": "status",
|
||||
"sortable": True,
|
||||
"classes": "font-mono",
|
||||
"style": "width: 15%;",
|
||||
},
|
||||
{
|
||||
"name": "filename",
|
||||
"label": "Source Filename",
|
||||
"field": "filename",
|
||||
"name": "document_name",
|
||||
"label": "Document Name",
|
||||
"field": "document_name",
|
||||
"sortable": True,
|
||||
"classes": "font-mono text-xs",
|
||||
"classes": "font-serif text-left ui-table-cell-wrap",
|
||||
"align": "left",
|
||||
"style": "width: 35%;",
|
||||
},
|
||||
{
|
||||
"name": "retry_count",
|
||||
"label": "Retries",
|
||||
"field": "retry_count",
|
||||
"sortable": True,
|
||||
},
|
||||
{
|
||||
"name": "date_created",
|
||||
"label": "Created",
|
||||
"field": "created_sort",
|
||||
"sortable": True,
|
||||
"style": "width: 8%;",
|
||||
},
|
||||
{
|
||||
"name": "date_updated",
|
||||
"label": "Updated",
|
||||
"field": "updated_sort",
|
||||
"sortable": True,
|
||||
"style": "width: 12%;",
|
||||
},
|
||||
],
|
||||
default_sort_by="created_sort",
|
||||
default_sort_by="updated_sort",
|
||||
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}"),
|
||||
)
|
||||
|
||||
|
||||
@@ -242,58 +242,14 @@ def register_page() -> None: # noqa: PLR0915
|
||||
_render_bento_relations_zone(document)
|
||||
|
||||
@ui.page("/documents/{document_id}/jobs")
|
||||
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/documents")
|
||||
|
||||
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")
|
||||
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
|
||||
_ = session_factory
|
||||
return RedirectResponse(url=f"/ui/jobs?document_id={document_id}")
|
||||
|
||||
@ui.page("/documents/{document_id}/sources")
|
||||
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
|
||||
_ = 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")
|
||||
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from nicegui import events
|
||||
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 render_upload_picker
|
||||
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 save_homepage_markdown
|
||||
from transcription.ui.homepage_store import store_homepage_image
|
||||
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.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"):
|
||||
if markdown_text:
|
||||
@@ -42,6 +106,7 @@ def _render_homepage_editor(*, render_image_panel, markdown_input, on_upload) ->
|
||||
on_upload=on_upload,
|
||||
label="Upload image",
|
||||
extensions=IMAGE_UPLOAD_EXTENSIONS,
|
||||
multiple=True,
|
||||
)
|
||||
render_image_panel()
|
||||
|
||||
@@ -61,6 +126,17 @@ def register_page() -> None:
|
||||
@ui.page("/homepage", title="VibeScribe Home")
|
||||
def homepage_page() -> None:
|
||||
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 section_header_row():
|
||||
@@ -70,26 +146,44 @@ def register_page() -> None:
|
||||
on_click=lambda: ui.navigate.to("/homepage/edit"),
|
||||
icon="edit",
|
||||
).classes("ui-btn-primary text-xs")
|
||||
|
||||
_render_homepage_view(
|
||||
markdown_text=read_homepage_markdown().strip(),
|
||||
image_path=latest_homepage_image(),
|
||||
render_image_panel=render_image_panel,
|
||||
)
|
||||
|
||||
@ui.page("/homepage/edit", title="Edit Homepage")
|
||||
def homepage_edit_page() -> None:
|
||||
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]
|
||||
|
||||
@ui.refreshable
|
||||
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:
|
||||
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")
|
||||
render_image_panel.refresh()
|
||||
|
||||
|
||||
@@ -53,33 +53,39 @@ def register_page() -> None: # noqa: PLR0915
|
||||
"""Register jobs list and detail routes."""
|
||||
|
||||
@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)
|
||||
parsed_document_id = parse_uuid(document_id)
|
||||
is_document_context = parsed_document_id is not None
|
||||
render_navigation_header(current_path="/jobs")
|
||||
|
||||
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||
with section_header_row():
|
||||
page_header("Transcription Pipeline Jobs")
|
||||
with ui.row().classes("items-center gap-2"):
|
||||
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes(
|
||||
"ui-btn-primary"
|
||||
)
|
||||
ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat")
|
||||
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"):
|
||||
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes(
|
||||
"ui-btn-primary"
|
||||
)
|
||||
ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat")
|
||||
|
||||
@ui.refreshable
|
||||
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(
|
||||
id=job.id,
|
||||
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,
|
||||
date_created=job.date_created.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()
|
||||
|
||||
@@ -564,18 +570,23 @@ def _render_job_logistics(job: Job) -> None:
|
||||
|
||||
def _render_job_document_links(job: Job) -> None:
|
||||
with archival_card(title="Document Links"):
|
||||
ui.label("Navigate to related archival records:").classes("text-xs ui-text-muted mb-3")
|
||||
with ui.column().classes("w-full gap-2"):
|
||||
document_name = job.document.name if job.document is not None else str(job.document_id)
|
||||
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(
|
||||
"View Linked Document",
|
||||
document_name,
|
||||
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
|
||||
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(
|
||||
"View Linked Sources",
|
||||
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
|
||||
"View Sources",
|
||||
on_click=lambda: ui.navigate.to(f"/sources?document_id={job.document_id}"),
|
||||
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:
|
||||
|
||||
@@ -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.cards import archival_card
|
||||
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.error_presenter import run_ui_action
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
from transcription.ui.components.formatters import compact_date
|
||||
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.guards import parsed_record_id
|
||||
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 render_empty_state
|
||||
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 render_people_table
|
||||
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"):
|
||||
metadata_row("Full Name:", person.full_name)
|
||||
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 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 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:
|
||||
ui.link(
|
||||
"Open in FamilySearch",
|
||||
metadata_link_row(
|
||||
"FamilySearch ID:",
|
||||
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"):
|
||||
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.")
|
||||
return
|
||||
|
||||
with ui.column().classes("w-full gap-2"):
|
||||
for link in person.document_people:
|
||||
doc = link.document
|
||||
if doc is None:
|
||||
continue
|
||||
role_label = 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"):
|
||||
with ui.column().classes("gap-0"):
|
||||
ui.label(doc.name).classes("text-xs font-semibold ui-text-primary")
|
||||
ui.label(f"Role: {role_label}").classes("text-[10px] ui-text-muted")
|
||||
ui.button(
|
||||
"Open",
|
||||
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")
|
||||
rows = sorted(
|
||||
(
|
||||
{
|
||||
"id": str(link.document.id),
|
||||
"document_name": link.document.name,
|
||||
"role": link.role_ref.label if link.role_ref is not None else "Unknown role",
|
||||
"page_count": len(link.document.sources),
|
||||
}
|
||||
for link in person.document_people
|
||||
if link.document is not None
|
||||
),
|
||||
key=lambda row: str(row["document_name"]).casefold(),
|
||||
)
|
||||
|
||||
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 ---
|
||||
|
||||
@@ -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 section_header_row
|
||||
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 ...db.session import SessionFactoryDep
|
||||
@@ -282,7 +284,7 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
||||
)
|
||||
if not summaries_outcome.ok:
|
||||
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:
|
||||
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:
|
||||
recovery.props("disable")
|
||||
|
||||
await render_document_types()
|
||||
await render_person_roles()
|
||||
await render_prompts()
|
||||
@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()
|
||||
with ui.tab_panel(person_roles_tab):
|
||||
await render_person_roles()
|
||||
with ui.tab_panel(prompts_tab):
|
||||
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:
|
||||
@@ -371,3 +416,11 @@ async def _write_prompt(prompts: PromptStore, name: str, content: str) -> None:
|
||||
|
||||
async def _recover_prompt(prompts: PromptStore, name: str) -> None:
|
||||
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)
|
||||
|
||||
@@ -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.data_display import archival_badge
|
||||
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 show_error
|
||||
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.table.sources import SourceTableRow
|
||||
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.theme import page_header
|
||||
|
||||
@@ -268,7 +268,11 @@ def _render_source_viewer_zone(source: Source, *, settings: Settings, request: R
|
||||
upload_dir=settings.upload_dir,
|
||||
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:
|
||||
|
||||
@@ -19,6 +19,12 @@ def read_svg(relative_path: str) -> str:
|
||||
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:
|
||||
resource_path = PurePosixPath(relative_path)
|
||||
if resource_path.is_absolute() or ".." in resource_path.parts or resource_path.suffix != suffix:
|
||||
|
||||
@@ -460,6 +460,41 @@ input:focus-visible,
|
||||
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) {
|
||||
.app-shell {
|
||||
padding-inline: 0.75rem;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -18,6 +18,7 @@ from transcription.db import create_all
|
||||
from transcription.db import dispose_database_runtime
|
||||
from transcription.db import initialize_database_runtime
|
||||
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.models import Document
|
||||
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()
|
||||
|
||||
|
||||
@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():
|
||||
"""create_all must be able to order every table, including on PostgreSQL."""
|
||||
with warnings.catch_warnings():
|
||||
|
||||
@@ -131,7 +131,7 @@ class TestDocumentsPageRendering:
|
||||
assert "Edit Document" in response.text
|
||||
|
||||
@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
|
||||
|
||||
async with session_scope() as session:
|
||||
@@ -143,13 +143,14 @@ class TestDocumentsPageRendering:
|
||||
session.add(job)
|
||||
await session.commit()
|
||||
doc_id = str(doc.id)
|
||||
job_id = str(job.id)
|
||||
_ = str(job.id)
|
||||
|
||||
response = client.get(f"/ui/documents/{doc_id}/jobs")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Jobs for Doc With Job" in response.text
|
||||
assert f"Job ID: {job_id}" in response.text
|
||||
assert "Jobs for Document" in response.text
|
||||
assert "Create job" not in response.text
|
||||
assert "Refresh" not in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_edit_page_prefills_existing_values(self, app_client, seed_person_and_document):
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import date
|
||||
from transcription.db.models import Person
|
||||
from transcription.ui.components.formatters import compact_date
|
||||
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
|
||||
|
||||
|
||||
@@ -32,3 +33,9 @@ def test_family_search_url_uses_fixed_person_details_route():
|
||||
assert family_search_url("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"
|
||||
)
|
||||
|
||||
@@ -54,8 +54,38 @@ class TestJobsPageRendering:
|
||||
response = client.get("/ui/jobs")
|
||||
|
||||
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 "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
|
||||
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 "openai" in response.text
|
||||
assert "gpt-4o" in response.text
|
||||
assert "View Linked Document" in response.text
|
||||
assert "View Linked Sources" in response.text
|
||||
assert "Document Name:" 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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -24,3 +24,8 @@ class TestPageRegistration:
|
||||
assert sources_response.status_code == 200
|
||||
assert jobs_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
|
||||
|
||||
@@ -12,6 +12,7 @@ from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
from transcription.db.models import Person
|
||||
from transcription.db.models import PersonRole
|
||||
from transcription.db.models import Source
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -55,6 +56,9 @@ class TestPeoplePageRendering:
|
||||
assert "Birth 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 "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 "Save person" in response.text
|
||||
|
||||
@@ -92,11 +96,16 @@ class TestPeoplePageRendering:
|
||||
assert "1906-12-09" in response.text
|
||||
assert "Death Date:" 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&query=New+York" in response.text
|
||||
assert "google.com/maps/search/?api=1&query=Arlington" in response.text
|
||||
assert "biography" in response.text.lower()
|
||||
assert "Computer pioneer" in response.text
|
||||
assert "Created:" 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 "New Document" in response.text
|
||||
assert "No linked documents yet." in response.text
|
||||
@@ -134,6 +143,28 @@ class TestPeoplePageRendering:
|
||||
document = Document(name="Linked Document")
|
||||
session.add_all([person, document])
|
||||
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(
|
||||
DocumentPerson(
|
||||
@@ -148,8 +179,27 @@ class TestPeoplePageRendering:
|
||||
response = client.get(f"/ui/people/{person_id}")
|
||||
|
||||
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 "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):
|
||||
_, client = app_client
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for the sources page routes and Source model properties."""
|
||||
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
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 selectinload
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import ExecutionAttempt
|
||||
from transcription.db.models import Job
|
||||
from transcription.db.models import JobSource
|
||||
from transcription.db.models import JobSourceStatus
|
||||
@@ -71,6 +74,51 @@ class TestSourceModelProperties:
|
||||
assert source.latest_error_detail == "Timeout during OCR parsing"
|
||||
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 ---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user