32 Commits
Author SHA1 Message Date
Jim Lancaster 4eeb552273 Added Home page 2026-08-04 19:20:33 -05:00
Jim Lancaster d9f5fbb1a4 Delete Document & Delete Source buttons now delete the underlying files 2026-08-04 18:34:35 -05:00
Jim Lancaster 271633d1d5 Jobs: jobs still stuck in queue. Fixes from testing. 2026-08-04 18:09:31 -05:00
Jim Lancaster 6c6589d8ff Jobs: big jobs stuck in queue. Added Cancel, Resubmit 2026-08-04 09:03:51 -05:00
Jim Lancaster 759d4c2434 UI style refresh: Very close!!! 2026-08-03 19:46:32 -05:00
Jim Lancaster 323f12d911 UI style refresh: Final cleanup 2026-08-03 16:27:02 -05:00
Jim Lancaster f80834d589 UI style refresh: extract reusable components and refactor 2026-08-03 15:28:58 -05:00
Jim Lancaster 752346025b UI style refresh continued 2026-08-03 15:15:58 -05:00
Jim Lancaster 4f6e1fd913 UI style refresh with Gemini's help 2026-08-03 13:54:24 -05:00
Jim Lancaster 47aef0e26e UI slog grinds on 2026-08-02 23:44:53 -05:00
Jim Lancaster c098013a68 UI slog continues 2026-08-02 20:03:02 -05:00
Jim Lancaster 49e2e48df1 UI updates continue. Focus on Sources 2026-08-02 18:41:09 -05:00
Jim Lancaster 0ab7ad50f2 UI updates, changes sync'd to UI docs 2026-08-02 18:20:38 -05:00
Jim Lancaster 9653060c2a UI update complete? 2026-08-02 13:33:09 -05:00
Jim Lancaster ed6f9dfe25 UI update planning complete 2026-08-02 11:33:19 -05:00
Jim Lancaster 5946867ff3 UI update initial phase complete. Still need to create schema-mapping for the two many-to-many tables. 2026-08-02 11:23:04 -05:00
Jim Lancaster 646a360aca UI update planning continued 2026-08-02 10:00:03 -05:00
Jim Lancaster 2b3d33e50e Begin UI update starting with Document table. 2026-08-02 08:02:58 -05:00
Jim Lancaster dfe6f121ff V2 (new) sStep 4 complete. (untested, unreviewed, cross my fingers) 2026-08-01 18:28:00 -05:00
Jim Lancaster 51ac2d0b98 V2 step 3 complete 2026-08-01 16:24:44 -05:00
Jim Lancaster 61cc8a200b V2 step 2 complete 2026-08-01 16:17:38 -05:00
Jim Lancaster c46d1bd0bc V2 implementation step 1 2026-08-01 15:34:06 -05:00
Jim Lancaster 00ed176ac1 Merge branch 'session-engine' of https://gitea.john-stream.com/bbchops/transcription into session-engine 2026-08-01 14:47:39 -05:00
Jim Lancaster 99a128e981 Reorganized docs, checked docs for internal consistency and made adjustments 2026-08-01 14:47:29 -05:00
John Lancaster aa94f34de4 pruned ddl 2026-08-01 10:35:21 -05:00
John Lancaster 661e2b1bec smoothed readme and startup 2026-08-01 09:51:21 -05:00
John Lancaster d75083a666 shutdown fixes 2026-08-01 09:36:34 -05:00
John Lancaster d0a3ca0289 WIP theming 2026-07-31 19:34:12 -05:00
John Lancaster 209c48987c separated cli settings 2026-07-31 19:23:12 -05:00
John Lancaster 4ed1f43eda ui instructions 2026-07-31 16:01:23 -05:00
John Lancaster ce8fcce6b0 Merge commit '4ae8e5be4f60059ba611ceea0cf553b009e6a88e' into session-engine 2026-07-31 15:40:20 -05:00
Jim Lancaster 4ae8e5be4f Test UI diagrams 2026-07-31 11:35:37 -05:00
101 changed files with 10794 additions and 982 deletions
+53 -7
View File
@@ -1,8 +1,54 @@
PROVIDER=openrouter # --- NiceGUI Server ---
OPENROUTER_API_KEY=sk-or-... # HOST=`0.0.0.0` (default)
# PROVIDER_MODEL= # optional: OpenRouter adapter supplies default # PORT=8000 (default)
# LOG_LEVEL: [`critical`, `error`, `warning`, `info` (default), `debug`, `trace`]
# RELOAD=false (default)
# --- AI provider ---
# PROVIDER=[`openrouter`(default), `google_genai`]
PROVIDER=openrouter
# OPENROUTER_API_KEY - Required when `PROVIDER=openrouter`
OPENROUTER_API_KEY=your-api-key-goes-here
# GEMINI_API_KEY - Required when `PROVIDER=google_genai`
# PROVIDER_MODEL= specify model. If left blank OpenRouter will supply default.
PROVIDER_MODEL=google/gemini-2.5-flash
# OPENROUTER_HTTP_REFERER=https://example.com # OPENROUTER_HTTP_REFERER=https://example.com
# OPENROUTER_APP_TITLE=Historical Transcription MVP # OPENROUTER_APP_TITLE="Google: Gemini 2.5 Flash (openrouter)"
# DATABASE_URL=sqlite:///./transcription.db
# UPLOAD_DIR=./uploads # --- runtime environment ---
# PROMPT_DIR=./prompts # ENVIRONMENT: [`development`(default), `test`, `production`]
# --- persistence ---
# Use nested settings with double underscore because env_nested_delimiter="__".
# SQLite example:
# DATABASE__DRIVER=sqlite
# DATABASE__PATH=app.db
#
# SQLite with custom relative path:
# DATABASE__DRIVER=sqlite
DATABASE__PATH=./data/transcription.db
#
# Postgres example:
# DATABASE__DRIVER=postgres
# DATABASE__HOST=localhost
# DATABASE__PORT=5432
# DATABASE__DATABASE=transcription
# DATABASE__USER=postgres
# DATABASE__PASSWORD=change-me
#
# Optional persistence flags:
# BOOTSTRAP_SCHEMA_ON_STARTUP=false
# SQLITE_CHECK_SAME_THREAD=false
# --- filesystem paths ---
UPLOAD_DIR="./data"
PROMPT_DIR="./prompts"
# --- worker reliability ---
WORKER_MAX_RETRIES=0
WORKER_RETRY_BACKOFF_SECONDS=0
# WORKER_PROVIDER_TIMEOUT_SECONDS=[0-20]
WORKER_PROVIDER_TIMEOUT_SECONDS=20
WORKER_MIN_TRANSCRIPTION_CHARS=0
WORKER_MIN_TRANSCRIPTION_LINES=0
WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
+49 -2
View File
@@ -1,6 +1,53 @@
--- ---
description: Copilot rules for modifying the UI description: "Use when modifying the NiceGUI application under src/transcription/ui. Defines ownership and dependency boundaries for UI registration, pages, components, services, persistence, state, and static assets."
applyTo: 'src/transcription/ui/**/*.py' applyTo: 'src/transcription/ui/**/*.py'
--- ---
The UI is forbidden from directly touching the database. All interactions should be done using the [services](../../src/transcription/services/) # UI Conceptual Boundaries
Keep dependencies flowing in this direction:
`ui/__init__.py` -> `pages` -> `components`
Pages may depend on application services and framework-provided dependencies. Components may depend on smaller components and shared presentation helpers. Services and domain modules must never depend on the UI.
## Package Root
- Keep `ui/__init__.py` as the UI composition root: register global assets, register pages, and mount NiceGUI on FastAPI.
- Do not put feature rendering, service calls, persistence, or route-specific state in the package root.
## Pages
- Pages own route registration and route-level orchestration.
- Resolve request or application dependencies, call [services](../../src/transcription/services/), adapt returned data for presentation when needed, and coordinate refresh, navigation, and notifications here.
- Do not query, mutate, commit, or roll back the database from a page. Do not import database engines, sessions, operations, or query-building APIs. Persistence belongs to services or workflow functions.
- Framework dependency types may cross into page handlers only to construct or invoke services; do not pass sessions or session factories into components.
- Keep business rules, lifecycle transitions, transaction boundaries, and cross-service workflows out of page callbacks.
## Components
- Components own reusable rendering, widget-local state, input normalization, and presentation-only formatting.
- Expose user actions through typed callback parameters. The calling page decides which service or workflow runs and what refresh or navigation follows.
- Do not register routes, resolve request/app state, instantiate services, or access persistence from components.
- Components may accept ORM models returned by services as read-only snapshots. Only use fields and relationships that the service loaded eagerly; never mutate models, trigger lazy loading, or expose session behavior.
- A component may compose lower-level components, but it must not import from `pages`.
## Shared UI Infrastructure
- Keep app-wide navigation and layout primitives in `components/app_shell.py`.
- Keep generic table/event adaptation in `components/table/common.py`; feature-specific columns, row read models, and formatting belong in the feature table module.
- Keep exception normalization and user-facing error display in `components/error_presenter.py`; preserve `AppError` details and operation identifiers at page/component boundaries.
## CSS Assets
- Keep CSS under `ui/static` and split it into manageable, feature-oriented files. Do not grow a monolithic stylesheet or embed substantial style blocks in Python components.
- Load each stylesheet from the page, component, or composition root that needs it with `ui.add_css(...)`. Use shared registration only for genuinely application-wide styles.
- Read stylesheet text through `importlib.resources.files(...)` so loading works from installed packages and is independent of the working directory.
- Centralize CSS reading in one typed helper cached by relative resource path with `functools.cache` or an equivalent unbounded `lru_cache`. Cache the immutable stylesheet text to avoid repeated resource I/O during component renders; keep NiceGUI registration decisions at the caller.
- Do not encode application behavior in CSS or other static assets.
## State and Side Effects
- Limit component state to ephemeral interaction state such as loading flags, form values, dialogs, and expansion state.
- Application and worker state must be resolved at the page or application boundary and passed through narrow interfaces such as callbacks or notifier protocols.
- Keep filesystem, network, provider, and worker orchestration behind application services or dedicated adapters. UI code may trigger those operations but must not implement them.
+1
View File
@@ -17,3 +17,4 @@ wheels/
# Document images # Document images
uploads/* uploads/*
data/*
+4 -8
View File
@@ -8,14 +8,10 @@
"module": "debugpy", "module": "debugpy",
"args": [ "args": [
"-m", "-m",
"uvicorn", "transcription",
"transcription.app:create_app", "--host", "127.0.0.1",
"--factory", "--port", "9999",
"--host", "--database.driver", "sqlite"
// "127.0.0.1",
"0.0.0.0",
"--port",
"8080"
], ],
"justMyCode": true, "justMyCode": true,
"console": "integratedTerminal", "console": "integratedTerminal",
+72 -6
View File
@@ -22,18 +22,80 @@ uv sync
### 2) Configure environment ### 2) Configure environment
Create a `.env` file in the project root (minimum required setting shown): Create a `.env` file in the project root with the required OpenRouter API key:
```env ```env
OPENROUTER_API_KEY=your_openrouter_api_key OPENROUTER_API_KEY=your_openrouter_api_key
``` ```
Optional settings (defaults shown): Settings are read from CLI arguments first, then environment variables, then `.env`, then the defaults below.
### Configuration Source Precedence
When the same setting is provided in multiple places, the value is chosen in this order (highest priority first):
1. CLI arguments (for example `--port 9999`)
2. Settings constructor arguments (used mainly in tests)
3. Environment variables
4. `.env` file values
5. Model defaults in code
Practical examples:
- `--port 9999` overrides both `PORT=8000` in the shell and `PORT=7000` in `.env`.
- `DATABASE__PATH=prod.db` in the shell overrides `DATABASE__PATH=dev.db` in `.env`.
#### Server and runtime
| Environment variable | Default | Description |
| --- | --- | --- |
| `HOST` | `0.0.0.0` | Address on which the server listens. |
| `PORT` | `8000` | Server port. |
| `LOG_LEVEL` | `info` | Uvicorn and application log level. |
| `RELOAD` | `false` | Restart the development server when source files change. |
| `ENVIRONMENT` | `development` | Runtime environment: `development`, `test`, or `production`. |
#### Provider
| Environment variable | Default | Description |
| --- | --- | --- |
| `PROVIDER` | `openrouter` | Transcription provider. |
| `OPENROUTER_API_KEY` | Required | OpenRouter API key. |
| `PROVIDER_MODEL` | Provider default | Optional model override. |
| `OPENROUTER_HTTP_REFERER` | Unset | Optional OpenRouter attribution URL. |
| `OPENROUTER_APP_TITLE` | Unset | Optional OpenRouter attribution title. |
#### Database and files
Use nested env vars for database settings (recommended):
```env ```env
DATABASE_URL=sqlite:///./transcription.db DATABASE__DRIVER=sqlite
DATABASE__PATH=app.db
# BOOTSTRAP_SCHEMA_ON_STARTUP=true
SQLITE_CHECK_SAME_THREAD=false
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
PROMPT_DIR=./prompts PROMPT_DIR=./prompts
```
For PostgreSQL:
```env
DATABASE__DRIVER=postgres
DATABASE__HOST=localhost
DATABASE__PORT=5432
DATABASE__DATABASE=transcription
DATABASE__USER=postgres
DATABASE__PASSWORD=change-me
```
This uses Pydantic nested settings (`env_nested_delimiter='__'`) and avoids JSON blobs in `.env`. A top-level `DATABASE={...}` JSON value is still supported as a fallback, and nested keys such as `DATABASE__PATH` take precedence over conflicting JSON keys.
`BOOTSTRAP_SCHEMA_ON_STARTUP` creates missing tables when the app starts. When unset, it is enabled in `development` and `test`, and disabled in `production`; set it explicitly to override that policy. `SQLITE_CHECK_SAME_THREAD` defaults to `false`.
#### Worker
```env
WORKER_MAX_RETRIES=0 WORKER_MAX_RETRIES=0
WORKER_RETRY_BACKOFF_SECONDS=0 WORKER_RETRY_BACKOFF_SECONDS=0
WORKER_PROVIDER_TIMEOUT_SECONDS=20 WORKER_PROVIDER_TIMEOUT_SECONDS=20
@@ -45,13 +107,17 @@ WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
### 3) Run the app ### 3) Run the app
```bash ```bash
uv run uvicorn transcription.app:create_app --factory --reload uv run python -m transcription --port 9999 --reload --database.driver sqlite --bootstrap-schema-on-startup
``` ```
This starts the development server with SQLite, creates missing tables, and enables automatic reload. Run `uv run python -m transcription --help` for all CLI options; CLI names use kebab case and nested database options use dot notation, such as `--database.path ./data/transcription.db`.
### 4) Open in browser ### 4) Open in browser
- GUI: [http://[IP_ADDRESS]:8000/ui](http://[IP_ADDRESS]:8000/ui) - GUI: [http://localhost:9999/ui](http://localhost:9999/ui)
- Health check: [http://[IP_ADDRESS]:8000/healthz](http://[IP_ADDRESS]:8000/healthz) - Health check: [http://localhost:9999/healthz](http://localhost:9999/healthz)
Replace `localhost` with the server's hostname or IP address when connecting from another machine.
## How to navigate the GUI ## How to navigate the GUI
-24
View File
@@ -1,24 +0,0 @@
# Historical Document Transcription Design Intent
I have several thousand pages of family history told through letters, postcards, books, and other documents that I want to transcribe to text.
---
## Goals
1. Preserve our family history
2. Unburden my family (and descendants) from having to store and care for the physical media. Once the documents have been transcribed and organized, they can be donated (or kept by a family member that wants to retain them).
3. Make the text easily available and searchable by family members, as well as AI (which may have different requirements).
4. Ability create timelines or assemble the historical record of the family or specific individuals from across the complete document archive. Perhaps use AI to create the timelines in a more narrative form.
---
## Source material
1. **letters, cards, diaries** - handwritten; mostly stored in tubs, with little organization
2. **books** - typed or typeset; mostly self-published books 50-100 pages in length. This may be expanded to include selected pages from other publications.
3. **newspaper clippings, event programs, invitations, and other ephemera**
---
## Methodology
1. Follow current best practices per "A Guide to Documentary Editing" by Mary-Jo Kline. (See [Transcription Methodology](transcription_methodology.md))
+8 -8
View File
@@ -59,7 +59,7 @@ Responsibilities:
### Domain & Service Layer ### Domain & Service Layer
* `src/transcription/models/*.py` (Pydantic V2 schemas and entity definitions) * `src/transcription/db/models.py` (SQLModel/Pydantic V2 schema definitions for the current implementation)
* `src/transcription/services/*.py` (Transactional operations for `Document`, `Person`, `Source`, `Job`, and `JobSource`) * `src/transcription/services/*.py` (Transactional operations for `Document`, `Person`, `Source`, `Job`, and `JobSource`)
### Infrastructure Layer ### Infrastructure Layer
@@ -121,14 +121,14 @@ Responsibilities:
## Related Local References ## Related Local References
- [System Overview](index_v1.md) - [System Overview](index_v2.md)
- [System Design Intent](intent.md) - [System Design Intent](invariant/intent.md)
- [Transcription Methodology](transcription_methodology.md) - [Transcription Methodology](invariant/transcription_methodology.md)
- System Architecture (this document) - System Architecture (this document)
- [System Requirements](requirements_v1.md) - [System Requirements](requirements_v2.md)
- [Data model](schema_v1.md) - [Data model](schema_v2.md)
- [Error Handling Policy](error_handling_v1.md) - [Error Handling Policy](error_handling_v2.md)
- [Implementation Plan](implementation_plan_v1.md) - [Implementation Plan](implementation_plan_v2.md)
-102
View File
@@ -1,102 +0,0 @@
## PostgreSQL DDL Specification (Version 2)
```sql
-- Enable pgcrypto for UUID generation if on PostgreSQL < 13
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- 1. PERSON TABLE
CREATE TABLE person (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
full_name TEXT NOT NULL,
display_name TEXT,
maiden_name TEXT,
birth_date DATE,
birth_date_raw TEXT,
birth_place TEXT,
death_date DATE,
death_date_raw TEXT,
death_place TEXT,
biography TEXT,
portrait_path TEXT,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 2. DOCUMENT TABLE
CREATE TABLE document (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
document_type TEXT,
document_date DATE,
document_date_raw TEXT,
location_created TEXT,
notes TEXT,
archive_identifier TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 3. DOCUMENT_PERSON (Junction Table for Multi-Author / Multi-Recipient)
CREATE TABLE document_person (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES document(id) ON DELETE CASCADE,
person_id UUID NOT NULL REFERENCES person(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL, -- 'author' or 'recipient'
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT unique_document_person_role UNIQUE (document_id, person_id, role)
);
-- 4. JOB TABLE (Batch-level orchestrator)
CREATE TABLE job (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES document(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL DEFAULT 'queued', -- 'queued', 'processing', 'completed', 'partial_success', 'failed'
retry_count INTEGER NOT NULL DEFAULT 0,
provider TEXT NOT NULL, -- e.g., 'openai', 'anthropic'
model TEXT NOT NULL, -- e.g., 'gpt-4o', 'claude-3-5-sonnet'
prompt_name TEXT,
date_created TIMESTAMPTZ NOT NULL DEFAULT now(),
date_updated TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 5. SOURCE TABLE (Physical image files & active state)
CREATE TABLE source (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES document(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL DEFAULT 1,
upload_name TEXT NOT NULL,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
raw_transcription TEXT, -- Cached active AI text output
revised_text TEXT, -- Active human edited text
date_uploaded TIMESTAMPTZ NOT NULL DEFAULT now(),
date_revised TIMESTAMPTZ
);
-- 6. JOB_SOURCE (Junction Table: Per-Image Execution Record)
CREATE TABLE job_source (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES job(id) ON DELETE CASCADE,
source_id UUID NOT NULL REFERENCES source(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL DEFAULT 'pending', -- 'pending', 'transcribed', 'failed'
raw_transcription TEXT, -- Point-in-time raw AI text output
ai_metadata JSONB, -- Page-level bounding boxes, tokens, confidence
raw_api_response JSONB, -- Complete REST response envelope
error_detail TEXT,
executed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT unique_job_source UNIQUE (job_id, source_id)
);
-- INDEXES FOR FAST LOOKUPS & QUERY PERFORMANCE
CREATE INDEX idx_person_full_name ON person(full_name);
CREATE INDEX idx_document_date ON document(document_date);
CREATE INDEX idx_document_person_doc ON document_person(document_id);
CREATE INDEX idx_document_person_per ON document_person(person_id);
CREATE INDEX idx_source_document ON source(document_id);
CREATE INDEX idx_source_page_order ON source(document_id, page_number);
CREATE INDEX idx_job_document ON job(document_id);
CREATE INDEX idx_job_source_job ON job_source(job_id);
CREATE INDEX idx_job_source_source ON job_source(source_id);
CREATE INDEX idx_job_source_ai_metadata ON job_source USING GIN (ai_metadata);
```
+2 -2
View File
@@ -79,8 +79,8 @@ HTTP Status Mappings:
## Related Local References ## Related Local References
- [System Overview](index_v2.md) - [System Overview](index_v2.md)
- [System Design Intent](intent.md) - [System Design Intent](invariant/intent.md)
- [Transcription Methodology](transcription_methodology.md) - [Transcription Methodology](invariant/transcription_methodology.md)
- [System Architecture](architecture_v2.md) - [System Architecture](architecture_v2.md)
- [System Requirements](requirements_v2.md) - [System Requirements](requirements_v2.md)
- [Data model](schema_v2.md) - [Data model](schema_v2.md)
+8 -1
View File
@@ -22,7 +22,7 @@ Use a fresh database. There will be no migrations, data conversion, legacy compa
- Remove `Revision`, `Source.job_id`, and the transcription fields that no longer belong on `Job`. - Remove `Revision`, `Source.job_id`, and the transcription fields that no longer belong on `Job`.
- Keep `create_all()` as the schema bootstrap for a fresh database. - Keep `create_all()` as the schema bootstrap for a fresh database.
- Delete `_ensure_sqlite_compat_columns()` and all schema patching from `src/transcription/db/operations.py`. - Delete `_ensure_sqlite_compat_columns()` and all schema patching from `src/transcription/db/operations.py`.
- Keep the Python models, `docs/schema_v2.md`, and `docs/ddl_v2.sql` consistent. - Keep the Python models and `docs/schema_v2.md` consistent.
### 2. Align the async CRUD methods ### 2. Align the async CRUD methods
@@ -42,6 +42,13 @@ Use a fresh database. There will be no migrations, data conversion, legacy compa
- Test both service-owned sessions and caller-provided sessions so flush/commit behavior remains correct. - Test both service-owned sessions and caller-provided sessions so flush/commit behavior remains correct.
- Run the focused database and service tests, then the full suite with `uv run pytest`. - Run the focused database and service tests, then the full suite with `uv run pytest`.
### 4. Update the UI for the V2 schema
- Review the UI components and views that display document, job, person, and source data so they reference the V2 schema instead of V1 relationships.
- Update upload, detail, and listing screens to show the new person and source associations, revised-source fields, and the revised status values.
- Keep the UI behavior aligned with the updated service layer and ensure the existing UI tests continue to pass with the V2 data model.
- Consider the guidance in `docs/ui_style_guide.md` when making UI changes so the updated views remain consistent with the projects visual and interaction conventions.
## Done When ## Done When
- A fresh database is created directly from the V2 SQLModel metadata. - A fresh database is created directly from the V2 SQLModel metadata.
+2 -2
View File
@@ -38,8 +38,8 @@ Read [architecture_v2.md](architecture_v2.md) first for technical overview and s
## Documentation Index ## Documentation Index
- System Overview (this document) - System Overview (this document)
- [System Design Intent](intent.md) - [System Design Intent](invariant/intent.md)
- [Transcription Methodology](transcription_methodology.md) - [Transcription Methodology](invariant/transcription_methodology.md)
- [System Architecture](architecture_v2.md) - [System Architecture](architecture_v2.md)
- [System Requirements](requirements_v2.md) - [System Requirements](requirements_v2.md)
- [Data model](schema_v2.md) - [Data model](schema_v2.md)
+25
View File
@@ -0,0 +1,25 @@
# Historical Document Transcription Design Intent
I have several thousand pages of family history told through letters, postcards, books, and other documents that I want to transcribe to text.
---
## Goals
1. Preserve our family history
2. Unburden my family (and descendants) from having to store and care for the physical media. Once the documents have been transcribed and organized, they can be donated (or kept by a family member that wants to retain and preserve them).
3. Make the document text easily available and easily searchable.
4. Ability create timelines for individuals and/or families through document dates or the data contained in them. Perhaps even use AI to generate biographies or family histories.
---
## Source material
1. **letters, cards, diaries** - handwritten; mostly stored in boxes and tubs with little organization
2. **books** - typed or typeset; mostly self-published books 50-100 pages in length. This may be expanded to include selected pages from other publications.
3. **photos** - notes written on the backs of photos and the pages of photo albums
4. **other ephemera** - newspaper clippings, event programs, invitations, military records, immigration records, etc
---
## Methodology
1. Follow current best practices per **A Guide to Documentary Editing** by Mary-Jo Kline. (See [Transcription Methodology](transcription_methodology.md))
+112
View File
@@ -0,0 +1,112 @@
# UI Style Guide (Invariant)
## 1. Purpose
This guide defines non-negotiable UI styling rules for the transcription application.
The design system is token-first and class-driven:
1. Theme tokens are defined in [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css).
2. Python UI code composes semantic classes instead of inline color values.
3. Pages and components should share a single visual language across Documents, Jobs, People, and Sources flows.
## 2. Source of Truth
Use these files as the style authority:
1. [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css) for color tokens, semantic utility classes, table styles, and viewer surfaces.
2. [src/transcription/ui/theme.py](src/transcription/ui/theme.py) for runtime NiceGUI theme bridge and shared UI helpers.
If this document conflicts with implementation, update this document to match the code immediately after intentional style changes.
## 3. Core Design Invariants
1. Flat, high-density surfaces over decorative depth.
2. Strong content hierarchy with subdued backgrounds and border-based separation.
3. Viewer area remains the highest contrast region in image/transcription workflows.
4. Primary actions are consistent and visually recognizable.
5. Accessible focus rings are always visible for keyboard users.
## 4. Token System
### 4.1 Palette Tokens
Base palette variables live under :root in [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css):
1. --palette-carbon-black: #1c2321
2. --palette-cool-steel: #7d98a1
3. --palette-blue-slate: #5e6572
4. --palette-powder-blue: #a9b4c2
5. --palette-platinum: #eef1ef
### 4.2 Semantic Theme Tokens
Do not style components directly with palette tokens when a semantic token exists.
Semantic tokens currently include:
1. --theme-text and --theme-text-muted
2. --theme-page, --theme-surface, --theme-surface-raised, --theme-surface-muted
3. --theme-border
4. --theme-primary and --theme-primary-hover
5. --theme-secondary and --theme-focus
6. --theme-inverse-text
7. --theme-viewer, --theme-viewer-border, --theme-viewer-muted
## 5. Approved Semantic Classes
### 5.1 Text and Background
1. ui-text-primary
2. ui-text-muted
3. ui-text-inverse
4. ui-bg-page
5. ui-bg-surface
6. ui-bg-surface-raised
7. ui-bg-surface-muted
8. ui-bg-viewer
9. ui-bg-viewer-overlay
10. ui-bg-viewer-overlay-soft
### 5.2 Borders and Surfaces
1. ui-border-subtle
2. ui-border-viewer
3. ui-header-divider
4. ui-card-surface
5. ui-row-surface
6. ui-note-box
### 5.3 Interactive Elements
1. ui-btn-primary
2. ui-btn-secondary
3. ui-link-primary
4. ui-text-accent
### 5.4 Table Patterns
1. ui-table
2. ui-table-header
3. ui-table-body
Use existing class combinations from [src/transcription/ui/components](src/transcription/ui/components) and [src/transcription/ui/pages](src/transcription/ui/pages) as reference implementations.
## 6. Legacy Class Policy
Legacy classes with vibe- prefix still exist in a few components and are allowed only for compatibility while migrating:
1. Existing usage may remain temporarily.
2. New usage of vibe- classes is not allowed.
3. When touching a file that uses vibe- classes, prefer migrating it to ui- semantic classes in the same change when safe.
Current legacy usage examples are in:
1. [src/transcription/ui/components/document_panzoom.py](src/transcription/ui/components/document_panzoom.py)
2. [src/transcription/ui/components/error_presenter.py](src/transcription/ui/components/error_presenter.py)
3. [src/transcription/ui/components/transcript.py](src/transcription/ui/components/transcript.py)
## 7. Prohibited Patterns
1. Inline hex colors in Python UI class strings or style blocks, except in isolated bridge code explicitly marked for migration.
2. Ad-hoc one-off class names that duplicate existing semantic class intent.
3. Page-specific palette forks that bypass theme tokens.
4. Hidden or low-contrast focus states on interactive controls.
## 8. Implementation Rules For Contributors
1. Prefer composing existing semantic classes before creating new ones.
2. If a new class is required, add it to [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css) with a semantic name, then reuse it.
3. Keep behavior ownership in Python and appearance ownership in CSS.
4. Update UI tests that assert exact text or labels when intentional copy changes are made.
5. Avoid introducing class churn unrelated to the feature being changed.
## 9. Verification Checklist
Before merging UI changes, verify:
1. No new inline hex colors were introduced in UI pages/components.
2. New styles are token-backed and added to [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css).
3. Primary buttons, links, cards, and tables still render with consistent semantics.
4. Keyboard focus ring visibility is preserved.
5. Relevant UI and integration tests pass.
+2 -2
View File
@@ -31,8 +31,8 @@ This document captures the **Version 2 baseline requirements** for the productio
## Related Local References ## Related Local References
- [System Overview](index_v2.md) - [System Overview](index_v2.md)
- [System Design Intent](intent.md) - [System Design Intent](invariant/intent.md)
- [Transcription Methodology](transcription_methodology.md) - [Transcription Methodology](invariant/transcription_methodology.md)
- [System Architecture](architecture_v2.md) - [System Architecture](architecture_v2.md)
- System Requirements (this document) - System Requirements (this document)
- [Data model](schema_v2.md) - [Data model](schema_v2.md)
+4 -3
View File
@@ -50,7 +50,7 @@ erDiagram
JOB { JOB {
UUID id PK UUID id PK
UUID document_id FK UUID document_id FK
VARCHAR status "queued | processing | completed | partial_success | failed" VARCHAR status "queued | processing | transcribed | completed | partial_success | failed"
INTEGER retry_count INTEGER retry_count
TEXT provider TEXT provider
TEXT model TEXT model
@@ -97,6 +97,7 @@ erDiagram
### Page-Level Execution & AI Outputs ### Page-Level Execution & AI Outputs
* Execution Granularity: Every single image execution by an AI model produces a dedicated record in job_source. * Execution Granularity: Every single image execution by an AI model produces a dedicated record in job_source.
* Source vs Execution Status: `source` does not carry a `status` column. Per-source execution state is tracked in `job_source.status` (`pending`, `transcribed`, `failed`).
* Point-in-Time Auditability: job_source.raw_api_response stores the unparsed REST response envelope for that specific image page call. job_source.ai_metadata stores spatial bounding boxes, token usage, and layout details for that specific image page call. * Point-in-Time Auditability: job_source.raw_api_response stores the unparsed REST response envelope for that specific image page call. job_source.ai_metadata stores spatial bounding boxes, token usage, and layout details for that specific image page call.
* Active Output Caching: Upon successful completion of an image call, source.raw_transcription is updated with the latest output string from job_source.raw_transcription for fast UI rendering. * Active Output Caching: Upon successful completion of an image call, source.raw_transcription is updated with the latest output string from job_source.raw_transcription for fast UI rendering.
@@ -126,8 +127,8 @@ erDiagram
## Related Local References ## Related Local References
- [System Overview](index_v2.md) - [System Overview](index_v2.md)
- [System Design Intent](intent.md) - [System Design Intent](invariant/intent.md)
- [Transcription Methodology](transcription_methodology.md) - [Transcription Methodology](invariant/transcription_methodology.md)
- [System Architecture](architecture_v2.md) - [System Architecture](architecture_v2.md)
- [System Requirements](requirements_v2.md) - [System Requirements](requirements_v2.md)
- Data model (this document) - Data model (this document)
+107
View File
@@ -0,0 +1,107 @@
from nicegui import ui
# 1. Mature Dark Mode Setup
ui.dark_mode(True)
# Define a refined dark palette using expanded dictionary styling
theme_colors = {
'primary': '#6366f1',
'secondary': '#8b5cf6',
'accent': '#ec4899',
'dark': '#0f172a',
'dark_page': '#020617',
'positive': '#10b981',
'negative': '#ef4444',
}
ui.colors(**theme_colors)
# Optional: Add custom CSS for subtle noise overlays or kinetic typography
ui.add_css('''
.glass-card {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 1.5rem;
}
''')
# 2. Bento Grid Layout
with ui.element('div').classes('grid grid-cols-1 md:grid-cols-4 gap-6 w-full max-w-6xl mx-auto p-8'):
# Header spanning all columns
with ui.element('div').classes('col-span-1 md:col-span-4 mb-4'):
ui.label('Analytics Dashboard').classes('text-4xl font-extrabold tracking-tight text-white')
ui.label('AI-driven insights for Q3').classes('text-lg text-slate-400 mt-1')
# Large Feature Card (Glassmorphism + Functional Motion)
with ui.element('div').classes('glass-card col-span-1 md:col-span-2 p-6 transition-transform duration-300 hover:scale-[1.02]'):
ui.icon('monitoring', size='2rem').classes('text-primary mb-4')
ui.label('Revenue Prediction').classes('text-xl font-semibold text-slate-100')
ui.label('$45,231.00').classes('text-5xl font-bold text-white mt-2')
# Placeholder for an interactive EChart
ui.echart({
'xAxis': {
'type': 'category',
'data': [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
],
},
'yAxis': {
'type': 'value',
},
'series': [
{
'data': [
120,
200,
150,
80,
70,
],
'type': 'bar',
'itemStyle': {
'color': '#6366f1',
},
},
],
}).classes('w-full h-48 mt-4')
# Smaller Metric Cards
metric_cards = [
{
'title': 'Active Users',
'value': '1,204',
'icon': 'group',
'color': 'text-secondary',
},
{
'title': 'Server Load',
'value': '34%',
'icon': 'memory',
'color': 'text-accent',
},
]
for card in metric_cards:
with ui.element('div').classes('glass-card col-span-1 p-6 flex flex-col justify-between transition-transform duration-300 hover:-translate-y-1'):
ui.icon(card['icon'], size='2rem').classes(card['color'])
ui.element('div').classes('flex-grow')
ui.label(card['value']).classes('text-4xl font-bold text-white mt-4')
ui.label(card['title']).classes('text-sm font-medium text-slate-400 uppercase tracking-wider')
# AI Assistant Module (Adaptive Interface)
with ui.element('div').classes('glass-card col-span-1 md:col-span-4 p-6 flex items-center gap-4'):
ui.icon('smart_toy', size='2rem').classes('text-positive animate-pulse')
with ui.element('div'):
ui.label('Ambient AI Suggestion').classes('text-sm font-bold text-positive uppercase tracking-wider')
ui.label('Based on current server load, scaling up instances in the EU-West region is recommended.').classes('text-slate-300')
ui.space()
ui.button('Apply Now', color='positive').classes('rounded-full px-6 py-2 shadow-lg shadow-positive/20')
ui.run(title='2026 UI Dashboard')
+92
View File
@@ -0,0 +1,92 @@
```mermaid
block-beta
columns 3
%% UI Component Column
block:UI["UI COMPONENTS / WIREFRAME"]:1
columns 1
block:HeaderUI["Header & Nav"]:1
columns 1
h_title["[Text] Document Name & Type"]
h_date["[Text] Date & Origin Location"]
end
block:EditorUI["Page Transcription Editor"]:1
columns 1
ed_img["[Image Viewer] Source Image"]
ed_page["[Badge] Page Number"]
ed_raw["[Read-Only] AI Raw Output"]
ed_rev["[Textarea] Human Revised Text"]
end
block:PeopleUI["Attribution Sidebar"]:1
columns 1
p_author["[List] Authors (Full Name)"]
p_recip["[List] Recipients (Full Name)"]
p_bio["[Card] Person Biography & Dates"]
end
block:JobUI["AI Processing Drawer"]:1
columns 1
j_status["[Badge] Job Status"]
j_model["[Text] Provider & Model"]
j_tokens["[JSON View] AI Token Usage"]
end
end
%% Directional Mapping / Connectors
block:FLOW["MAPPING / FLOW"]:1
columns 1
f1["Reads / Updates -->"]
f2["Renders Active Page -->"]
f3["Joins via Role -->"]
f4["Executes & Logs -->"]
end
%% Postgres Schema Column
block:DB["POSTGRES SQL SCHEMA"]:1
columns 1
block:DocTbl["Table: document"]:1
columns 1
d_id["id : UUID (PK)"]
d_name["name : TEXT"]
d_type["document_type : TEXT"]
d_date["document_date : DATE"]
end
block:SrcTbl["Table: source"]:1
columns 1
s_id["id : UUID (PK)"]
s_page["page_number : INT"]
s_path["file_path : TEXT"]
s_raw["raw_transcription : TEXT"]
s_rev["revised_text : TEXT"]
end
block:PersonTbl["Table: person & document_person"]:1
columns 1
p_id["id : UUID (PK)"]
p_name["full_name : TEXT"]
p_role["role : 'author' | 'recipient'"]
end
block:JobTbl["Table: job & job_source"]:1
columns 1
j_id["id : UUID (PK)"]
j_stat["status : VARCHAR"]
j_prov["provider / model : TEXT"]
j_meta["ai_metadata : JSONB"]
end
end
%% Connections
HeaderUI --> DocTbl
ed_img --> s_path
ed_page --> s_page
ed_raw --> s_raw
ed_rev --> s_rev
PeopleUI --> PersonTbl
JobUI --> JobTbl
```
+53
View File
@@ -0,0 +1,53 @@
```mermaid
flowchart LR
subgraph UI["UI Components / Wireframe"]
direction TB
subgraph HeaderUI["Header & Nav"]
h_title["[Text] Document Name & Type"]
h_date["[Text] Date & Origin Location"]
end
subgraph EditorUI["Page Transcription Editor"]
ed_img["[Image Viewer] Source Image"]
ed_page["[Badge] Page Number"]
ed_raw["[Read-Only] AI Raw Output"]
ed_rev["[Textarea] Human Revised Text"]
end
subgraph PeopleUI["Attribution Sidebar"]
p_author["[List] Authors / Recipients"]
end
subgraph JobUI["AI Processing Drawer"]
j_status["[Badge] Job Status"]
end
end
subgraph DB["Postgres SQL Schema"]
direction TB
subgraph DocTbl["Table: document"]
d_name["name : TEXT"]
d_type["document_type : TEXT"]
end
subgraph SrcTbl["Table: source"]
s_path["file_path : TEXT"]
s_page["page_number : INT"]
s_raw["raw_transcription : TEXT"]
s_rev["revised_text : TEXT"]
end
subgraph PersonTbl["Table: person & document_person"]
p_name["full_name : TEXT"]
p_role["role : author | recipient"]
end
subgraph JobTbl["Table: job & job_source"]
j_stat["status : VARCHAR"]
j_meta["ai_metadata : JSONB"]
end
end
%% Mappings
HeaderUI --> DocTbl
ed_img --> s_path
ed_page --> s_page
ed_raw --> s_raw
ed_rev --> s_rev
PeopleUI --> PersonTbl
JobUI --> JobTbl
```
+80
View File
@@ -0,0 +1,80 @@
# UI Documentation
This folder contains UI-focused design and mapping documents that connect the database schema to user-facing workflows.
## Document Types
### user-journey.md
A product and UX contract for a user-facing entity.
Use this document to describe:
- what the user is trying to do
- which screen or action starts the workflow
- which fields the user sees and edits
- validation rules
- expected success and failure outcomes
- where the user goes next
### schema-mapping.md
A field-level mapping between schema, UI, and implementation.
Use this document to describe:
- the authoritative schema fields for an entity
- which fields are shown, hidden, editable, or system-managed
- current implementation behavior
- intended target behavior
- implementation gaps between current code and intended UX
### acceptance-criteria.md
An implementation-ready checklist for CRUD behavior and quality gates.
Use this document to describe:
- testable acceptance statements by flow (Create, Read, Update, Delete)
- success and failure behaviors
- first-release constraints
- cross-criteria quality gates
### traceability-matrix.md
A criteria-to-code mapping that identifies implementation anchors and status.
Use this document to describe:
- acceptance criteria group to implementation file mapping
- delivery status (implemented, partial, planned)
- ordered implementation priorities
## Organization Rules
- Store documents under `docs/ui/entities/<entity-name>/`.
- Create both `user-journey.md` and `schema-mapping.md` for user-facing entities.
- Create `acceptance-criteria.md` for user-facing entities.
- Create only `schema-mapping.md` for supporting tables that do not currently have standalone UI.
- Keep one shared `traceability-matrix.md` under `docs/ui/entities/` to map criteria to implementation anchors.
- Keep top-level `docs/` reserved for core architecture, requirements, schema, and system-wide reference material.
## Current Entity Plan
User-facing entities:
- `document`
- `person`
- `source`
- `job`
Supporting entities:
- `document-person`
- `job-source`
## Relationship to Core Docs
These UI docs complement, but do not replace:
- `docs/schema_v2.md`
- `docs/requirements_v2.md`
- `docs/architecture_v2.md`
When there is a conflict:
- schema definitions come from the database model and schema docs
- user interaction intent comes from the user-journey docs
- implementation truth comes from code and is recorded in schema-mapping docs as current-state evidence
@@ -0,0 +1,182 @@
# DocumentPerson Schema-to-UI Mapping
Purpose: Map the DocumentPerson schema to UI-facing workflows, while separating intended target behavior from current implementation.
Supporting entity note: DocumentPerson does not currently have a standalone UI surface.
## 1. Entity Snapshot
- Table: document_person
- Primary key: id (UUID)
- Related entities: Document, Person
- Canonical schema references:
- src/transcription/db/models.py
- docs/schema_v2.md
## 2. Mapping Rules
This document uses three lenses:
1. Intended behavior: what user-facing workflows should support indirectly.
2. Current behavior: what code supports today.
3. Gap to target: what must change to align implementation with intended UX.
## 3. Field Inventory
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|---|---|---|---|---|---|
| id | UUID | No | uuid4() | Hidden, system-managed | Primary key |
| document_id | UUID FK | No | None | Context-managed | Selected Document context |
| person_id | UUID FK | No | None | Context-managed | Selected Person context |
| role | enum DocumentPersonRole | No | author | Visible in relationship context | First-release behavior may default to author |
| created_at | datetime | No | datetime.now(UTC) | Hidden or read-only | System-managed timestamp |
Constraint behavior:
1. document_id, person_id, and role are unique as a tuple.
2. duplicate links for the same document, person, and role must be rejected.
## 4. CREATE Mapping
### 4.1 Intended Create Flow
Entry points are indirect through user-facing entities:
1. Document create or update workflows may create one or more DocumentPerson links.
2. Person relationship workflows may create DocumentPerson links.
| Field | Intended User Input | Required | Visible | Notes |
|---|---|---|---|---|
| document_id | None | Yes | No | Derived from selected Document |
| person_id | None | Yes | No | Derived from selected Person |
| role | Select or default | Yes | Indirectly | Defaults to author in first-release behavior |
| created_at | None | No | No | System-generated |
### 4.2 Current Implementation
Current entry point: Document create/edit flows
Current user action: select an existing Person from the Document author dropdown
Current backend path: Document page submit callback -> `DocumentService.create_document_person()` or `delete_document_person()` as the author selection changes
| Field | Current Value at Create | Source | Visible to User | Evidence |
|---|---|---|---|---|
| id | Generated UUID | System | No | src/transcription/db/models.py |
| document_id | Caller-provided | Document UI | Indirectly | src/transcription/ui/pages/documents_page.py |
| person_id | Caller-provided | Document UI | Indirectly | src/transcription/ui/pages/documents_page.py |
| role | Default author in current UI | Service/model default | No | src/transcription/db/models.py, src/transcription/services/documents.py |
| created_at | Current UTC timestamp | System | No | src/transcription/db/models.py |
### 4.3 Gap to Target
To satisfy intended supporting behavior, implementation must add:
1. explicit UI relationship controls in Document and/or Person detail flows.
2. duplicate-link handling with clear user feedback.
3. role-selection UX when role expansion is enabled beyond default author.
## 5. READ Mapping
### 5.1 Intended Read Behavior
Users should see DocumentPerson relationships indirectly in user-facing surfaces:
1. Document detail shows linked people.
2. Person detail shows linked documents.
3. Relationship role is shown where relevant.
### 5.2 Current Implementation
Current read behavior is mainly service-level.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| document_id/person_id link | Indirect relationship usage in workflows | Partial | Document/Person dedicated relationship surfaces are planned | docs/ui/entities/document/*, docs/ui/entities/person/* |
| role | Not shown in current job-centric pages | No | Role expansion is deferred in user-facing workflows | docs/ui/entities/person/user-journey.md |
| created_at | Not rendered | No | Operational metadata only | current UI pages |
Service read/query coverage:
1. read_document_person() returns one link by id.
2. list_document_people() supports filtering by document_id and person_id.
### 5.3 Gap to Target
To satisfy intended read behavior, implementation must add:
1. linked-people and linked-documents UI sections backed by list_document_people().
2. relationship role display where role context is required.
## 6. UPDATE Mapping
### 6.1 Intended Update Behavior
DocumentPerson updates are limited to relationship role or relationship-management actions.
Intended editable fields:
- role (when role management is enabled)
Intended read-only fields:
- id
- document_id
- person_id
- created_at
### 6.2 Current Implementation
| Field | Updatable via UI | Updatable via Service | Notes |
|---|---|---|---|
| role | No | Yes | DocumentService.update_document_person() supports updates |
| document_id/person_id | No | Technically yes via full-row update | Should generally be treated as immutable link identity |
| created_at | No | Technically yes | Should remain system-managed |
### 6.3 Gap to Target
Implementation should add:
1. explicit relationship-role edit controls when product scope enables them.
2. safeguards against mutating link identity instead of recreating links.
## 7. DELETE Mapping
### 7.1 Intended Delete Behavior
Deletion of DocumentPerson should be exposed as unlink behavior in Document and Person flows.
Rules:
1. unlink should remove only the selected relationship.
2. unlink must not delete the underlying Document or Person records.
### 7.2 Current Implementation
| Action | UI Exposed | Backend Capability | Notes |
|---|---|---|---|
| Delete DocumentPerson link | No | Yes | DocumentService.delete_document_person() exists |
### 7.3 Gap to Target
Implementation must add:
1. unlink controls in relationship sections.
2. confirmation and success feedback for relationship removal.
3. blocked-delete guidance if policy constraints are added later.
## 8. Hidden and System-Managed Fields
| Field | Category | Why Hidden or Protected |
|---|---|---|
| id | System-managed | Internal identifier |
| document_id | Context-managed | Derived from selected Document |
| person_id | Context-managed | Derived from selected Person |
| created_at | System-managed | Audit timestamp |
## 9. Traceability Anchors
Schema and models:
- docs/schema_v2.md
- src/transcription/db/models.py
Current implementation:
- src/transcription/services/documents.py
- tests/services/test_v2_crud.py
Related user-facing workflows:
- docs/ui/entities/document/user-journey.md
- docs/ui/entities/person/user-journey.md
## 10. Coverage Summary
- Every DocumentPerson schema field appears in the field inventory.
- Intended behavior is defined as supporting workflow behavior rather than standalone UI.
- Current behavior reflects UI-backed CRUD through Document create/edit flows and Person detail rendering, with no standalone DocumentPerson UI.
- Gaps between intended and current behavior are explicit.
@@ -0,0 +1,136 @@
# Document Acceptance Criteria
Purpose: Define implementation-ready acceptance criteria for Document Read, Update, and Delete workflows.
Companion documents:
- docs/ui/entities/document/user-journey.md
- docs/ui/entities/document/schema-mapping.md
## Scope
This checklist covers:
1. Read flow
2. Update flow
3. Delete flow
This checklist does not cover:
1. Source upload workflow details
2. Job execution internals
3. Revision editor behavior
## Read Acceptance Criteria
### RD-1 Document detail retrieval
1. Given a valid Document id
2. When the user opens the Document detail page
3. Then the system displays Document metadata for that record only
### RD-2 Metadata visibility
1. The page shows name, document_type, document_date, document_date_raw, location_created, notes, archive_identifier
2. created_at and updated_at are displayed as system-managed, read-only values
### RD-3 Related people section
1. Given zero linked people
2. Then the page shows a no linked people yet empty state
3. Given one linked person
4. Then the page shows that linked person
### RD-4 Sources section empty state
1. The page shows a Sources action for the current Document
2. The page shows a primary + Add Source action that opens job-create flow for this Document
3. The action routes to a document-scoped Sources view
### RD-5 Jobs section empty state
1. The page shows a Jobs action for the current Document
2. The page shows a primary + Add Job action for the current Document
3. The action routes to a document-scoped Jobs view
### RD-6 Filtered navigation readiness
1. The detail page provides links or actions that can route to document-scoped Sources and Jobs views
2. Target views are filtered to the current Document id
### RD-7 Failure state
1. Given a nonexistent Document id
2. Then the UI shows a clear not found state without crashing
## Update Acceptance Criteria
### UP-1 Edit entry
1. Given a loaded Document detail page
2. When the user chooses Edit document
3. Then editable controls are shown for allowed fields only, including the author relationship selector
4. The author selector includes No author, existing Person options, and a Create new item option
5. Selecting Create new item routes to Person create
### UP-2 Editable fields
1. Editable: name, document_type, document_date, document_date_raw, location_created, notes, archive_identifier
2. Not editable: id, created_at, updated_at
3. The edit flow may also change the associated author Person link
### UP-3 Required validation
1. name is required
2. document_type is required
3. Save is blocked with inline feedback when either required field is missing
### UP-4 Date handling rule
1. document_date only is allowed
2. document_date_raw only is allowed
3. both fields together are allowed
4. if both are present, document_date is treated as canonical exact date and document_date_raw is retained as descriptive context
### UP-5 Successful save
1. Given valid input
2. When the user saves
3. Then changes persist
4. Then success feedback is shown
5. Then the user remains on Document detail with refreshed values
6. Then updated_at reflects update policy
### UP-6 Save failure
1. Given backend failure during save
2. Then clear error feedback is shown
3. Then the user-entered values remain available for retry where possible
4. Then no false success feedback is shown
## Delete Acceptance Criteria
### DL-1 Delete entry and confirmation
1. Given a Document detail page
2. When the user chooses Delete document
3. Then a confirmation dialog appears with permanent-action wording
### DL-2 Dependency guardrails
1. Delete is allowed only when the Document has no related Source records and no related Job records
2. Delete is blocked when at least one related Source or Job exists
### DL-3 Blocked delete behavior
1. When blocked
2. Then the UI explains why deletion is blocked
3. Then the UI identifies dependency categories present: Sources, Jobs, or both
4. Then the UI provides navigation to dependency cleanup paths
### DL-4 Successful delete
1. Given no blocking dependencies
2. When the user confirms delete
3. Then the Document is removed
4. Then success feedback is shown
5. Then the user is returned to the Document list page
### DL-5 Delete failure
1. Given backend failure during delete
2. Then a clear error message is shown
3. Then the user remains on Document detail with retry path
## Cross-Criteria Quality Gates
### QG-1 Separation of intent and implementation
1. UX intent remains in user-journey.md
2. Current versus target implementation mapping remains in schema-mapping.md
### QG-2 Traceability
1. Each accepted behavior maps to at least one future UI action or service call path
2. No acceptance criterion contradicts the current deferred-item policy
### QG-3 First-release constraints
1. Linked person during create remains optional
2. Recipient and multi-person expansion remain deferred
+231
View File
@@ -0,0 +1,231 @@
# Document Schema-to-UI Mapping
Purpose: Map the Document schema to the UI, while clearly separating intended target behavior from current implementation.
Companion document: user-journey.md
Acceptance criteria: acceptance-criteria.md
## 1. Entity Snapshot
- Table: Document
- Primary key: `id` (UUID)
- Related entities: `Source`, `Job`, `DocumentPerson`, `Person`
- Canonical schema references:
- `src/transcription/db/models.py`
- `docs/schema_v2.md`
## 2. Mapping Rules
This document uses three lenses:
1. Intended behavior: what the UX should support.
2. Current behavior: what the code supports today.
3. Gap to target: what must change to align implementation with the intended UX.
## 3. Field Inventory
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|---|---|---|---|---|---|
| id | UUID | No | `uuid4()` | Hidden, system-managed | Primary key |
| name | str | No | None | Shown, editable on create and edit | Required |
| document_type | str | Yes | None | Shown, editable on create and edit | Required by intended UX |
| document_date | date | Yes | None | Shown, editable | Canonical exact date when present |
| document_date_raw | str | Yes | None | Shown, editable | Approximate or unknown date text |
| location_created | str | Yes | None | Shown, editable | Optional metadata |
| notes | str | Yes | None | Shown, editable | Optional metadata |
| archive_identifier | str | Yes | None | Shown, editable | Free text in first release |
| created_at | datetime | No | `datetime.now(UTC)` | Hidden or read-only | System-managed |
| updated_at | datetime | No | `datetime.now(UTC)` | Hidden or read-only | System-managed |
## 4. CREATE Mapping
### 4.1 Intended Create Flow
Entry point: Document page
User action: Create new document
Success destination: new Document detail page
| Field | Intended User Input | Required | Visible | Notes |
|---|---|---|---|---|
| name | Text input | Yes | Yes | Primary identifier used by the user |
| document_type | Text input | Yes | Yes | Free text in first release |
| document_date | Date input | No | Yes | Structured exact date |
| document_date_raw | Text input | No | Yes | Approximate or uncertain date |
| location_created | Text input | No | Yes | Optional |
| notes | Text area | No | Yes | Optional |
| archive_identifier | Text input | No | Yes | Free text |
| created_at | None | No | No | System-generated |
| updated_at | None | No | No | Not used during initial create |
Related records during intended create:
- A related person may optionally be selected or created.
- If present, the system creates a `DocumentPerson` link.
- Jobs are not created during Document create.
- Sources are not created during Document create.
### 4.2 Current Implementation
Current entry point: `/documents` page
Current user action: open create form, fill metadata, optionally select an existing Person
Current backend path: document page submit callback -> `DocumentService.create_document()` -> optional `DocumentService.create_document_person()`
| Field | Current Value at Create | Source | Visible to User | Evidence |
|---|---|---|---|---|
| id | Generated UUID | System | No | `Document` default factory in `src/transcription/db/models.py` |
| name | User-provided | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| document_type | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| document_date | Parsed from date input or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| document_date_raw | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| location_created | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| notes | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| archive_identifier | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| created_at | Current UTC timestamp | System | No | Default factory in `src/transcription/db/models.py` |
| updated_at | Current UTC timestamp | System | No | Default factory in `src/transcription/db/models.py` |
Current related-record behavior:
- User may optionally select an existing `Person`.
- If selected, `DocumentPerson` is created with role `author`.
- `Job` is not created during Document create.
- `Source` is not created during Document create.
### 4.3 Gap to Target
To satisfy the intended Create flow, implementation now includes:
1. a Document page and dedicated create form
2. user-entered metadata fields for `document_type`, `document_date`, `document_date_raw`, `location_created`, `notes`, and `archive_identifier`
3. optional Person lookup through a dropdown of existing people
4. optional `DocumentPerson` link creation when a person is chosen
5. post-submit routing to a Document detail page
## 5. READ Mapping
### 5.1 Intended Read Behavior
On the Document detail page, the user should be able to see:
1. Document metadata
2. linked people
3. a Sources section with empty-state behavior when no sources exist
4. a Jobs section with empty-state behavior when no jobs exist
5. filtered Jobs and Sources views for the current document
### 5.2 Current Implementation
Current Document visibility in the UI is direct.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| name | Rendered as title and detail heading | Yes | Dedicated Document detail page | `src/transcription/ui/pages/documents_page.py` |
| id | Not shown as raw id | No | Internal identifier remains hidden | `src/transcription/ui/pages/documents_page.py` |
| document_type | Rendered | Yes | Shown on detail and editable on create/edit | `src/transcription/ui/pages/documents_page.py` |
| document_date | Rendered | Yes | Exact date shown when present | `src/transcription/ui/pages/documents_page.py` |
| document_date_raw | Rendered | Yes | Approximate date shown when present | `src/transcription/ui/pages/documents_page.py` |
| location_created | Rendered | Yes | Optional metadata shown | `src/transcription/ui/pages/documents_page.py` |
| notes | Rendered | Yes | Optional metadata shown | `src/transcription/ui/pages/documents_page.py` |
| archive_identifier | Rendered | Yes | Optional metadata shown | `src/transcription/ui/pages/documents_page.py` |
| created_at | Rendered read-only | Yes | System timestamp shown on detail | `src/transcription/ui/pages/documents_page.py` |
| updated_at | Rendered read-only | Yes | System timestamp shown on detail | `src/transcription/ui/pages/documents_page.py` |
### 5.3 Gap to Target
To satisfy the intended Read flow, implementation now includes:
1. metadata rendering for Document fields
2. linked people rendering
3. document-scoped Sources and Jobs navigation views
## 6. UPDATE Mapping
### 6.1 Intended Update Behavior
The user should eventually be able to edit Document metadata from the Document detail page or a dedicated edit flow.
Intended editable fields:
- `name`
- `document_type`
- `document_date`
- `document_date_raw`
- `location_created`
- `notes`
- `archive_identifier`
Intended system-managed fields:
- `id`
- `created_at`
- `updated_at`
### 6.2 Current Implementation
| Field | Updatable via UI | Updatable via Service | Notes |
|---|---|---|---|
| id | No | Practically no | Primary key should be treated as immutable |
| name | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
| document_type | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
| document_date | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
| document_date_raw | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
| location_created | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
| notes | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
| archive_identifier | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
| created_at | No | Technically yes | Should remain system-managed |
| updated_at | No | Technically yes | Should remain system-managed |
### 6.3 Gap to Target
Implementation now includes:
1. Document edit controls in the UI
2. validation and save behavior for Document metadata
3. author relationship controls through the edit flow
## 7. DELETE Mapping
### 7.1 Intended Delete Behavior
The UI should eventually provide a delete action for Document with guardrails.
Rules:
1. A Document can be deleted when it has no attached Jobs and no attached Sources.
2. If dependent Jobs or Sources exist, the UI should block deletion and explain that those related records must be removed first.
3. Delete confirmation should make it clear that the action is permanent.
### 7.2 Current Implementation
| Action | UI Exposed | Backend Capability | Notes |
|---|---|---|---|
| Delete Document | Yes | Yes | `DocumentService.delete_document()` exists and the UI blocks dependent deletes |
### 7.3 Gap to Target
Implementation includes:
1. a Document delete control in the UI
2. pre-delete dependency checks for Jobs and Sources
3. user-facing messaging when deletion is blocked
4. confirmation UX for successful delete attempts
## 8. Hidden and System-Managed Fields
| Field | Category | Why Hidden or Protected |
|---|---|---|
| id | System-managed | Internal identifier |
| created_at | System-managed | Audit timestamp |
| updated_at | System-managed | Audit timestamp |
## 9. Traceability Anchors
Schema and models:
- `docs/schema_v2.md`
- `src/transcription/db/models.py`
Current implementation:
- `src/transcription/ui/pages/documents_page.py`
- `src/transcription/services/documents.py`
- `src/transcription/services/store.py`
- `src/transcription/ui/pages/jobs_page.py`
- `src/transcription/ui/components/transcript.py`
Companion UX spec:
- `docs/ui/entities/document/user-journey.md`
## 10. Acceptance Checklist Summary
- Every Document schema field appears in the field inventory.
- Intended Create behavior matches the companion user journey.
- Current Create behavior reflects the existing upload-driven implementation.
- Gaps between intended and current behavior are explicit.
- Read, Update, and Delete sections distinguish target behavior from current code.
+427
View File
@@ -0,0 +1,427 @@
# Document User Journey
Purpose: Define how a user should interact with the UI to create and manage a Document record, including expected inputs, validation, results, and related record creation.
Scope: This document describes intended user interaction for the Document UI. It is the UX contract for the Document entity.
Companion schema mapping: schema-mapping.md
Companion acceptance criteria: acceptance-criteria.md
## 1. Overview
A Document represents a real historical artifact the user wants to describe, organize, and eventually transcribe. The user should be able to create a Document before uploading or linking any source files.
Creating a Document is a metadata-first workflow:
1. The user opens the Document page.
2. The user selects Create new document.
3. The user enters descriptive metadata about the document.
4. The user optionally selects one related person from the existing Person list.
5. The system creates the Document.
6. If a person was selected, the system links that Person to the Document through DocumentPerson with author role.
7. The user sees a success state and lands on the new Document detail page.
## 2. User Goal
The user wants to create a new Document record that:
1. Has enough metadata to identify the historical artifact.
2. Can optionally be linked to a person.
3. Exists independently of transcription jobs and source uploads.
4. Is ready for later steps such as adding sources, starting jobs, and reviewing transcriptions.
## 3. Page Model
### 3.1 Document Page
The Document page is the general UI surface where users manage documents.
It should support:
1. listing or locating existing documents
2. starting the Create new document flow
3. navigating into a specific Document after it exists
### 3.2 Document Detail Page
The Document detail page is the page for one specific Document after it has been created.
It should show:
1. the Document metadata
2. related people linked to the Document
3. a linked-author summary when available
4. document-scoped navigation links for Sources and Jobs
5. filtered views for sources and jobs linked to the current document
6. primary actions + Add Source and + Add Job
## 4. Entry Point
Entry point: Document page
Primary action: Create new document
Expected UI affordance:
1. A visible button, link, or primary action labeled Create new document.
2. Activation opens a dedicated form view, modal, or detail panel for creating a Document.
Preferred first implementation:
1. A dedicated Document create page or panel.
2. A simple form with explicit labels.
3. Existing Person records should be selectable through a dropdown.
4. Text inputs are acceptable for the remaining fields in first release.
## 5. Create Document Form
The Create Document form should contain the following fields.
### 5.1 Required Fields
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Document name | name | Text input | Yes | Examples: Pioneer Days, Letter from Zenna to Omie |
| Document type | document_type | Text input | Yes | Examples: book, letter, enlistment papers, military record, other |
### 5.2 Date Fields
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Exact date | document_date | Date input | No | Use when the exact date is known |
| Approximate date | document_date_raw | Text input | No | Use when exact date is uncertain, approximate, or unknown |
Date handling rule:
1. The form may allow both fields to be entered.
2. If both fields are entered, `document_date` is the canonical structured date.
3. `document_date_raw` may still be retained as the user-entered descriptive form.
4. The UI should explain the distinction clearly.
Examples:
1. Exact date: `07/13/1885`
2. Approximate date: `c. 1885`
3. Approximate date: `Fall 1925`
4. Approximate date: `unknown`
### 5.3 Optional Metadata Fields
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Document location | location_created | Text input | No | Where the document was created |
| Notes | notes | Multiline text area | No | Freeform notes about the document |
| Archive identifier | archive_identifier | Text input | No | Free text for now; may represent inventory code, storage reference, or repository note |
Archive identifier guidance:
1. First implementation should treat this as free text.
2. Helper text may explain that this can store a repository code, box or folder reference, or storage note.
### 5.4 System Fields
| Schema Field | User Editable | Notes |
|---|---|---|
| created_at | No | System-generated at creation time |
| updated_at | No | Not user-entered during creation |
### 5.5 Optional Related Person
The Create Document flow may optionally link one related person during first release.
| UI Label | Schema Area | Input Type | Required | Notes |
|---|---|---|---|---|
| Related person | Person -> DocumentPerson | Dropdown select | No | Selects an existing Person and links as author when saved |
First release behavior:
1. The user may save a Document without linking any person.
2. If a person is linked during create, only one person is supported in first release.
3. The selected person is linked as author.
4. Additional people and recipient workflows are deferred to a future revision.
### 5.6 Related Records Not Created Directly Here
| Related Area | Included in Document Create | Notes |
|---|---|---|
| Jobs | No | Jobs are created later when transcription work begins |
| Sources | No | Sources are added later as uploaded pages or files |
## 6. Related Person Workflow
### 6.1 User Intent
The user should be able to:
1. select an existing Person to associate with the Document
2. change the associated Person from the Document edit flow
3. save the Document even if no person is linked
### 6.2 Data Model Interpretation
Person selection source:
1. The UI should select from Person records.
2. If a person is linked, the system should create a DocumentPerson record.
3. Role handling for non-author document relationships is deferred.
4. If the first release needs a persisted role immediately, the role can default to `author` until the relationship model is broadened.
This means:
1. The user does not choose from DocumentPerson records.
2. DocumentPerson is the relationship created after the Person is chosen or created.
### 6.3 Related Person UI Behavior
Minimum acceptable first implementation:
1. Dropdown of existing Person records.
2. Clear display of the selected related person before submit.
3. Ability to change or clear the selected person in the Document edit flow.
4. A Create new item option in the author selector that routes to Person create.
5. A visible Create new person link near the selector.
If the person does not exist:
1. The user can use Create new item from the author selector and continue from Person create.
2. The Document create flow links existing Person records after selection.
## 7. Validation Rules
### 7.1 Required Field Validation
The form must reject submission if:
1. `name` is empty
2. `document_type` is empty
### 7.2 Date Validation
The form should allow:
1. `document_date` only
2. `document_date_raw` only
3. both `document_date` and `document_date_raw`
4. neither date field
If both are present:
1. `document_date` is treated as the canonical exact date
2. `document_date_raw` is retained as descriptive context
### 7.3 Related Person Validation
The form must not require a linked person in first release.
If a related person is selected or created:
1. the selected value must resolve to a valid Person record before final save
2. the DocumentPerson link must not be partially persisted on failure
## 8. Submission Behavior
When the user submits the form, the system should perform these logical steps:
1. validate form inputs
2. create the Document record
3. create one DocumentPerson record only if an existing related person was selected
4. persist intended records successfully before reporting success to the user
Expected write sequence:
1. insert Document
2. insert DocumentPerson link only if a person is linked
Recommended transactional behavior:
1. Document and optional DocumentPerson writes should succeed or fail together
2. Person creation is a separate workflow reached from the author selector and is not part of the same transaction
## 9. Expected Result After Success
After successful creation, the user should expect to see:
1. confirmation that the Document was created successfully
2. the Document name displayed in the resulting UI state
3. the Document metadata displayed on the new Document detail page
4. any linked person displayed in the resulting UI state
5. a Sources section showing an empty state when no sources exist yet
6. a Jobs section showing an empty state when no jobs exist yet
7. a clear next step, such as adding source files
Recommended success route:
1. navigate to the new Document detail page
2. show Document summary metadata
3. show linked people section
4. show empty-state placeholders for Sources and Jobs
## 10. Expected Result After Failure
If submission fails, the user should expect:
1. clear error messaging
2. field-level validation feedback where applicable
3. no false success message
4. preservation of entered form values when possible
Examples:
1. missing required name
2. missing required document type
3. failed person creation
4. failed DocumentPerson link creation
5. database or server error
## 11. Read Document Journey
### 11.1 User Intent
The user wants to open a specific Document and quickly understand:
1. what the document is
2. which people are linked to it
3. whether sources exist
4. whether jobs exist
5. what the next action should be
### 11.2 Entry Points
A user can reach a Document detail page by:
1. selecting a document from the Document page list
2. being redirected after successfully creating a new document
3. following a direct link to a known Document record
### 11.3 Document Detail Layout
The Document detail page should include:
1. a header area with document name, document type, and key date values
2. a metadata section with location_created, notes, and archive_identifier
3. System metadata where created_at and updated_at are shown as read-only values
4. a related people section
5. a Sources section
6. a Jobs section
The Document detail page should support:
1. empty-state messaging when no related records exist
2. clear next actions from each empty state
3. filtered Sources and Jobs views scoped to the current document
### 11.4 Read Empty States
If no related records exist:
1. People section says no linked people yet
2. Sources section says no sources added yet
3. Jobs section says no jobs created yet
4. each section presents one clear next action
### 11.5 Read Success Criteria
A successful Read experience means:
1. The user can identify the Document immediately
2. The user can see whether work has started
3. The user can navigate directly to document-scoped Jobs and Sources workflows
## 12. Update Document Journey
### 12.1 User Intent
The user wants to correct or enrich metadata after creation without touching jobs or source transcriptions directly.
### 12.2 Update Entry Point
From the Document detail page:
1. The user selects Edit document
2. UI opens edit mode or a dedicated edit view
### 12.3 Editable Fields
First release editable fields:
1. name
2. document_type
3. document_date
4. document_date_raw
5. location_created
6. notes
7. archive_identifier
Read-only or system-managed fields:
1. id
2. created_at
3. updated_at
### 12.4 Update Validation Rules
1. name remains required
2. document_type remains required
3. document_date and document_date_raw may both be present
4. if both date fields are present, document_date remains canonical
5. validation errors should be shown inline and block save
### 12.5 Update Save Behavior
On save:
1. system validates form data
2. system persists Document updates
3. updated_at is refreshed by system policy
4. UI shows a confirmation message
5. user remains on Document detail page with refreshed values
### 12.6 Update Failure Behavior
If save fails:
1. Show a clear error message
2. keep user edits in form where possible
3. do not show stale success messaging
4. Allow retry without losing context
## 13. Delete Document Journey
### 13.1 User Intent
The user wants to remove a Document only when it is safe and unambiguous.
### 13.2 Delete Entry Point
From the Document detail page:
1. The user selects Delete document
2. UI opens a confirmation dialog explaining permanence
### 13.3 Delete Guardrails
Delete is allowed only when:
1. the Document has no related Source records
2. the Document has no related Job records
Delete is blocked when:
1. any Source exists for the Document
2. any Job exists for the Document
### 13.4 Blocked Delete UX
When blocked:
1. Show an explicit reason that related Jobs or Sources exist
2. Show which dependency types are present
3. provide links to filtered Sources and Jobs for cleanup
4. keep the Document unchanged
### 13.5 Allowed Delete UX
When allowed:
1. Show final confirmation with document name
2. perform delete
3. show success confirmation
4. return user to Document page list
### 13.6 Delete Failure Behavior
If delete fails due to system error:
1. Show a clear error message
2. keep user on Document detail page
3. preserve ability to retry
## 14. Non-Goals for This Flow
The Document journey does not define:
1. Source upload field-level UX
2. Job execution internals
3. revision editor behavior for transcriptions
4. multi-person recipient workflows in first release
## 15. Relationship to Other Workflows
This Document workflow integrates with:
1. Sources workflow for adding pages or files to the document
2. Jobs workflow for transcription execution
3. Person workflow for future expansion beyond one optional linked person
## 16. Relationship to Schema Mapping
This document is the intended UX contract.
The companion schema-mapping document should answer:
1. which schema field appears on which screen
2. whether the field is currently implemented
3. whether the field is hidden, editable, or system-managed
4. what the implementation gap is between intended UX and current code
## 17. Deferred Items
These topics are intentionally deferred to future revisions:
1. multiple linked people during create and update
2. recipient support during create and update
3. a broader role model for non-author document relationships
4. filtered Jobs and Sources list navigation details
@@ -0,0 +1,206 @@
# JobSource Schema-to-UI Mapping
Purpose: Map the JobSource schema to UI-facing workflows, while separating intended target behavior from current implementation.
Supporting entity note: JobSource does not currently have a standalone UI surface.
## 1. Entity Snapshot
- Table: job_source
- Primary key: id (UUID)
- Related entities: Job, Source
- Canonical schema references:
- src/transcription/db/models.py
- docs/schema_v2.md
## 2. Mapping Rules
This document uses three lenses:
1. Intended behavior: what user-facing workflows should support indirectly.
2. Current behavior: what code supports today.
3. Gap to target: what must change to align implementation with intended UX.
## 3. Field Inventory
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|---|---|---|---|---|---|
| id | UUID | No | uuid4() | Hidden, system-managed | Primary key |
| job_id | UUID FK | No | None | Context-managed | Selected Job context |
| source_id | UUID FK | No | None | Context-managed | Selected Source context |
| status | enum JobSourceStatus | No | pending | Shown in job detail source context | Per-source execution state |
| raw_transcription | str | Yes | None | Shown read-only in review context | Machine output per source |
| ai_metadata | JSONB/JSON | Yes | None | Hidden or advanced diagnostics | Provider metadata |
| raw_api_response | JSONB/JSON | Yes | None | Hidden or advanced diagnostics | Low-level provider payload |
| error_detail | str | Yes | None | Shown when status is failed | Execution failure details |
| executed_at | datetime | No | datetime.now(UTC) | Shown read-only | Execution timestamp |
## 4. CREATE Mapping
### 4.1 Intended Create Flow
JobSource creation is indirect through Job and transcription workflows:
1. Job create flow should create a JobSource row for each uploaded source page.
2. Processing workflow may create missing JobSource rows when persisting transcription output.
| Field | Intended User Input | Required | Visible | Notes |
|---|---|---|---|---|
| job_id | None | Yes | No | Derived from active Job |
| source_id | None | Yes | No | Derived from created/selected Source |
| status | None | No | Indirectly | Defaults to pending at create |
| raw_transcription | None | No | No at create | Filled after processing |
| ai_metadata | None | No | No | Operational metadata |
| raw_api_response | None | No | No | Operational payload |
| error_detail | None | No | No at create | Filled on failure |
| executed_at | None | No | No | System-generated |
### 4.2 Current Implementation
Current entry points:
1. upload create path adds pending JobSource link in _create_upload_records().
2. transcription update path creates or updates JobSource row during output persistence.
Current backend paths:
1. src/transcription/services/store.py -> _create_upload_records()
2. src/transcription/services/transcription.py -> update_job_transcription()
| Field | Current Value at Create/Update | Source | Visible to User | Evidence |
|---|---|---|---|---|
| id | Generated UUID | System | No | src/transcription/db/models.py |
| job_id | Caller or workflow derived | Service/workflow | Indirectly | store.py, transcription.py |
| source_id | Caller or workflow derived | Service/workflow | Indirectly | store.py, transcription.py |
| status | pending at create, transcribed or failed on update | Workflow logic | Partial | transcription.py |
| raw_transcription | Set on successful transcription update | Workflow/provider result | Yes in review context | transcription.py, jobs UI |
| ai_metadata | Available in model; not currently filled in update path | Workflow potential | No | models.py, transcription.py |
| raw_api_response | Available in model; not currently filled in update path | Workflow potential | No | models.py, transcription.py |
| error_detail | Set on failed transcription update | Workflow/provider error | Partial | transcription.py |
| executed_at | Set at row creation and refreshed on updates | System/workflow | Partial | models.py, transcription.py |
### 4.3 Gap to Target
To satisfy intended supporting behavior, implementation must add:
1. explicit per-source status display for all linked sources in Job detail.
2. clear surfaced error_detail for failed source executions.
3. optional diagnostics surface for ai_metadata/raw_api_response when needed.
4. first-class multi-source create path from Job create flow.
## 5. READ Mapping
### 5.1 Intended Read Behavior
Users should see JobSource data indirectly in job detail and review workflows:
1. per-source execution status.
2. per-source raw transcription output.
3. per-source failure details where applicable.
4. execution timestamp context.
### 5.2 Current Implementation
Current read behavior is partial and job-detail-centric.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| status | Job-level status is visible; source-level status is limited | Partial | Source-level status not fully surfaced as a dedicated list | src/transcription/ui/pages/jobs_page.py |
| raw_transcription | Original transcription card is visible | Yes | Primary source is shown in current detail flow | src/transcription/ui/components/transcript.py |
| error_detail | Not prominently surfaced in current detail UI | Partial | Stored in JobSource rows during failures | src/transcription/services/transcription.py |
| executed_at | Not first-class rendered | Partial | Available in model for future display | src/transcription/db/models.py |
Service read/query coverage:
1. read_job_source() reads one row with source relation.
2. list_job_sources() lists rows and supports job_id filtering.
### 5.3 Gap to Target
To satisfy intended read behavior, implementation must add:
1. source-level execution table in Job detail.
2. explicit failed-source messaging from error_detail.
3. multi-source navigation in job review UI.
## 6. UPDATE Mapping
### 6.1 Intended Update Behavior
JobSource updates are workflow-managed, not directly user-edited.
Intended user-editable fields:
- none in first-release behavior
Workflow-managed fields:
- status
- raw_transcription
- error_detail
- executed_at
- optional diagnostics payload fields
### 6.2 Current Implementation
| Field | Updatable via UI | Updatable via Service/Workflow | Notes |
|---|---|---|---|
| status | No | Yes | Set by transcription update and job lifecycle handling |
| raw_transcription | No | Yes | Persisted in update_job_transcription() |
| error_detail | No | Yes | Persisted on transcription failure |
| executed_at | No | Yes | Updated when existing JobSource rows are changed |
| ai_metadata/raw_api_response | No | Potentially yes | Model supports them; active population is limited |
### 6.3 Gap to Target
Implementation should add:
1. clearer job-detail visualization of per-source execution updates.
2. optional operator diagnostics views for advanced troubleshooting.
## 7. DELETE Mapping
### 7.1 Intended Delete Behavior
JobSource deletion should be policy-driven and usually tied to Job/Source lifecycle operations.
Rules:
1. direct user deletion is not required in first-release behavior.
2. cleanup should occur through Job or Source deletion policies.
### 7.2 Current Implementation
| Action | UI Exposed | Backend Capability | Notes |
|---|---|---|---|
| Delete JobSource row | No | Yes | TranscriptionService.delete_job_source() exists |
### 7.3 Gap to Target
Implementation may add:
1. maintenance tooling for cleanup operations.
2. policy-aware cascade guidance in Job and Source delete flows.
## 8. Hidden and System-Managed Fields
| Field | Category | Why Hidden or Protected |
|---|---|---|
| id | System-managed | Internal identifier |
| job_id | Context-managed | Derived from Job context |
| source_id | Context-managed | Derived from Source context |
| ai_metadata | Operational metadata | Advanced diagnostics payload |
| raw_api_response | Operational metadata | Raw provider response payload |
| executed_at | System-managed | Execution timestamp |
## 9. Traceability Anchors
Schema and models:
- docs/schema_v2.md
- src/transcription/db/models.py
Current implementation:
- src/transcription/services/store.py
- src/transcription/services/transcription.py
- src/transcription/services/workflows.py
- src/transcription/ui/pages/jobs_page.py
- src/transcription/ui/components/transcript.py
- tests/services/test_v2_crud.py
Related user-facing workflows:
- docs/ui/entities/job/user-journey.md
- docs/ui/entities/source/user-journey.md
## 10. Coverage Summary
- Every JobSource schema field appears in the field inventory.
- Intended behavior is defined as supporting workflow behavior rather than standalone UI.
- Current behavior reflects workflow/service-driven CRUD with partial job-detail visibility.
- Gaps between intended and current behavior are explicit.
+154
View File
@@ -0,0 +1,154 @@
# Job Acceptance Criteria
Purpose: Define implementation-ready acceptance criteria for Job Create, Read, Update, and Delete workflows.
Companion documents:
- docs/ui/entities/job/user-journey.md
- docs/ui/entities/job/schema-mapping.md
## Scope
This checklist covers:
1. Create flow
2. Read flow
3. Update flow
4. Delete flow
This checklist does not cover:
1. provider-specific transcription internals
2. advanced workflow scheduling and queue orchestration controls
3. multi-job bulk operations
## Create Acceptance Criteria
### CR-1 Job creation entry
1. Given the user is on the Jobs page
2. When the user selects Create job
3. Then the user is taken to Job detail/create mode
### CR-2 Required create values
1. document_id must be selected before submit
2. at least one source file must be uploaded before submit
3. each uploaded file creates a Source linked to the selected Document
4. each created Source is linked to the new Job through JobSource
### CR-3 Source ordering behavior
1. Given multi-file or folder upload
2. When source records are created
3. Then page ordering follows alphabetical order of original filenames
4. Then helper text explains how filename conventions control ordering
### CR-4 Provider/model/prompt visibility
1. provider, model, and prompt_name are visible in create flow when known
2. provider, model, and prompt_name are visible in detail flow when known
3. if values are unknown at create time, UI shows clear unknown or pending state without blocking submit
### CR-5 Successful create outcome
1. Given valid inputs
2. When the user submits create
3. Then the Job record is created and linked to selected Document
4. Then source and JobSource records are created for uploads
5. Then job status is queued or processing based on execution timing
6. Then the user is routed to Job detail mode
### CR-6 Create failure outcome
1. Given create validation or persistence failure
2. Then clear error feedback is shown
3. Then no false success feedback is shown
4. Then entered selections are preserved where possible
5. Then retry path remains available
## Read Acceptance Criteria
### RD-1 Jobs list retrieval
1. Given one or more jobs exist
2. When the user opens the Jobs page
3. Then all jobs are listed in a table or equivalent list surface
### RD-2 Jobs list fields
1. Jobs list shows job id
2. Jobs list shows status
3. Jobs list shows created or updated timestamps
4. Jobs list shows retry_count when available
5. Jobs list provides navigation to Job detail for each row
### RD-3 Job detail retrieval
1. Given a valid job id
2. When the user opens Job detail
3. Then job metadata for that record only is shown
4. Then document-scoped navigation links for Sources and Jobs are shown
### RD-4 Detail execution context visibility
1. provider, model, and prompt_name are displayed when known
2. status lifecycle value is visible
3. source-level transcription and revision context is available through Source detail navigation from Job detail
### RD-5 Missing and invalid id states
1. Given an invalid job id format
2. Then UI shows invalid job id state without crashing
3. Given a valid but nonexistent job id
4. Then UI shows job not found state without crashing
## Update Acceptance Criteria
### UP-1 Revision edit entry
1. Given a job detail page
2. When the user opens the page
3. Then navigation links to job-scoped Sources are available
4. Then source rows can open Source detail revision workflow
### UP-2 Revision validation
1. revision save blocks empty trimmed text and shows warning feedback
### UP-3 Successful revision save
1. Source detail save persists revised text and shows success feedback
### UP-4 Revision save failure
1. Source detail save failure shows clear error feedback with retry path
### UP-5 Job lifecycle state update visibility
1. status changes from queued to processing to terminal states are reflected in UI
2. retry_count updates are reflected when retry logic runs
3. users cannot directly edit lifecycle state fields in first release
## Delete Acceptance Criteria
### DL-1 Delete entry and confirmation
1. Given a job detail context
2. When the user opens job delete page
3. Then a permanent-action confirmation is shown for non-processing jobs
### DL-2 Dependency guardrails
1. Delete is blocked while job status is processing
2. Related JobSource links are removed as part of allowed delete flow
### DL-3 Blocked delete behavior
1. When blocked, the UI shows clear processing-state guidance
2. The user is offered navigation back to job or jobs list
### DL-4 Successful delete
1. Given an allowed delete
2. When the user confirms delete
3. Then the job is removed and success feedback is shown
4. Then the user is returned to Jobs list
### DL-5 Delete failure
1. Given backend failure during delete
2. Then clear error feedback is shown
3. Then the user remains in delete context with retry path
## Cross-Criteria Quality Gates
### QG-1 Separation of intent and implementation
1. UX intent remains in user-journey.md
2. Current versus target implementation mapping remains in schema-mapping.md
### QG-2 Traceability
1. Each accepted behavior maps to at least one UI action or service path
2. No acceptance criterion contradicts first-release deferred items
### QG-3 First-release constraints
1. Jobs page remains list-all with explicit Create job action
2. Job create requires Document selection and source upload
3. provider/model/prompt_name are visible to users when known
4. manual retry controls may remain deferred while status visibility is required
+233
View File
@@ -0,0 +1,233 @@
# Job Schema-to-UI Mapping
Purpose: Map the Job schema to the UI, while clearly separating intended target behavior from current implementation.
Companion document: user-journey.md
Acceptance criteria: acceptance-criteria.md
## 1. Entity Snapshot
- Table: Job
- Primary key: id (UUID)
- Related entities: Document, JobSource, Source
- Canonical schema references:
- src/transcription/db/models.py
- docs/schema_v2.md
## 2. Mapping Rules
This document uses three lenses:
1. Intended behavior: what the UX should support.
2. Current behavior: what the code supports today.
3. Gap to target: what must change to align implementation with the intended UX.
## 3. Field Inventory
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|---|---|---|---|---|---|
| id | UUID | No | uuid4() | Shown read-only in list and detail | Primary key |
| document_id | UUID FK | No | None | Required create input via Document selection | Job belongs to one Document |
| status | enum JobStatus | No | queued | Shown read-only as lifecycle state | System-managed transitions |
| retry_count | int | No | 0 | Shown read-only | Operational counter |
| date_created | datetime | No | datetime.now(UTC) | Shown read-only | System-managed timestamp |
| date_updated | datetime | No | datetime.now(UTC) | Shown read-only | System-managed timestamp |
| provider | str | Yes | None | Visible when known; editable if create-time options are available | Processing metadata |
| model | str | Yes | None | Visible when known; editable if create-time options are available | Processing metadata |
| prompt_name | str | Yes | None | Visible when known; editable if create-time options are available | Prompt metadata |
Related execution fields rendered in Job detail via relationships:
- Job detail renders metadata and document links; source-level review/editing is reached through job-scoped Sources routes.
## 4. CREATE Mapping
### 4.1 Intended Create Flow
Entry point: Jobs page Create job action
User action: open create mode, select Document, upload one or more source files or a folder, submit for transcription
Success destination: Job detail page in detail mode
| Field | Intended User Input | Required | Visible | Notes |
|---|---|---|---|---|
| document_id | Select/search | Yes | Yes | Required create selection |
| status | None | No | Yes (read-only) | Starts at queued and changes by workflow |
| retry_count | None | No | Yes (read-only) | Starts at 0 |
| date_created | None | No | Yes (read-only) | System-generated |
| date_updated | None | No | Yes (read-only) | System-generated |
| provider | Display or select | No | Yes | Visible when known during create and detail |
| model | Display or select | No | Yes | Visible when known during create and detail |
| prompt_name | Display or select | No | Yes | Visible when known during create and detail |
Create-related relationship rules:
1. source file upload is required for create.
2. each uploaded file creates a Source linked to the selected Document.
3. each created Source must be linked to the new Job through JobSource.
4. processing order for multi-file and folder uploads is alphabetical by original filename.
### 4.2 Current Implementation
Current entry point: Jobs page create flow
Current user action: select Document and upload one or more files or a folder through a single upload widget
Current backend path: job create submit -> create_job_for_document()
| Field | Current Value at Create | Source | Visible to User | Evidence |
|---|---|---|---|---|
| id | Generated UUID | System | Yes on jobs list/detail | src/transcription/ui/pages/jobs_page.py |
| document_id | Selected existing Document id | User selection + service write | Indirectly | src/transcription/ui/pages/jobs_page.py, src/transcription/services/store.py |
| status | queued | Service/model default | Yes | src/transcription/services/store.py, src/transcription/db/models.py |
| retry_count | 0 | Model default | Yes | src/transcription/db/models.py, src/transcription/ui/pages/jobs_page.py |
| date_created | current UTC timestamp | System | Yes | src/transcription/db/models.py, src/transcription/ui/pages/jobs_page.py |
| date_updated | current UTC timestamp | System | Yes | src/transcription/db/models.py, src/transcription/ui/pages/jobs_page.py |
| provider | None at create, set after transcription update | Workflow/service | Yes | src/transcription/services/workflows.py |
| model | None at create, set after transcription update | Workflow/service | Yes | src/transcription/services/workflows.py |
| prompt_name | None at create, set by workflow updates | Workflow/service | Yes | src/transcription/services/workflows.py |
Current create constraints:
1. dedicated Create job action exists in the Jobs page.
2. job create flow requires a Document selection.
3. current upload path accepts one widget for files or folder selection.
### 4.3 Gap to Target
To satisfy intended Create flow, implementation must add:
1. Jobs list Create job action that opens Job detail/create mode.
2. explicit Document selection and source upload controls in create mode.
3. multi-file and folder upload support in create mode.
4. deterministic alphabetical page ordering and user guidance.
5. explicit visibility of provider, model, and prompt_name in create/detail when known.
## 5. READ Mapping
### 5.1 Intended Read Behavior
On Job list/detail surfaces, users should be able to see:
1. all jobs in one list.
2. status and timeline context.
3. selected Document context.
4. source-level processing and transcription results.
5. provider/model/prompt_name when known.
### 5.2 Current Implementation
Current read behavior exists in jobs list and jobs detail routes.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| id | Jobs list row and detail header | Yes | Primary visible identifier | src/transcription/ui/pages/jobs_page.py |
| status | Jobs list and detail | Yes | Chip styling for transcribed; text for others | src/transcription/ui/pages/jobs_page.py |
| retry_count | Jobs list table | Yes | Included in row model | src/transcription/ui/components/table/jobs.py |
| date_created | Jobs list table | Yes | Included in row model | src/transcription/ui/components/table/jobs.py |
| date_updated | Jobs list table | Yes | Included in row model | src/transcription/ui/components/table/jobs.py |
| document_id | Not rendered directly as labeled field | Partial | Document context exists by relationship but limited direct display | src/transcription/ui/pages/jobs_page.py |
| provider/model/prompt_name | Rendered as labeled fields in Job detail | Yes | Shows pending fallback when unset | src/transcription/ui/pages/jobs_page.py |
Source-related read behavior:
1. Job detail exposes Sources navigation for current job context.
2. Source preview, transcription context, and revision editor are rendered in Source detail.
3. invalid or missing job ids show explicit UI states.
### 5.3 Gap to Target
To satisfy intended Read flow, implementation must add:
1. optional in-page source summaries in Job detail if future UX requires fewer navigation steps.
2. richer filtering/search UX if needed.
## 6. UPDATE Mapping
### 6.1 Intended Update Behavior
Primary user updates in first release are source revision edits in Source detail reached from Job detail.
Intended editable scope (first release):
- Source.revised_text through Source detail review
Intended read-only Job fields in first release:
- id
- document_id after create
- status
- retry_count
- date_created
- date_updated
Job metadata visibility policy:
- provider, model, and prompt_name should be visible when known.
- create-time editing of provider/model/prompt_name is optional and depends on available options.
### 6.2 Current Implementation
| Field/Area | Updatable via UI | Updatable via Service | Notes |
|---|---|---|---|
| Source.revised_text from Source detail | Yes | Yes | Saved via transcription service revision path from Sources page detail route |
| status | No | Yes | Updated by workflow lifecycle services |
| retry_count | No | Yes | Incremented by workflow retry logic |
| provider/model/prompt_name | No | Yes | Set during transcription result finalization |
| document_id | No | Technically via model/service update | Treated as fixed post-create in intended UX |
### 6.3 Gap to Target
Implementation now includes:
1. create-mode handling for provider/model/prompt visibility and optional selection.
2. detail display for provider/model/prompt and document-scoped navigation links.
3. source revision workflow through job-scoped Sources and Source detail pages.
4. manual controls for retry and state transitions remain deferred.
## 7. DELETE Mapping
### 7.1 Intended Delete Behavior
Job deletion is implemented as a dedicated delete route with processing-state guardrails.
Rules:
1. deletion is allowed only when policy allows cleanup or retention handling for related JobSource records.
2. blocked deletion must explain constraints and required cleanup path.
3. successful deletion requires confirmation and returns user to Jobs list.
### 7.2 Current Implementation
| Action | UI Exposed | Backend Capability | Notes |
|---|---|---|---|
| Delete Job | Yes | Yes | Job delete page confirms permanent action and blocks when processing |
### 7.3 Gap to Target
Implementation may add in a future revision:
1. inline delete entry in Job detail header.
2. richer dependency messaging beyond processing-state guardrail.
## 8. Hidden and System-Managed Fields
| Field | Category | Why Hidden or Protected |
|---|---|---|
| status | System-managed lifecycle | Managed by worker lifecycle transitions |
| retry_count | System-managed operational state | Reflects retry behavior, not direct user input |
| date_created | System-managed | Audit timestamp |
| date_updated | System-managed | Audit timestamp |
## 9. Traceability Anchors
Schema and models:
- docs/schema_v2.md
- src/transcription/db/models.py
Current implementation:
- src/transcription/ui/pages/jobs_page.py
- src/transcription/ui/components/table/jobs.py
- src/transcription/ui/pages/sources_page.py
- src/transcription/services/jobs.py
- src/transcription/services/workflows.py
- src/transcription/services/store.py
- src/transcription/services/transcription.py
Companion UX spec:
- docs/ui/entities/job/user-journey.md
Acceptance checklist:
- docs/ui/entities/job/acceptance-criteria.md
## 10. Acceptance Checklist Summary
- Every Job schema field appears in the field inventory.
- Intended Create behavior matches the companion user journey.
- Current behavior reflects explicit jobs creation plus source review/editing through dedicated Sources routes.
- Provider/model/prompt visibility intent is explicit for create and detail views.
- Gaps between intended and current behavior are explicit.
- Read, Update, and Delete sections distinguish target behavior from current code.
+291
View File
@@ -0,0 +1,291 @@
# Job User Journey
Purpose: Define how a user should interact with the UI to create and manage a Job record, including document linking, source uploads, processing status, and page-level review.
Scope: This document describes intended user interaction for the Job UI. It is the UX contract for the Job entity.
Companion schema mapping: schema-mapping.md
Companion acceptance criteria: acceptance-criteria.md
## 1. Overview
A Job represents one transcription run for a selected Document and one or more uploaded source files.
Managing a Job is run-first:
1. The user opens the Jobs page.
2. The user selects Create job.
3. The user lands on a Job detail/create surface.
4. The user links a Document and uploads one or more source files.
5. The user submits for transcription.
6. The system creates and processes the Job.
7. The user reviews job metadata and follows document-scoped links for Sources and Jobs.
## 2. User Goal
The user wants to:
1. see all jobs in one place
2. create a new transcription run intentionally
3. attach the run to the correct Document
4. upload source file(s) for that run
5. submit and monitor processing state
6. review and revise page-level outputs
## 3. Page Model
### 3.1 Jobs List Page
The Jobs page is the primary UI surface where users manage jobs.
It should support:
1. listing all jobs
2. searching or filtering jobs
3. opening job detail for any row
4. starting Create job
5. clear empty state when no jobs exist
### 3.2 Job Detail/Create Page
The Job detail/create page is used for both creating a new Job and viewing an existing Job.
Create mode should include:
1. document selection
2. source upload controls
3. submit for transcription action
Detail mode should include:
1. job metadata and status
2. document-scoped navigation links for the current Document
3. provider/model/prompt visibility when known
4. no delete action in first release
## 4. Entry Points
Primary entry points:
1. from Jobs page, Create job
2. from Jobs page row selection, open existing Job detail
Current implementation note:
1. current code path uses explicit /jobs/new creation
2. intended UX is explicit Create job from the Jobs page
3. current detail view is link-oriented and routes source review/editing through dedicated Source detail
## 5. Create Job Flow
### 5.1 User Intent
The user wants to start a transcription run by selecting the right Document and providing source files in one guided flow.
### 5.2 Create Entry
1. The user opens the Jobs page
2. The user selects Create job
3. The system opens Job detail/create page in create mode
### 5.3 Create Inputs
| UI Label | Schema Area | Input Type | Required | Notes |
|---|---|---|---|---|
| Document | Job.document_id | Select/search | Yes | Links the run to one Document |
| Source files | Source upload fields | Multi-file upload or folder upload | Yes | User may select one file, many files, or a folder |
| Processing order | Source.page_number assignment rule | System rule | Yes | If multiple files are uploaded, order is alphabetical by original filename |
| Provider | Job.provider | Display or select | No | Visible to user when known; selectable when options are available |
| Model | Job.model | Display or select | No | Visible to user when known; selectable when options are available |
| Prompt | Job.prompt_name | Display or select | No | Visible to user when known; selectable when options are available |
### 5.4 Source Handling Rules
1. Each uploaded file becomes a Source linked to the selected Document
2. Each created Source is linked to the Job through JobSource
3. Multi-file or folder uploads are processed alphabetically by original filename
4. upload_name stores the original filename
5. stored filename uses UUID plus original extension in the form UUID.extension
Suggested helper text:
1. Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control page order.
### 5.5 Validation Rules
Create submission must be blocked when:
1. no Document is selected
2. no source file is uploaded
Create submission should provide clear feedback when:
1. uploaded files are invalid or unreadable
2. persistence fails for Job, Source, or JobSource linkage
### 5.6 Submission Behavior
On submit:
1. validate create inputs
2. create Job record linked to selected Document
3. create Source records for uploaded files
4. create JobSource links for each Source in the Job
5. queue processing for transcription
6. route user to Job detail mode
Recommended transactional behavior:
1. intended create writes should succeed or fail together
2. The user should not receive false success when required records fail
### 5.7 Create Success Result
After successful create:
1. job appears in Jobs list
2. job detail shows selected Document and created source set
3. status appears as queued or processing based on execution timing
4. The user can monitor progress and open page-level review
### 5.8 Create Failure Result
If create fails:
1. Show clear error message
2. preserve entered selections where possible
3. keep retry path available
4. do not show false success feedback
## 6. Read Job Journey
### 6.1 User Intent
The user wants to quickly understand what the job is, its current status, and which source pages need review.
### 6.2 Jobs List Expectations
The Jobs list should show, at minimum:
1. job identifier
2. document context
3. current status
4. creation or update timestamp
5. quick action to open detail
Optional first-release columns if available:
1. retry count
2. provider/model summary
### 6.3 Job Detail Expectations
The Job detail should show:
1. job status and summary metadata
2. selected Document context
3. document-scoped and job-scoped navigation links
4. source review entry through job-scoped Sources list
Source detail should show:
1. source metadata and preview
2. original transcription output per source
3. revision editor and latest revised content
### 6.4 Read Empty and Missing States
If no jobs exist:
1. list shows no jobs yet empty state
2. list shows Create job action
If a job id is invalid or missing:
1. Show clear not found state
2. do not crash the page
If a job has no source items due to failure:
1. Show clear warning state
2. keep recovery guidance visible
## 7. Job Status Lifecycle UX
### 7.1 Status Values
The UI should map to model-backed job states:
1. queued
2. processing
3. transcribed
4. completed
5. partial_success
6. failed
### 7.2 In-Progress States
When status is queued or processing:
1. Show active progress state
2. keep detail page refresh-safe
3. indicate that source-level results may still be arriving
### 7.3 Terminal States
When status is completed:
1. Show completion success state
2. direct user to revision workflow
When status is partial_success:
1. Show mixed outcome state
2. identify failed pages
3. guide user to review available successful pages and retry strategy
When status is failed:
1. Show failure state with actionable message
2. keep navigation and retry guidance available
## 8. Update Job Journey
### 8.1 User Intent
The user primarily updates job-related review outcomes by editing revised transcription text per source page.
### 8.2 First-Release Editable Scope
Editable in first release:
1. source-level revised_text through Source detail reached from job-scoped Sources navigation
Read-only in first release:
1. Job.document_id after create
2. job status values managed by processing workflow
3. provider/model/prompt values may be system-managed, but should remain visible in UI when known
### 8.3 Update Save Behavior
On revision save:
1. validate revised text
2. persist revised text for selected source
3. update revised timestamp fields by system policy
4. show success feedback
On save failure:
1. Show clear error feedback
2. preserve entered text where possible
3. Allow retry
## 9. Delete and Retention Policy
### 9.1 User Intent
The user may need to remove invalid or duplicate jobs safely.
### 9.2 First-Release Policy
Delete behavior uses explicit guardrails:
1. deletion is blocked while status is processing
2. blocked delete explains constraints and offers back navigation
3. allowed delete requires explicit confirmation and then returns to Jobs list with success feedback
## 10. Relationship to Other Workflows
Job workflow integrates with:
1. Document workflow for ownership context
2. Source workflow for uploaded page records and ordering
3. Revision workflow for human correction lifecycle
4. Worker processing workflow for queued execution and status transitions
## 11. Relationship to Schema Mapping
The companion schema-mapping document should specify:
1. field visibility per CRUD action
2. current implementation status
3. intended behavior
4. gap-to-target items
## 12. Deferred Items
Deferred to future revisions:
1. manual retry controls from job detail
2. advanced provider/model/prompt policy controls beyond basic create-time visibility
3. advanced bulk actions across multiple jobs
4. live streaming progress updates beyond refresh-based updates
5. job templates or preset configurations
@@ -0,0 +1,154 @@
# Job Acceptance Criteria
Purpose: Define implementation-ready acceptance criteria for Job Create, Read, Update, and Delete workflows.
Companion documents:
- docs/ui/entities/job/user-journey.md
- docs/ui/entities/job/schema-mapping.md
## Scope
This checklist covers:
1. Create flow
2. Read flow
3. Update flow
4. Delete flow
This checklist does not cover:
1. provider-specific transcription internals
2. advanced workflow scheduling and queue orchestration controls
3. multi-job bulk operations
## Create Acceptance Criteria
### CR-1 Job creation entry
1. Given the user is on the Jobs page
2. When the user selects Create job
3. Then the user is taken to Job detail/create mode
### CR-2 Required create values
1. document_id must be selected before submit
2. at least one source file must be uploaded before submit
3. each uploaded file creates a Source linked to the selected Document
4. each created Source is linked to the new Job through JobSource
### CR-3 Source ordering behavior
1. Given multi-file or folder upload
2. When source records are created
3. Then page ordering follows alphabetical order of original filenames
4. Then helper text explains how filename conventions control ordering
### CR-4 Provider/model/prompt visibility
1. provider, model, and prompt_name are visible in create flow when known
2. provider, model, and prompt_name are visible in detail flow when known
3. if values are unknown at create time, UI shows clear unknown or pending state without blocking submit
### CR-5 Successful create outcome
1. Given valid inputs
2. When the user submits create
3. Then the Job record is created and linked to selected Document
4. Then source and JobSource records are created for uploads
5. Then job status is queued or processing based on execution timing
6. Then the user is routed to Job detail mode
### CR-6 Create failure outcome
1. Given create validation or persistence failure
2. Then clear error feedback is shown
3. Then no false success feedback is shown
4. Then entered selections are preserved where possible
5. Then retry path remains available
## Read Acceptance Criteria
### RD-1 Jobs list retrieval
1. Given one or more jobs exist
2. When the user opens the Jobs page
3. Then all jobs are listed in a table or equivalent list surface
### RD-2 Jobs list fields
1. Jobs list shows job id
2. Jobs list shows status
3. Jobs list shows created or updated timestamps
4. Jobs list shows retry_count when available
5. Jobs list provides navigation to Job detail for each row
### RD-3 Job detail retrieval
1. Given a valid job id
2. When the user opens Job detail
3. Then job metadata for that record only is shown
4. Then document-scoped navigation links for Sources and Jobs are shown
### RD-4 Detail execution context visibility
1. provider, model, and prompt_name are displayed when known
2. status lifecycle value is visible
3. source-level transcription and revision context is available through Source detail navigation from Job detail
### RD-5 Missing and invalid id states
1. Given an invalid job id format
2. Then UI shows invalid job id state without crashing
3. Given a valid but nonexistent job id
4. Then UI shows job not found state without crashing
## Update Acceptance Criteria
### UP-1 Revision edit entry
1. Given a job detail page
2. When the user opens the page
3. Then navigation links to job-scoped Sources are available
4. Then source rows can open Source detail revision workflow
### UP-2 Revision validation
1. revision save blocks empty trimmed text and shows warning feedback
### UP-3 Successful revision save
1. Source detail save persists revised text and shows success feedback
### UP-4 Revision save failure
1. Source detail save failure shows clear error feedback with retry path
### UP-5 Job lifecycle state update visibility
1. status changes from queued to processing to terminal states are reflected in UI
2. retry_count updates are reflected when retry logic runs
3. users cannot directly edit lifecycle state fields in first release
## Delete Acceptance Criteria
### DL-1 Delete entry and confirmation
1. Given a job detail context
2. When the user opens job delete page
3. Then a permanent-action confirmation is shown for non-processing jobs
### DL-2 Dependency guardrails
1. Delete is blocked while job status is processing
2. Related JobSource links are removed as part of allowed delete flow
### DL-3 Blocked delete behavior
1. When blocked, the UI shows clear processing-state guidance
2. The user is offered navigation back to job or jobs list
### DL-4 Successful delete
1. Given an allowed delete
2. When the user confirms delete
3. Then the job is removed and success feedback is shown
4. Then the user is returned to Jobs list
### DL-5 Delete failure
1. Given backend failure during delete
2. Then clear error feedback is shown
3. Then the user remains in delete context with retry path
## Cross-Criteria Quality Gates
### QG-1 Separation of intent and implementation
1. UX intent remains in user-journey.md
2. Current versus target implementation mapping remains in schema-mapping.md
### QG-2 Traceability
1. Each accepted behavior maps to at least one UI action or service path
2. No acceptance criterion contradicts first-release deferred items
### QG-3 First-release constraints
1. Jobs page remains list-all with explicit Create job action
2. Job create requires Document selection and source upload
3. provider/model/prompt_name are visible to users when known
4. manual retry controls may remain deferred while status visibility is required
@@ -0,0 +1,147 @@
# Person Acceptance Criteria
Purpose: Define implementation-ready acceptance criteria for Person Create, Read, Update, and Delete workflows.
Companion documents:
- docs/ui/entities/person/user-journey.md
- docs/ui/entities/person/schema-mapping.md
## Scope
This checklist covers:
1. Create flow
2. Read flow
3. Update flow
4. Delete flow
This checklist does not cover:
1. advanced metadata_ editing UX
2. structured-name schema migration implementation
3. bulk merge or dedup workflow design
## Create Acceptance Criteria
### CR-1 Person creation entry
1. Given a Person page
2. When the user selects Create new person
3. Then the user can open a Person create form
### CR-2 Required field validation
1. full_name is required
2. Save is blocked when full_name is empty
3. Inline feedback is shown for required-field errors
### CR-3 Optional field handling
1. Optional fields may be blank without blocking create
2. Date raw and exact fields can coexist
3. Exact date remains canonical when both exact and raw are provided
4. Portrait uploads persist under uploads/portraits/person and store a relative portrait_path
### CR-4 Successful create outcome
1. Given valid input
2. When the user saves
3. Then the Person record is created
4. Then success feedback is shown
5. Then the user is routed to Person detail page
### CR-5 Create failure outcome
1. Given backend failure during create
2. Then clear error feedback is shown
3. Then entered values are retained where possible
4. Then no false success feedback is shown
## Read Acceptance Criteria
### RD-1 Person detail retrieval
1. Given a valid Person id
2. When the user opens the Person detail page
3. Then the system displays Person metadata for that record only
### RD-2 Metadata visibility
1. The page shows full_name and available optional person fields
2. created_at and updated_at are shown as system-managed, read-only values
3. portrait_path is rendered when available, including an image preview when possible
4. relative portrait_path values resolve through /uploads for image rendering
### RD-3 Linked documents section
1. Given no linked DocumentPerson rows
2. Then the page shows a no linked documents yet empty state
3. Given linked documents exist
4. Then the page shows linked document entries
### RD-4 Read failure state
1. Given a nonexistent Person id
2. Then the UI shows a clear not found state without crashing
## Update Acceptance Criteria
### UP-1 Edit entry
1. Given a loaded Person detail page
2. When the user selects Edit person
3. Then editable controls are shown for allowed fields only
### UP-2 Editable fields
1. Editable: full_name, display_name, maiden_name, birth/death fields, places, biography, portrait_path
2. Not editable: id, created_at, updated_at
3. metadata_ remains hidden in first release
### UP-3 Required validation
1. full_name remains required
2. Save is blocked with inline feedback when full_name is empty
### UP-4 Successful save
1. Given valid input
2. When the user saves
3. Then changes persist
4. Then success feedback is shown
5. Then the user remains on Person detail with refreshed values
### UP-5 Save failure
1. Given backend failure during save
2. Then clear error feedback is shown
3. Then the user-entered values remain available for retry where possible
4. Then no false success feedback is shown
## Delete Acceptance Criteria
### DL-1 Delete entry and confirmation
1. Given a Person detail page
2. When the user selects Delete person
3. Then a confirmation dialog appears with permanent-action wording
### DL-2 Relationship guardrails
1. Delete is allowed only when relationship policy allows it
2. If linked DocumentPerson rows must be removed first, delete is blocked
### DL-3 Blocked delete behavior
1. When blocked
2. Then the UI explains why deletion is blocked
3. Then the UI identifies linked-document dependency presence
4. Then the UI provides navigation to cleanup paths
### DL-4 Successful delete
1. Given no blocking dependencies
2. When the user confirms delete
3. Then the Person record is removed
4. Then success feedback is shown
5. Then the user returns to the Person list page
### DL-5 Delete failure
1. Given backend failure during delete
2. Then clear error feedback is shown
3. Then the user remains on Person detail with retry path
## Cross-Criteria Quality Gates
### QG-1 Separation of intent and implementation
1. UX intent remains in user-journey.md
2. Current versus target implementation mapping remains in schema-mapping.md
### QG-2 Traceability
1. Each accepted behavior maps to at least one future UI action or service call path
2. No acceptance criterion contradicts the deferred-item policy
### QG-3 First-release constraints
1. metadata_ remains hidden in first release
2. structured name field split remains deferred
3. recipient and multi-person role management stays in later revisions
+265
View File
@@ -0,0 +1,265 @@
# Person Schema-to-UI Mapping
Purpose: Map the Person schema to the UI, while clearly separating intended target behavior from current implementation.
Companion document: user-journey.md
Acceptance criteria: acceptance-criteria.md
## 1. Entity Snapshot
- Table: Person
- Primary key: id (UUID)
- Related entities: DocumentPerson, Document
- Canonical schema references:
- src/transcription/db/models.py
- docs/schema_v2.md
## 2. Mapping Rules
This document uses three lenses:
1. Intended behavior: what the UX should support.
2. Current behavior: what the code supports today.
3. Gap to target: what must change to align implementation with the intended UX.
## 3. Field Inventory
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|---|---|---|---|---|---|
| id | UUID | No | uuid4() | Hidden, system-managed | Primary key |
| full_name | str | No | None | Shown, editable on create and update | Required canonical name |
| display_name | str | Yes | None | Shown, editable | Optional |
| maiden_name | str | Yes | None | Shown, editable | Optional |
| birth_date | date | Yes | None | Shown, editable | Canonical exact date when present |
| birth_date_raw | str | Yes | None | Shown, editable | Approximate or unknown date text |
| birth_place | str | Yes | None | Shown, editable | Optional |
| death_date | date | Yes | None | Shown, editable | Canonical exact date when present |
| death_date_raw | str | Yes | None | Shown, editable | Approximate or unknown date text |
| death_place | str | Yes | None | Shown, editable | Optional |
| biography | str | Yes | None | Shown, editable | Optional narrative |
| portrait_path | str | Yes | None | Shown, editable | Optional path |
| metadata_ | JSONB/JSON | Yes | None | Hidden in first release | Advanced metadata |
| created_at | datetime | No | datetime.now(UTC) | Hidden or read-only | System-managed |
| updated_at | datetime | No | datetime.now(UTC) | Hidden or read-only | System-managed |
## 4. CREATE Mapping
### 4.1 Intended Create Flow
Entry point: Person page
User action: Create new person
Success destination: new Person detail page
| Field | Intended User Input | Required | Visible | Notes |
|---|---|---|---|---|
| full_name | Text input | Yes | Yes | Canonical identity field |
| display_name | Text input | No | Yes | Optional |
| maiden_name | Text input | No | Yes | Optional |
| birth_date | Date input | No | Yes | Structured exact date |
| birth_date_raw | Text input | No | Yes | Approximate/uncertain date |
| birth_place | Text input | No | Yes | Optional |
| death_date | Date input | No | Yes | Structured exact date |
| death_date_raw | Text input | No | Yes | Approximate/uncertain date |
| death_place | Text input | No | Yes | Optional |
| biography | Text area | No | Yes | Optional |
| portrait_path | Text input | No | Yes | Optional |
| metadata_ | None | No | No | Hidden in first release |
| created_at | None | No | No | System-generated |
| updated_at | None | No | No | Not user-entered |
Related records during intended create:
- No DocumentPerson link is required during Person creation.
- Document linking can be done later from Document or Person workflows.
### 4.2 Current Implementation
Current entry point: dedicated People page and Person create/edit flows
Current user action: open Person create page, fill form fields, optionally upload portrait
Current backend path: People page submit callbacks -> DocumentService.create_person() / update_person()
| Field | Current Value at Create | Source | Visible to User | Evidence |
|---|---|---|---|---|
| id | Generated UUID | System | No | Person model default factory in src/transcription/db/models.py |
| full_name | Form input | User input | Yes | src/transcription/ui/pages/people_page.py |
| display_name | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| maiden_name | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| birth_date | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| birth_date_raw | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| birth_place | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| death_date | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| death_date_raw | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| death_place | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| biography | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| portrait_path | Relative upload path or manual path | Upload helper + user input | Yes | src/transcription/ui/pages/people_page.py, src/transcription/services/store.py |
| metadata_ | Caller-provided or None | Service/API caller | No | Person model in src/transcription/db/models.py |
| created_at | Current UTC timestamp | System | No | Person model default in src/transcription/db/models.py |
| updated_at | Current UTC timestamp | System | No | Person model default in src/transcription/db/models.py |
### 4.3 Gap to Target
To satisfy the intended Create flow, implementation now includes:
1. a Person page and dedicated create form
2. user-entered controls for Person fields
3. create validation and success/failure UX states
4. post-submit routing to a Person detail page
## 5. READ Mapping
### 5.1 Intended Read Behavior
On the Person detail page, the user should be able to see:
1. Person identity and biographical metadata
2. linked Documents (through DocumentPerson)
3. empty-state behavior when no linked documents exist
### 5.2 Current Implementation
Current Person visibility is implemented in dedicated list/detail/edit/delete pages.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| full_name | Rendered in header and summary | Yes | Dedicated Person page exists | `src/transcription/ui/pages/people_page.py` |
| display_name | Rendered | Yes | Visible in detail and list contexts | src/transcription/ui/pages/people_page.py |
| maiden_name | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| birth_date | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| birth_date_raw | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| birth_place | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| death_date | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| death_date_raw | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| death_place | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| biography | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| portrait_path | Rendered as text and image when available | Yes | Dedicated Person page exists | `src/transcription/ui/pages/people_page.py` |
| metadata_ | Not rendered | No | Hidden advanced field | no current UI field |
| created_at | Rendered read-only | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| updated_at | Rendered read-only | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
### 5.3 Gap to Target
To satisfy the intended Read flow, implementation now includes:
1. metadata rendering for Person fields
2. linked Documents section with empty states
3. document-link navigation paths
## 6. UPDATE Mapping
### 6.1 Intended Update Behavior
The user should be able to edit Person metadata from the Person detail page or a dedicated edit flow.
Intended editable fields:
- full_name
- display_name
- maiden_name
- birth_date
- birth_date_raw
- birth_place
- death_date
- death_date_raw
- death_place
- biography
- portrait_path
Intended system-managed fields:
- id
- created_at
- updated_at
Hidden in first release:
- metadata_
### 6.2 Current Implementation
| Field | Updatable via UI | Updatable via Service | Notes |
|---|---|---|---|
| id | No | Practically no | Primary key should be treated as immutable |
| full_name | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| display_name | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| maiden_name | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| birth_date | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| birth_date_raw | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| birth_place | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| death_date | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| death_date_raw | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| death_place | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| biography | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| portrait_path | Yes | Yes | Editable manually and via portrait upload helper |
| metadata_ | No | Yes | Technically updatable, hidden in first release |
| created_at | No | Technically yes | Should remain system-managed |
| updated_at | No | Technically yes | Should remain system-managed |
### 6.3 Gap to Target
Implementation now includes:
1. Person edit controls in the UI
2. validation and save behavior for Person metadata
3. a consistent updated_at update policy for Person edits
## 7. DELETE Mapping
### 7.1 Intended Delete Behavior
The UI should provide a delete action for Person with guardrails.
Rules:
1. Deletion can proceed when relationship policy allows no retained document links.
2. If linked DocumentPerson records exist and policy requires cleanup first, deletion is blocked.
3. Delete confirmation must make clear that deletion is permanent.
### 7.2 Current Implementation
| Action | UI Exposed | Backend Capability | Notes |
|---|---|---|---|
| Delete Person | Yes | Yes | Dedicated delete page enforces linked-document guardrails before service delete |
### 7.3 Gap to Target
Implementation includes:
1. a Person delete control in the UI
2. relationship-aware pre-delete checks
3. user-facing blocked-delete messaging
4. confirmation UX for successful delete attempts
## 8. Hidden and System-Managed Fields
| Field | Category | Why Hidden or Protected |
|---|---|---|
| id | System-managed | Internal identifier |
| created_at | System-managed | Audit timestamp |
| updated_at | System-managed | Audit timestamp |
| metadata_ | Hidden in first release | Advanced JSON metadata not needed in initial UI |
## 9. Structured Name Deferred Note
Structured name fields are deferred to a future schema revision.
Current policy:
1. full_name remains canonical and required.
Future revision intent:
1. introduce first_name, middle_name, last_name, and optional suffix fields.
2. maintain compatibility with existing full_name records during migration.
3. define normalization and reconciliation rules when structured and canonical forms differ.
## 10. Traceability Anchors
Schema and models:
- docs/schema_v2.md
- src/transcription/db/models.py
Current implementation:
- src/transcription/services/documents.py
- src/transcription/ui/pages/people_page.py
- src/transcription/services/store.py
Companion UX spec:
- docs/ui/entities/person/user-journey.md
Acceptance checklist:
- docs/ui/entities/person/acceptance-criteria.md
## 11. Acceptance Checklist Summary
- Every Person schema field appears in the field inventory.
- Intended Create behavior matches the companion user journey.
- Current Create behavior reflects dedicated UI form implementation with optional portrait upload handling.
- Gaps between intended and current behavior are explicit.
- Read, Update, and Delete sections distinguish target behavior from current code.
+292
View File
@@ -0,0 +1,292 @@
# Person User Journey
Purpose: Define how a user should interact with the UI to create and manage a Person record, including expected inputs, validation, outcomes, and links to Document relationships.
Scope: This document describes intended user interaction for the Person UI. It is the UX contract for the Person entity.
Companion schema mapping: schema-mapping.md
Companion acceptance criteria: acceptance-criteria.md
## 1. Overview
A Person represents a historical individual who may be associated with one or more Documents.
Managing a Person is a profile-first workflow:
1. The user opens the Person page.
2. The user selects Create new person.
3. The user enters known biographical fields.
4. The system creates the Person record.
5. The user can later associate the Person with one or more Documents through DocumentPerson links.
## 2. User Goal
The user wants to:
1. create and maintain historical person records
2. reuse the same Person across multiple Documents
3. record both precise and approximate date values where certainty is limited
4. link people to documents as author or recipient in future flows
## 3. Page Model
### 3.1 Person Page
The Person page is the general UI surface where users manage people.
It should support:
1. listing or locating existing people
2. starting the Create new person flow
3. navigating into a specific Person after it exists
### 3.2 Person Detail Page
The Person detail page is the page for one specific Person after creation.
It should show:
1. core identity fields
2. biographical metadata
3. portrait image when available
4. related Documents section
5. empty state when no linked documents exist yet
## 4. Entry Point
Entry point: Person page
Primary action: Create new person
Expected UI affordance:
1. a visible action labeled Create new person
2. activation opens a dedicated form view, modal, or detail panel
Preferred first implementation:
1. dedicated Person create page or panel
2. simple labeled form controls
3. text inputs are acceptable for first release
## 5. Create Person Form
### 5.1 Required Fields
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Full name | full_name | Text input | Yes | Canonical identity field |
### 5.2 Optional Name Fields
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Display name | display_name | Text input | No | Friendly or abbreviated display |
| Maiden name | maiden_name | Text input | No | Historical alternate surname |
### 5.3 Birth and Death Date Fields
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Birth date | birth_date | Date input | No | Exact known date |
| Birth date (approximate/raw) | birth_date_raw | Text input | No | Approximate or uncertain value |
| Death date | death_date | Date input | No | Exact known date |
| Death date (approximate/raw) | death_date_raw | Text input | No | Approximate or uncertain value |
Date handling rule:
1. exact and raw values may both be entered
2. exact date is canonical when present
3. raw date is retained as historical context
### 5.4 Optional Biographical Fields
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Birth place | birth_place | Text input | No | Free text |
| Death place | death_place | Text input | No | Free text |
| Biography | biography | Text area | No | Narrative context |
| Portrait path | portrait_path | Text input | No | File or resource path |
| Metadata | metadata_ | Hidden or advanced JSON editor | No | Prefer hidden in first release |
### 5.5 System Fields
| Schema Field | User Editable | Notes |
|---|---|---|
| id | No | System-generated |
| created_at | No | System-generated |
| updated_at | No | System-managed |
## 6. Validation Rules
### 6.1 Required Validation
1. full_name is required
2. save is blocked when full_name is empty
### 6.2 Date Validation
1. birth_date and birth_date_raw may coexist
2. death_date and death_date_raw may coexist
3. exact date fields are canonical when present
4. raw fields remain descriptive context
### 6.3 Integrity Validation
1. form accepts unknown values for optional fields
2. missing birth or death data does not block creation
## 7. Submission Behavior
On submit:
1. The system validates required fields
2. The system creates the Person record
3. The system returns the user to the Person detail page
4. The system shows a success message
5. If portrait upload is used, the file is stored under uploads/portraits/person and portrait_path is set to that relative file path
Recommended transactional behavior:
1. Person writes are atomic
2. no partial save state should be persisted
## 8. Expected Result After Success
After successful creation:
1. The user sees the Person detail page for the new record
2. full_name is visible in the header or summary
3. empty Related Documents section is shown if no links exist
4. The user can proceed to link this person from Document workflows
## 9. Expected Result After Failure
If creation fails:
1. Show a clear error message
2. Show field-level feedback for validation failures
3. preserve entered data where possible
4. do not show false success messaging
## 10. Read Person Journey
### 10.1 User Intent
The user wants to open a Person and quickly understand:
1. identity and key biography fields
2. whether this person is linked to any documents
3. what next action to take
### 10.2 Read Surfaces
The Person detail page should show:
1. full_name and display fields
2. birth and death fields
3. biography summary
4. related documents list or empty state
5. portrait preview resolved from /uploads when portrait_path is a relative path
### 10.3 Read Empty State
If no linked documents exist:
1. Show No linked documents yet
2. provide guidance to link from Document workflow
## 11. Update Person Journey
### 11.1 User Intent
The user wants to correct or enrich person metadata over time.
### 11.2 Editable Fields
Editable:
1. full_name
2. display_name
3. maiden_name
4. birth_date
5. birth_date_raw
6. birth_place
7. death_date
8. death_date_raw
9. death_place
10. biography
11. portrait_path
System-managed:
1. id
2. created_at
3. updated_at
4. metadata_ can remain hidden in first release
### 11.3 Update Save Behavior
On save:
1. validate required fields
2. persist updates
3. refresh updated_at by system policy
4. show confirmation
5. keep user on Person detail page
### 11.4 Update Failure Behavior
1. Show clear error feedback
2. preserve form state where possible
3. Allow retry
## 12. Delete Person Journey
### 12.1 User Intent
The user wants to remove incorrect or duplicate person records safely.
### 12.2 Delete Guardrails
Delete is allowed when:
1. Person has no required retained relationships
Delete is blocked when:
1. Person is linked to one or more Documents via DocumentPerson and unlink policy requires cleanup first
### 12.3 Blocked Delete UX
1. explain that linked Document relationships exist
2. Show link count or list
3. provide cleanup path
### 12.4 Allowed Delete UX
1. Show a confirmation dialog
2. confirm permanent action
3. delete Person
4. return to Person list with success message
## 13. Relationship to Other Workflows
This Person workflow integrates with:
1. Document create and update workflows through person lookup and linking
2. DocumentPerson mapping for role assignments
3. future recipient and multi-person enhancements
## 14. Relationship to Schema Mapping
The companion schema-mapping document should specify:
1. field visibility per CRUD action
2. current implementation status
3. intended behavior
4. gap-to-target items
## 15. Deferred Items
Deferred to future revisions:
1. advanced metadata_ editing UI
2. multi-person role editing in the Person UI itself
3. richer relationship timeline views
4. bulk merge or dedup workflows
5. structured name fields migration (first_name, middle_name, last_name, optional suffix)
### 15.1 Structured Name Fields Migration Note
For now, `full_name` remains the canonical required name field.
Future revision intent:
1. introduce structured fields such as first_name, middle_name, last_name, and optional suffix
2. keep full_name during transition for backward compatibility and historical formatting
3. define normalization and formatting rules for display and sorting
4. update search and dedup workflows to use both structured and canonical forms during migration
Migration considerations:
1. schema migration and backfill strategy for existing Person records
2. validation updates for create and update forms
3. compatibility for existing APIs and UI components that currently rely on full_name
4. clear precedence and reconciliation rules when structured fields and full_name differ
@@ -0,0 +1,141 @@
# Source Acceptance Criteria
Purpose: Define implementation-ready acceptance criteria for Source Create, Read, Update, and Delete workflows.
Companion documents:
- docs/ui/entities/source/user-journey.md
- docs/ui/entities/source/schema-mapping.md
## Scope
This checklist covers:
1. Create flow
2. Read flow
3. Update flow
4. Delete flow
This checklist does not cover:
1. advanced multi-version revision history design
2. job orchestration state-machine behavior
3. provider-level transcription internals
## Create Acceptance Criteria
### CR-1 Source creation entry
1. Given the user is in job creation or job configuration flow
2. When the user selects Add sources
3. Then the user can upload one or more source files or a folder
4. Then source creation is not offered as a standalone first-release document-only flow
### CR-2 Required create values
1. document_id is derived from selected Document context
2. JobSource.job_id is derived from the active Job context
3. Each created Source is linked to the active Job through JobSource at create time
4. page_number is assigned to preserve ordering
5. upload_name, filename, and file_path are persisted for each created source
### CR-3 Ordering and filename strategy
1. Given a multi-file or folder upload
2. When source records are created
3. Then page ordering follows alphabetical order of original filenames
4. Then upload_name stores the original filename
5. Then filename is stored using UUID plus original extension in the form UUID.extension
### CR-4 Successful create outcome
1. Given valid uploads
2. When source creation completes
3. Then Source records are created and linked to the Document
4. Then Source records are linked to the active Job through JobSource
5. Then source list reflects new pages in sequence
6. Then the user can open preview or revision workflow
### CR-5 Create failure outcome
1. Given upload or persistence failure
2. Then clear error feedback is shown
3. Then no false success feedback is shown
4. Then retry path remains available
5. Then creation fails when required Document or Job linkage cannot be established
## Read Acceptance Criteria
### RD-1 Source detail retrieval
1. Given a valid Source id in source context
2. When the user opens source detail
3. Then source metadata and preview are displayed for that source only
### RD-2 Transcription and revision visibility
1. Original transcription context is visible read-only in Source detail
2. Revision state is visible in Source detail
3. If revised_text is absent, revision input opens as empty and can be edited
### RD-3 Missing source state
1. Given a missing source
2. Then UI shows clear no source available or not found messaging without crashing
## Update Acceptance Criteria
### UP-1 Revision editing entry
1. Given a source context
2. When the user enters revision edit flow
3. Then revised_text input is available in Source detail
### UP-2 Revision validation
1. revised_text cannot be saved as empty after trimming
2. Warning feedback is shown for invalid empty input
### UP-3 Successful revision save
1. Given valid revision text
2. When the user saves
3. Then revised_text persists
4. Then date_revised is updated
5. Then success feedback is shown
6. Then refreshed revision content is visible
### UP-4 Revision save failure
1. Given backend failure during save
2. Then clear error feedback is shown
3. Then the user-entered text remains available for retry where possible
## Delete Acceptance Criteria
### DL-1 Delete entry and confirmation
1. Given a source in source context
2. When the user selects delete source
3. Then a permanent-action confirmation dialog appears
### DL-2 Dependency guardrails
1. If policy requires cleanup of related JobSource records first, delete is blocked
2. If policy allows dependent cleanup path, delete can proceed
### DL-3 Blocked delete behavior
1. When blocked
2. Then UI explains dependency constraints
3. Then UI provides guidance for dependency cleanup
### DL-4 Successful delete
1. Given no blocking dependencies
2. When the user confirms deletion
3. Then source is removed
4. Then success feedback is shown
5. Then the user returns to source list context
### DL-5 Delete failure
1. Given backend failure during delete
2. Then clear error feedback is shown
3. Then the user remains in source context with retry path
## Cross-Criteria Quality Gates
### QG-1 Separation of intent and implementation
1. UX intent remains in user-journey.md
2. Current versus target implementation mapping remains in schema-mapping.md
### QG-2 Traceability
1. Each accepted behavior maps to at least one future UI action or service path
2. No acceptance criterion contradicts first-release deferred items
### QG-3 First-release constraints
1. Source creation remains job-create-centric
2. revised_text is the primary editable source field in first release
3. source creation requires both Document linkage and Job linkage at create time
4. source delete management surfaces are phased in later
+212
View File
@@ -0,0 +1,212 @@
# Source Schema-to-UI Mapping
Purpose: Map the Source schema to the UI, while clearly separating intended target behavior from current implementation.
Companion document: user-journey.md
Acceptance criteria: acceptance-criteria.md
## 1. Entity Snapshot
- Table: Source
- Primary key: id (UUID)
- Related entities: Document, JobSource, Job
- Canonical schema references:
- src/transcription/db/models.py
- docs/schema_v2.md
## 2. Mapping Rules
This document uses three lenses:
1. Intended behavior: what the UX should support.
2. Current behavior: what the code supports today.
3. Gap to target: what must change to align implementation with the intended UX.
## 3. Field Inventory
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|---|---|---|---|---|---|
| id | UUID | No | uuid4() | Hidden, system-managed | Primary key |
| document_id | UUID FK | No | None | Hidden/context-managed | Selected Document context |
| page_number | int | No | 1 | Shown read-only or ordered list | Sequential ordering |
| upload_name | str | No | None | Shown read-only after upload | Original user-provided name |
| filename | str | No | None | Shown read-only | Stored filename |
| file_path | str | No | None | Usually hidden; preview uses path internally | Filesystem path |
| raw_transcription | str | Yes | None | Shown indirectly or hidden | Immutable machine output context |
| revised_text | str | Yes | None | Editable in Source detail | Human-authored correction |
| date_uploaded | datetime | No | datetime.now(UTC) | Shown read-only | System-managed timestamp |
| date_revised | datetime | Yes | None | Shown read-only | Set when revision is saved |
## 4. CREATE Mapping
### 4.1 Intended Create Flow
Entry point: Job creation or job configuration Add sources action
User action: upload one or more source files, or a whole folder
Success destination: source preview or revision flow in job detail context
| Field | Intended User Input | Required | Visible | Notes |
|---|---|---|---|---|
| document_id | Hidden/context | Yes | No | Comes from selected Document |
| JobSource.job_id | Hidden/context | Yes | No | Comes from active Job; required for first release |
| page_number | Auto or user-assisted ordering | Yes | Indirectly | Should preserve sequence |
| upload_name | File picker name | Yes | Yes | Original display name |
| filename | None | Yes | No or read-only | System-stored as UUID.extension |
| file_path | None | Yes | No | Storage path |
| raw_transcription | None | No | No | Filled by processing |
| revised_text | None | No | No | Initially empty |
| date_uploaded | None | No | No | System-generated |
| date_revised | None | No | No | Null until revision |
### 4.2 Current Implementation
Current entry point: Jobs page create flow
Current user action: upload one or more files or a folder through a single upload widget
Current backend path: job create submit -> create_job_for_document()
| Field | Current Value at Create | Source | Visible to User | Evidence |
|---|---|---|---|---|
| id | Generated UUID | System | No | Source model default in src/transcription/db/models.py |
| document_id | Selected existing Document id | Job create selection + service write | Indirectly | src/transcription/ui/pages/jobs_page.py, src/transcription/services/store.py |
| page_number | Sequential assignment based on existing max and alphabetical upload order | Service | No | src/transcription/services/store.py |
| upload_name | original filename basename | User file name transformed by service | Indirectly | src/transcription/services/store.py |
| filename | stored generated filename | Service | Indirectly | src/transcription/services/store.py |
| file_path | stored path | Service | Indirectly | src/transcription/services/store.py |
| raw_transcription | None initially | System | No at create | Source model defaults |
| revised_text | None initially | System | No at create | Source model defaults |
| date_uploaded | current UTC timestamp | System | No | Source model default |
| date_revised | None | System | No | Source model default |
### 4.3 Gap to Target
To satisfy intended Create flow, implementation now includes:
1. multi-source and folder upload support in job create/configure flows
2. deterministic page_number assignment from alphabetical original filename ordering
3. enforced create-time Source-to-Document and Source-to-Job linkage invariants
4. filename storage policy using UUID.extension
## 5. READ Mapping
### 5.1 Intended Read Behavior
On Source detail/list surfaces, users should be able to see:
1. source page preview
2. source metadata and ordering
3. revision state
4. original transcription context
### 5.2 Current Implementation
Current Source reading is centered on dedicated Sources list/detail routes with optional document/job filtering.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| upload_name | Shown in Sources list and Source detail | Yes | Displayed in source context | src/transcription/ui/pages/sources_page.py |
| filename | Shown in Sources list and Source detail | Yes | Source metadata shown in list/detail | src/transcription/ui/pages/sources_page.py |
| file_path | Hidden from direct text rendering | No | Used internally for preview rendering | src/transcription/ui/components/document_panzoom.py |
| page_number | Shown in Sources list and Source detail | Yes | Ordering visible in filtered/global list | src/transcription/ui/pages/sources_page.py |
| raw_transcription | Shown read-only in Source detail | Yes | Read from latest linked JobSource context | src/transcription/ui/pages/sources_page.py |
| revised_text | Shown and editable in Source detail | Yes | Saved through revision action | src/transcription/ui/pages/sources_page.py |
| date_uploaded | Shown in Source detail | Yes | Read-only metadata | src/transcription/ui/pages/sources_page.py |
| date_revised | Shown in Source detail | Yes | Read-only metadata after revision save | src/transcription/ui/pages/sources_page.py |
### 5.3 Gap to Target
To satisfy intended Read flow, implementation must add:
1. optional list filtering controls in-page (current filtering is URL/context based)
2. optional page-specific navigation enhancements beyond current list/detail pattern
## 6. UPDATE Mapping
### 6.1 Intended Update Behavior
Primary user update for Source is revised_text maintenance in Source detail.
Intended editable fields (first release):
- revised_text
Intended read-only fields (first release):
- document_id
- page_number
- upload_name
- filename
- file_path
- raw_transcription
- date_uploaded
- date_revised
### 6.2 Current Implementation
| Field | Updatable via UI | Updatable via Service | Notes |
|---|---|---|---|
| revised_text | Yes | Yes | Saved via TranscriptionService.upsert_revision_for_source() from Source detail |
| date_revised | No | Yes | Set automatically on revision save |
| other fields | No | Technically yes in service layer | No first-class UI editing flow |
### 6.3 Gap to Target
Implementation should add in a later revision:
1. optional future controls for page ordering and metadata corrections
2. revision history and conflict-resolution UX beyond single revised_text updates
## 7. DELETE Mapping
### 7.1 Intended Delete Behavior
Source deletion is deferred in the current UI.
Rules:
1. Deletion can proceed when policy allows cleanup of related JobSource records.
2. If related execution history must be preserved first, deletion is blocked with guidance.
### 7.2 Current Implementation
| Action | UI Exposed | Backend Capability | Notes |
|---|---|---|---|
| Delete Source | No | Yes | TranscriptionService.delete_source() exists, no dedicated UI delete flow |
### 7.3 Gap to Target
Implementation should add in a future revision:
1. source delete controls in source/document context UI
2. dependency checks for JobSource links
3. blocked-delete messaging and cleanup path guidance
4. confirmation UX for successful delete attempts
## 8. Hidden and System-Managed Fields
| Field | Category | Why Hidden or Protected |
|---|---|---|
| id | System-managed | Internal identifier |
| document_id | Context-managed | Derived from selected document context |
| file_path | Operational/internal | Used for file storage and preview plumbing |
| date_uploaded | System-managed | Audit timestamp |
| date_revised | System-managed | Revision timestamp set by system |
## 9. Traceability Anchors
Schema and models:
- docs/schema_v2.md
- src/transcription/db/models.py
Current implementation:
- src/transcription/services/store.py
- src/transcription/services/transcription.py
- src/transcription/ui/pages/sources_page.py
- src/transcription/ui/pages/jobs_page.py
- src/transcription/ui/pages/documents_page.py
- src/transcription/ui/components/document_panzoom.py
Companion UX spec:
- docs/ui/entities/source/user-journey.md
Acceptance checklist:
- docs/ui/entities/source/acceptance-criteria.md
## 10. Acceptance Checklist Summary
- Every Source schema field appears in the field inventory.
- Intended Create behavior matches the companion user journey.
- Source create invariant requires both Document linkage and Job linkage at create time.
- Current behavior reflects upload-centric create flow and dedicated Sources list/detail review flow.
- Gaps between intended and current behavior are explicit.
- Read, Update, and Delete sections distinguish target behavior from current code.
+229
View File
@@ -0,0 +1,229 @@
# Source User Journey
Purpose: Define how a user should interact with the UI to create and manage Source records, including page-level transcription context and revision behavior.
Scope: This document describes intended user interaction for the Source UI. It is the UX contract for the Source entity.
Companion schema mapping: schema-mapping.md
Companion acceptance criteria: acceptance-criteria.md
## 1. Overview
A Source represents one page or file unit associated with a Document.
Managing Source records is page-first:
1. The user starts from a transcription job flow.
2. The user adds one or more source files.
3. The system creates Source records linked to the Document and linked to the Job through JobSource.
4. The user reviews source lists from a dedicated Sources page.
5. The user opens Source detail to review preview, metadata, transcription text, and revision text.
## 2. User Goal
The user wants to:
1. add page files to a Document
2. ensure every source is attached to the transcription job context
3. keep page order reliable
4. review original machine output
5. save human revisions per page
6. navigate source pages efficiently
## 3. Page Model
### 3.1 Source List Surface
A Source list surface should support:
1. listing source pages globally or filtered by selected Document or Job
2. sorting by page_number
3. opening the owning Document or Job context
4. opening Source detail for a selected source
### 3.2 Source Detail Surface
Source detail supports:
1. pan/zoom image or PDF preview
2. read-only source metadata (page number, names, timestamps)
3. read-only original transcription text
4. editable revision text with save action
## 4. Entry Points
Primary entry points:
1. from Job workflow, Add sources while creating or configuring a job
2. from Job detail, open filtered Sources for the current Job
3. from Document detail, open filtered Sources for the current Document
4. from global navigation, open all Sources
Current implementation note:
1. source interaction occurs in job-create flow and dedicated Sources list/detail flows
## 5. Create Source Flow
### 5.1 User Intent
The user wants to attach one or more files to a Document so each page can be processed and reviewed.
### 5.2 Create from Job Context
1. The user starts from a job-creation or job-configuration flow
2. The user can upload one or more files, or upload a whole folder
3. The system creates Source rows linked to the selected Document
4. The system creates JobSource links for the active Job as part of this flow
5. Source creation fails if required Document or Job linkage cannot be established
### 5.3 Source Create Inputs
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Source files | upload_name/filename/file_path | Multi-file upload or folder upload | Yes | User may select one file, many files, or a folder |
| Processing order | page_number assignment rule | System rule | Yes | If multiple files are uploaded, processing order is alphabetical by original filename |
| Document reference | document_id | Hidden/context | Yes | Comes from selected Document |
| Job reference | JobSource.job_id | Hidden/context | Yes | Required for first-release source creation |
### 5.4 Filename Strategy
1. store original user filename in upload_name
2. store persisted filename using UUID plus original extension only, in the form UUID.extension
3. this replaces the previous UUID-upload_name.extension pattern
### 5.5 Ordering Guidance
1. multi-file or folder uploads are processed alphabetically by original filename
2. UI should show a warning or helper note so users understand that filename conventions control order
Suggested helper text:
1. Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control page order.
### 5.6 System-Managed Values at Create
| Schema Field | User Editable | Notes |
|---|---|---|
| id | No | System-generated |
| date_uploaded | No | System-generated |
| raw_transcription | No | Filled later by processing |
| revised_text | No | Initially empty |
| date_revised | No | Initially null |
### 5.7 Expected Create Result
After successful source create:
1. Source is linked to the Document
2. Source appears in page order derived from alphabetical upload filename ordering
3. Source is linked to the Job through JobSource at create time
4. The user can open the owning Document or Job context
### 5.8 Source Creation Invariant
For first release:
1. every new Source must have a Document link (Source.document_id)
2. every new Source must have a Job link through JobSource (JobSource.job_id -> JobSource.source_id)
3. source creation is treated as part of transcription workflow, not a standalone document-only upload path
## 6. Read Source Journey
### 6.1 User Intent
The user wants to view each page file and understand file identity and processing context.
### 6.2 Read Surface Expectations
The UI should show:
1. source lists for current context (all, document-filtered, or job-filtered)
2. upload_name as the original user-provided filename
3. filename as the stored system filename
4. page_number and ordering context
5. the owning Document and Job navigation context
6. direct action to open Source detail
### 6.3 Read Empty and Missing States
If source is missing:
1. Show clear not found or no source available messaging
If source metadata is partially unavailable:
1. Show fallback labels and keep navigation available where possible
## 7. Update Source Journey
### 7.1 User Intent
The user primarily tracks page-level source records while preserving raw machine output in the service layer.
### 7.2 Intended Editable Fields
Editable in first release:
1. revised_text in Source detail
Read-only in first release:
1. upload_name
2. filename
3. file_path
4. raw_transcription
5. page_number
6. date_uploaded
7. date_revised set by system on revision save
### 7.3 Revision Save Behavior
On save:
1. validate revision text is non-empty after trimming
2. persist revised_text
3. set date_revised
4. show success feedback
5. keep user in current source context
### 7.4 Revision Failure Behavior
If save fails:
1. Show clear error feedback
2. keep user input where possible
3. Allow retry
## 8. Delete Source Journey
### 8.1 User Intent
The user may need to remove incorrect or duplicate source files from a Document.
### 8.2 Guardrails
Delete is allowed when:
1. policy allows removal of related processing history
Delete is blocked when:
1. policy requires preserving dependent job-source execution records until explicit cleanup
### 8.3 Delete UX
When blocked:
1. explain dependency constraints in a future delete flow
2. show cleanup guidance in a future delete flow
When allowed:
1. confirm permanent removal in a future delete flow
2. remove source in a future delete flow
3. return to source list with success state in a future delete flow
## 9. Relationship to Other Workflows
Source workflow integrates with:
1. Document workflow for ownership and page organization
2. Job workflow for processing status and outputs
3. revision workflow for human correction lifecycle
## 10. Relationship to Schema Mapping
The companion schema-mapping document should specify:
1. field visibility per CRUD action
2. current implementation status
3. intended behavior
4. gap-to-target items
## 11. Deferred Items
Deferred to future revisions:
1. bulk page reordering UX
2. multi-file upload progress and resumable upload UX
3. revision history versions beyond a single revised_text field
4. richer per-page status dashboards
5. source delete UI with dependency-aware confirmation
+78
View File
@@ -0,0 +1,78 @@
# UI Entity Traceability Matrix
Purpose: Map acceptance criteria to concrete implementation anchors and current delivery status.
Updated: 2026-08-02
Status legend:
- Implemented: behavior exists in current UI and service flow
- Partial: parts exist, but user-facing behavior or guardrails are incomplete
- Planned: documented intent with no dedicated UI implementation yet
## Document
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---|
| Read detail and metadata | RD-1, RD-2, RD-7 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py | Dedicated Document detail route renders metadata, read-only system timestamps, and invalid/missing-id states. |
| Related sections and navigation | RD-3, RD-4, RD-5, RD-6 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py | Document detail now shows linked people plus document-scoped Sources and Jobs navigation for the current document. |
| Update entry, validation, and author linkage | UP-1, UP-2, UP-3, UP-4, UP-5, UP-6 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py; tests/services/test_document_service.py | Dedicated edit page includes required-field validation messaging, date parsing rules, and author relationship selection with save path routed back to document detail. |
| Delete controls and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py; tests/services/test_document_service.py | Dedicated delete page provides permanent-action confirmation, dependency-category blocking, and guarded backend delete behavior. |
## Person
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---|
| Create flow and validation | CR-1, CR-2, CR-3, CR-4, CR-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py | Dedicated Person create page with required full_name validation, optional field handling, and success routing to detail. |
| Read detail and linked documents | RD-1, RD-2, RD-3, RD-4 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Person detail route renders metadata, full-name summary, portrait preview when available, linked-document section, and invalid/missing-id states. |
| Update behavior | UP-1, UP-2, UP-3, UP-4, UP-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Dedicated Person edit page supports allowed fields, required full_name validation, and save path back to detail. |
| Delete behavior and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Dedicated delete page provides permanent-action confirmation, linked-document blocking message, and guarded backend delete behavior. |
## Source
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---|
| Create entry and required links | CR-1, CR-2, CR-4, CR-5 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; src/transcription/ui/pages/upload_page.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Source upload/create is job-create-context only (legacy upload route redirects), with required Document and JobSource linkage enforced. |
| Ordering and filename policy | CR-3 | Implemented | src/transcription/services/store.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Multi-file/folder uploads are ordered alphabetically by original filename, helper text is visible, and stored filenames use generated unique-id plus extension. |
| Read and navigation visibility | RD-1, RD-2, RD-3 | Implemented | src/transcription/ui/pages/sources_page.py; src/transcription/ui/pages/documents_page.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_sources_page.py; tests/ui/test_documents_page.py; tests/ui/test_jobs_page.py | Dedicated Sources list/detail routes support global, document-filtered, and job-filtered navigation plus source metadata and preview rendering. |
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/ui/pages/sources_page.py; src/transcription/services/transcription.py; tests/ui/test_sources_page.py; tests/services/test_transcription_service.py | Source detail exposes revision edit/save UX with non-empty validation, success feedback, and refreshed state after save. |
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Planned | src/transcription/services/transcription.py; tests/services/test_transcription_service.py | Job-detail source delete UI was removed from the current simplified flow; backend guardrails remain for future reinstatement. |
## Job
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---|
| Create entry and required links | CR-1, CR-2, CR-5, CR-6 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py; tests/services/test_store.py | Jobs list now has explicit Create entry and `/jobs/new` create flow with Document selection, combined file/folder upload widget, and submit routing to job detail. |
| Source ordering and upload behavior | CR-3 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Multi-file and folder upload are supported through one widget, uploads are sorted alphabetically by original filename, and helper guidance is shown in create UI. |
| Provider/model/prompt visibility | CR-4, RD-4 | Implemented | src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py | Provider/model/prompt fields are visible in create and detail flows when known (with pending fallback labels). |
| Jobs list and detail read states | RD-1, RD-2, RD-3, RD-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/table/jobs.py; tests/ui/test_jobs_page.py | Jobs list, detail route, document-scoped navigation, and invalid/missing id states are present. |
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/pages/sources_page.py; src/transcription/services/transcription.py; tests/ui/test_jobs_page.py; tests/ui/test_sources_page.py; tests/services/test_transcription_service.py | Job detail routes users to job-scoped Sources where Source detail provides revision edit/save workflow. |
| Lifecycle visibility and retry indicators | UP-5 | Implemented | src/transcription/services/jobs.py; src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py | Job detail now surfaces lifecycle status plus retry/update metadata while lifecycle fields remain system-managed (no direct user edit controls). |
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/services/jobs.py; tests/ui/test_jobs_page.py; tests/services/test_job_service.py | Job delete page enforces processing-state block, confirms allowed deletes, and routes back to jobs list on success. |
## Quality Gate Coverage
| Quality Gate | Acceptance IDs | Status | Notes |
|---|---|---|---|
| Separation of intent vs implementation | QG-1 across entities | Implemented | user-journey.md, schema-mapping.md, and acceptance-criteria.md are maintained per entity. |
| Traceability from criteria to implementation | QG-2 across entities | Implemented | This matrix provides criterion-to-code anchors and current status tags. |
| First-release constraints | QG-3 across entities | Implemented | Constraints are documented and aligned with current flows: jobs-first source upload, visible provider/model/prompt context, and system-managed lifecycle fields. |
## Supporting Entity Coverage
| Supporting Entity | Documentation | Status | Notes |
|---|---|---|---|
| document-person | docs/ui/entities/document-person/schema-mapping.md | Completed | Supporting-entity schema mapping created; no standalone UI contract file by design. |
| job-source | docs/ui/entities/job-source/schema-mapping.md | Completed | Supporting-entity schema mapping created; no standalone UI contract file by design. |
## Suggested Implementation Order
1. Aggregate final acceptance review across Document, Person, Source, and Job criteria.
## Aggregate Final Review Snapshot (2026-08-02)
| Entity | Acceptance IDs still not fully met | Evidence | Notes |
|---|---|---|---|
| Document | None | src/transcription/ui/pages/documents_page.py; tests/ui/test_documents_page.py | Document criteria are covered by dedicated detail/edit/delete pages and document-scoped related views. |
| Person | None | src/transcription/ui/pages/people_page.py; tests/ui/test_people_page.py | Person criteria are covered by dedicated create/detail/edit/delete pages with relationship-aware delete guardrails. |
| Source | None | src/transcription/services/store.py; src/transcription/ui/pages/sources_page.py; src/transcription/ui/pages/jobs_page.py; tests/services/test_store.py; tests/ui/test_sources_page.py; tests/services/test_transcription_service.py | Source criteria are covered by job-context create behavior, ordering/filename policy, dedicated list/detail read flow, revision flow, and delete guardrails. |
| Job | None | src/transcription/ui/pages/jobs_page.py; src/transcription/services/jobs.py; tests/ui/test_jobs_page.py; tests/services/test_job_service.py | Job criteria are covered by create/read/revision/lifecycle visibility and delete guardrails in dedicated routes. |
+13 -6
View File
@@ -1,17 +1,24 @@
import uvicorn import uvicorn
from fastapi import FastAPI
from .config import LOGGING_CONFIG from .app import create_app
from .config import get_settings from .config import parse_cli_settings
def create_cli_app() -> FastAPI:
"""Create an app from CLI settings for Uvicorn's reload process."""
return create_app(settings=parse_cli_settings())
def main() -> None: def main() -> None:
settings = get_settings() settings = parse_cli_settings()
application = "transcription.__main__:create_cli_app" if settings.reload else create_app(settings=settings)
uvicorn.run( uvicorn.run(
"transcription.app:create_app", application,
factory=True, factory=settings.reload,
host=settings.host, host=settings.host,
port=settings.port, port=settings.port,
log_level=LOGGING_CONFIG.get("root", {}).get("level", "info").lower(), log_level=settings.log_level,
reload=settings.reload, reload=settings.reload,
) )
+6 -12
View File
@@ -20,10 +20,8 @@ from .config import Settings
from .config import configure_logging from .config import configure_logging
from .config import get_settings from .config import get_settings
from .db import create_all from .db import create_all
from .db import dispose_database_runtime
from .db import initialize_database_runtime from .db import initialize_database_runtime
from .db.engine import get_database_url
from .db.engine import resolve_engine
from .db.session import dispose_session_factory
from .services import ServiceBundle from .services import ServiceBundle
from .services.jobs import JobService from .services.jobs import JobService
from .ui import register_pages from .ui import register_pages
@@ -34,15 +32,14 @@ logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def _lifespan(app: FastAPI): async def _lifespan(app: FastAPI):
configure_logging()
settings = getattr(app.state, "settings", None) or get_settings() settings = getattr(app.state, "settings", None) or get_settings()
configure_logging(settings)
app.state.settings = settings app.state.settings = settings
app.state.services = ServiceBundle() app.state.services = ServiceBundle()
app.state.runtime = initialize_database_runtime(settings=settings) app.state.runtime = initialize_database_runtime(settings=settings)
if settings.should_bootstrap_schema: if settings.should_bootstrap_schema:
await create_all(engine=resolve_engine(settings=settings)) await create_all(engine=app.state.runtime.engine)
settings.upload_dir.mkdir(parents=True, exist_ok=True) settings.upload_dir.mkdir(parents=True, exist_ok=True)
settings.prompt_dir.mkdir(parents=True, exist_ok=True) settings.prompt_dir.mkdir(parents=True, exist_ok=True)
@@ -50,10 +47,7 @@ async def _lifespan(app: FastAPI):
await _recover_stale_processing_jobs(app) await _recover_stale_processing_jobs(app)
async with AsyncExitStack() as stack: async with AsyncExitStack() as stack:
stack.push_async_callback( stack.push_async_callback(dispose_database_runtime)
dispose_session_factory,
database_url=get_database_url(settings),
)
stop_event, worker_notifier = await stack.enter_async_context( stop_event, worker_notifier = await stack.enter_async_context(
worker_consumer_lifespan( worker_consumer_lifespan(
session_factory=app.state.runtime.session_factory, session_factory=app.state.runtime.session_factory,
@@ -92,11 +86,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)
async def root_redirect() -> RedirectResponse: async def root_redirect() -> RedirectResponse:
return RedirectResponse(url="/ui", status_code=status.HTTP_307_TEMPORARY_REDIRECT) return RedirectResponse(url="/ui/homepage", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
@app.get("/ui", include_in_schema=False) @app.get("/ui", include_in_schema=False)
async def ui_redirect() -> RedirectResponse: async def ui_redirect() -> RedirectResponse:
return RedirectResponse(url="/ui/upload", status_code=status.HTTP_307_TEMPORARY_REDIRECT) return RedirectResponse(url="/ui/homepage", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
@app.get("/healthz") @app.get("/healthz")
def health() -> dict[str, str]: def health() -> dict[str, str]:
+12 -5
View File
@@ -6,6 +6,7 @@ are resolved by the provider adapters, not here.
""" """
import logging.config import logging.config
from collections.abc import Sequence
from enum import StrEnum from enum import StrEnum
from functools import cache from functools import cache
from pathlib import Path from pathlib import Path
@@ -51,7 +52,7 @@ class Settings(BaseSettings):
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",
extra="ignore", extra="ignore",
cli_parse_args=True, env_nested_delimiter="__",
cli_implicit_flags=True, cli_implicit_flags=True,
cli_kebab_case=True, cli_kebab_case=True,
) )
@@ -74,7 +75,6 @@ class Settings(BaseSettings):
# --- persistence --- # --- persistence ---
database: DatabaseSettings = Field(default_factory=SqliteSettings) database: DatabaseSettings = Field(default_factory=SqliteSettings)
database_url: str = "sqlite:///./transcription.db"
bootstrap_schema_on_startup: bool = False bootstrap_schema_on_startup: bool = False
sqlite_check_same_thread: bool = False sqlite_check_same_thread: bool = False
@@ -93,14 +93,21 @@ class Settings(BaseSettings):
@property @property
def should_bootstrap_schema(self) -> bool: def should_bootstrap_schema(self) -> bool:
"""Return whether startup should auto-create schema for this environment.""" """Return whether startup should auto-create schema for this environment."""
if self.bootstrap_schema_on_startup is not None: if "bootstrap_schema_on_startup" in self.model_fields_set:
return self.bootstrap_schema_on_startup return self.bootstrap_schema_on_startup
return self.environment in {"development", "test"} return self.environment in {"development", "test"}
@cache @cache
def get_settings(**kwargs) -> Settings: def get_settings(**kwargs: Any) -> Settings:
return Settings(**kwargs) """Load cached settings without reading process CLI arguments."""
return Settings(_cli_parse_args=False, **kwargs) # pyright: ignore[reportCallIssue]
def parse_cli_settings(args: Sequence[str] | None = None) -> Settings:
"""Load settings with CLI arguments at the executable boundary."""
cli_args = True if args is None else list(args)
return Settings(_cli_parse_args=cli_args) # pyright: ignore[reportCallIssue]
LOGGING_CONFIG: dict[str, Any] = { LOGGING_CONFIG: dict[str, Any] = {
+142 -49
View File
@@ -1,28 +1,53 @@
"""SQLModel domain models for the transcription system. """SQLModel domain models for the V2 transcription system."""
Core V1 lifecycle:
Document -> one-to-many -> Source
Document -> one-to-many -> Job
Source -> one-to-one? -> Revision (optional)
"""
from datetime import UTC from datetime import UTC
from datetime import date
from datetime import datetime from datetime import datetime
from enum import StrEnum from enum import StrEnum
from typing import Any
from typing import Optional from typing import Optional
from uuid import UUID from uuid import UUID
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import Column
from sqlalchemy import JSON
from sqlalchemy import UniqueConstraint from sqlalchemy import UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm.exc import DetachedInstanceError
from sqlalchemy.types import TypeDecorator
from sqlmodel import Field from sqlmodel import Field
from sqlmodel import Relationship from sqlmodel import Relationship
from sqlmodel import SQLModel from sqlmodel import SQLModel
class JSONBCompat(TypeDecorator):
"""JSONB for PostgreSQL and JSON for SQLite/testing backends."""
impl = JSON
def load_dialect_impl(self, dialect):
if dialect.name == "postgresql":
return dialect.type_descriptor(JSONB())
return dialect.type_descriptor(JSON())
class JobStatus(StrEnum): class JobStatus(StrEnum):
QUEUED = "queued" QUEUED = "queued"
PROCESSING = "processing" PROCESSING = "processing"
TRANSCRIBED = "transcribed" TRANSCRIBED = "transcribed"
COMPLETED = "completed"
PARTIAL_SUCCESS = "partial_success"
FAILED = "failed"
class DocumentPersonRole(StrEnum):
AUTHOR = "author"
RECIPIENT = "recipient"
class JobSourceStatus(StrEnum):
PENDING = "pending"
TRANSCRIBED = "transcribed"
FAILED = "failed" FAILED = "failed"
@@ -31,34 +56,63 @@ class Document(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
name: str name: str
document_type: str | None = None
document_date: date | None = None
document_date_raw: str | None = None
location_created: str | None = None
notes: str | None = None
archive_identifier: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# Relationships jobs: list["Job"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
jobs: list["Job"] = Relationship(back_populates="document") sources: list["Source"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
sources: list["Source"] = Relationship(back_populates="document") document_people: list["DocumentPerson"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
class Source(SQLModel, table=True): class Person(SQLModel, table=True):
"""A document source (image or PDF).""" """A historical person linked to one or more documents."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
full_name: str
display_name: str | None = None
maiden_name: str | None = None
birth_date: date | None = None
birth_date_raw: str | None = None
birth_place: str | None = None
death_date: date | None = None
death_date_raw: str | None = None
death_place: str | None = None
biography: str | None = None
portrait_path: str | None = None
metadata_: dict[str, Any] | None = Field(
default=None,
sa_column=Column("metadata", JSONBCompat(), nullable=True),
)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
document_people: list["DocumentPerson"] = Relationship(back_populates="person", sa_relationship_kwargs={"lazy": "selectin"})
class DocumentPerson(SQLModel, table=True):
"""Associates documents with people in a given role."""
__tablename__ = "document_person"
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
document_id: UUID = Field(foreign_key="document.id") document_id: UUID = Field(foreign_key="document.id")
job_id: UUID = Field(foreign_key="job.id") person_id: UUID = Field(foreign_key="person.id")
upload_name: str role: DocumentPersonRole = Field(default=DocumentPersonRole.AUTHOR)
"""The filename of the source that was uploaded for transcription.""" created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
filename: str
"""The system generated unique source name."""
file_path: str
"""The location where the sources are stored on the local filesystem."""
date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC))
# Relationships __table_args__ = (
document: Optional["Document"] = Relationship(back_populates="sources") UniqueConstraint("document_id", "person_id", "role", name="uq_document_person_role"),
job: Optional["Job"] = Relationship(back_populates="sources")
revision: Optional["Revision"] = Relationship(
back_populates="source",
sa_relationship_kwargs={"uselist": False},
) )
document: Optional["Document"] = Relationship(back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"})
person: Optional["Person"] = Relationship(back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"})
class Job(SQLModel, table=True): class Job(SQLModel, table=True):
"""A transcription job tied to a single document.""" """A transcription job tied to a single document."""
@@ -70,41 +124,80 @@ class Job(SQLModel, table=True):
date_created: datetime = Field(default_factory=lambda: datetime.now(UTC)) date_created: datetime = Field(default_factory=lambda: datetime.now(UTC))
date_updated: datetime = Field(default_factory=lambda: datetime.now(UTC)) date_updated: datetime = Field(default_factory=lambda: datetime.now(UTC))
provider: str | None = None provider: str | None = None
"""Name of the transcription provider used to generate this transcript."""
model: str | None = None model: str | None = None
"""Model identifier used to generate this transcript."""
prompt_name: str | None = None prompt_name: str | None = None
"""Name of the prompt used to generate this transcript."""
text: str | None = None
"""The transcribed text. This may be None if the job failed or is still in progress."""
error_detail: str | None = None
"""Details of any error that occurred during transcription."""
# Relationships document: Optional["Document"] = Relationship(back_populates="jobs", sa_relationship_kwargs={"lazy": "selectin"})
document: Optional["Document"] = Relationship(back_populates="jobs") job_sources: list["JobSource"] = Relationship(back_populates="job", sa_relationship_kwargs={"lazy": "selectin"})
sources: list["Source"] = Relationship(back_populates="job")
@property @property
def filename(self) -> str: def filename(self) -> str:
"""Return the filename of the associated source, when available.""" """Return the filename of the associated source, when available."""
if not self.sources: if not self.job_sources:
return "unknown" return "unknown"
return self.sources[0].filename
for job_source in self.job_sources:
source = job_source.__dict__.get("source")
if source is None:
try:
source = job_source.source
except DetachedInstanceError:
source = None
except Exception: # noqa: BLE001
source = None
if source is not None:
return source.filename
return "unknown"
@property
def error_detail(self) -> str | None:
"""Return the first available source-level error detail for the job."""
if not self.job_sources:
return None
for job_source in self.job_sources:
if job_source.error_detail:
return job_source.error_detail
return None
class Revision(SQLModel, table=True): class Source(SQLModel, table=True):
"""A revision of a transcription text.""" """A document source image or PDF page."""
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
document_id: UUID = Field(foreign_key="document.id")
page_number: int = Field(default=1, ge=1)
upload_name: str
filename: str
file_path: str
raw_transcription: str | None = None
revised_text: str | None = None
date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC))
date_revised: datetime | None = None
document: Optional["Document"] = Relationship(back_populates="sources", sa_relationship_kwargs={"lazy": "selectin"})
job_sources: list["JobSource"] = Relationship(back_populates="source", sa_relationship_kwargs={"lazy": "selectin"})
class JobSource(SQLModel, table=True):
"""A single AI execution record for one source page."""
__tablename__ = "job_source"
id: UUID = Field(default_factory=uuid4, primary_key=True)
job_id: UUID = Field(foreign_key="job.id")
source_id: UUID = Field(foreign_key="source.id") source_id: UUID = Field(foreign_key="source.id")
"""ID for the associated source.""" status: JobSourceStatus = Field(default=JobSourceStatus.PENDING)
revision: int = Field(default=1, ge=1) raw_transcription: str | None = None
"""Revision number of this transcription revision, starting at 1.""" ai_metadata: dict[str, Any] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
text: str raw_api_response: dict[str, Any] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
"""The revised text.""" error_detail: str | None = None
date_created: datetime = Field(default_factory=lambda: datetime.now(UTC)) executed_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
job: Optional["Job"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "selectin"})
source: Optional["Source"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "selectin"})
__table_args__ = (UniqueConstraint("source_id", name="uq_revision_source_id"),)
# Relationships
source: Optional["Source"] = Relationship(back_populates="revision")
+1 -40
View File
@@ -2,9 +2,6 @@ from __future__ import annotations
import logging import logging
from sqlalchemy import inspect
from sqlalchemy import text
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncEngine from sqlalchemy.ext.asyncio import AsyncEngine
from sqlmodel import SQLModel from sqlmodel import SQLModel
from sqlmodel import select from sqlmodel import select
@@ -18,14 +15,13 @@ logger = logging.getLogger(__name__)
async def create_all(*, engine: AsyncEngine | None = None) -> None: async def create_all(*, engine: AsyncEngine | None = None) -> None:
"""Create all tables on the selected engine.""" """Create any missing tables on the selected engine."""
# Import models so SQLModel metadata is fully registered before bootstrap. # Import models so SQLModel metadata is fully registered before bootstrap.
from transcription.db import models as _models # noqa: F401 from transcription.db import models as _models # noqa: F401
active_engine = engine or resolve_engine() active_engine = engine or resolve_engine()
async with active_engine.begin() as connection: async with active_engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all) await connection.run_sync(SQLModel.metadata.create_all)
await connection.run_sync(_ensure_sqlite_compat_columns)
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url) logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
@@ -38,38 +34,3 @@ async def get_next_queued_job(*, session: AsyncSession) -> Job | None:
.limit(1) .limit(1)
) # fmt: skip ) # fmt: skip
return result.first() return result.first()
def _ensure_sqlite_compat_columns(connection: Connection) -> None:
"""Apply lightweight dev/test SQLite compatibility column patches.
This keeps local bootstrap resilient when models evolve but no full
migration tooling is in place yet.
"""
if connection.engine.url.get_backend_name() != "sqlite":
return
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
if "job" in table_names:
job_columns = {column["name"] for column in inspector.get_columns("job")}
if "retry_count" not in job_columns:
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0")
if "revision" in table_names:
revision_columns = {column["name"] for column in inspector.get_columns("revision")}
if "source_id" in revision_columns:
has_unique_source = False
for index in inspector.get_indexes("revision"):
if index.get("unique") and index.get("column_names") == ["source_id"]:
has_unique_source = True
break
if not has_unique_source:
connection.execute(
text("CREATE UNIQUE INDEX IF NOT EXISTS ux_revision_source_id ON revision(source_id)")
)
logger.warning(
"Applied SQLite compatibility schema patch table=revision unique_index=ux_revision_source_id"
)
+29 -9
View File
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSessionTransaction
from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ..config import Settings
from ..config import get_settings from ..config import get_settings
from .engine import dispose_engine from .engine import dispose_engine
from .engine import get_database_url from .engine import get_database_url
@@ -25,8 +26,14 @@ def get_session_factory(database_url: str) -> SessionFactory:
) )
def resolve_session_factory(database_url: str | None = None) -> SessionFactory: def resolve_session_factory(
return get_session_factory(database_url or get_database_url(get_settings())) database_url: str | None = None,
*,
settings: Settings | None = None,
) -> SessionFactory:
if database_url is not None:
return get_session_factory(database_url)
return get_session_factory(get_database_url(settings or get_settings()))
type SessionFactoryDep = Annotated[SessionFactory, Depends(resolve_session_factory)] type SessionFactoryDep = Annotated[SessionFactory, Depends(resolve_session_factory)]
@@ -40,15 +47,20 @@ async def dispose_session_factory(database_url: str) -> None:
@asynccontextmanager @asynccontextmanager
async def session_scope( async def session_scope(
*, *,
settings: Settings | None = None,
database_url: str | None = None, database_url: str | None = None,
session_factory: SessionFactory | None = None,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> AsyncGenerator[AsyncSession]: ) -> AsyncGenerator[AsyncSession]:
if session is not None: if session is not None:
yield session yield session
return return
session_factory = resolve_session_factory(database_url) active_session_factory = session_factory or resolve_session_factory(
async with session_factory() as owned_session: database_url,
settings=settings,
)
async with active_session_factory() as owned_session:
yield owned_session yield owned_session
@@ -58,9 +70,11 @@ type SessionScopeDep = Annotated[AsyncSession, Depends(session_scope)]
@asynccontextmanager @asynccontextmanager
async def transaction_scope( async def transaction_scope(
*, *,
settings: Settings | None = None,
database_url: str | None = None, database_url: str | None = None,
session: AsyncSessionTransaction | None = None, session_factory: SessionFactory | None = None,
) -> AsyncGenerator[AsyncSessionTransaction]: session: AsyncSession | AsyncSessionTransaction | None = None,
) -> AsyncGenerator[AsyncSession | AsyncSessionTransaction]:
match session: match session:
case AsyncSession() as async_session: case AsyncSession() as async_session:
if not async_session.in_transaction(): if not async_session.in_transaction():
@@ -71,9 +85,15 @@ async def transaction_scope(
yield async_transaction yield async_transaction
return return
session_factory = resolve_session_factory(database_url) active_session_factory = session_factory or resolve_session_factory(
async with session_factory().begin() as owned_session: database_url,
settings=settings,
)
async with active_session_factory.begin() as owned_session:
yield owned_session yield owned_session
type TransactionScopeDep = Annotated[AsyncSessionTransaction, Depends(transaction_scope)] type TransactionScopeDep = Annotated[
AsyncSession | AsyncSessionTransaction,
Depends(transaction_scope),
]
+4 -1
View File
@@ -31,7 +31,10 @@ class ServiceBase(ABC):
@asynccontextmanager @asynccontextmanager
async def _session_scope(self, session: AsyncSession | None = None): async def _session_scope(self, session: AsyncSession | None = None):
"""Provide a transactional scope around a series of operations.""" """Provide a transactional scope around a series of operations."""
async with session_scope(session=session) as active_session: async with session_scope(
session_factory=self.session_factory,
session=session,
) as active_session:
yield active_session yield active_session
async def _finalize( async def _finalize(
+230 -1
View File
@@ -1,7 +1,10 @@
import logging import logging
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from pathlib import Path from pathlib import Path
import shutil
from uuid import UUID from uuid import UUID
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -10,6 +13,8 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ..db.models import Document from ..db.models import Document
from ..db.models import DocumentPerson
from ..db.models import Person
from ..errors import AppError from ..errors import AppError
from ..errors import ErrorCategory from ..errors import ErrorCategory
from .base import ServiceBase from .base import ServiceBase
@@ -33,6 +38,14 @@ class DocumentAlreadyExistsError(DocumentError):
"""Raised when a document with the same name already exists in the database.""" """Raised when a document with the same name already exists in the database."""
class DocumentDeleteBlockedError(DocumentError):
"""Raised when a document delete is blocked by dependent records."""
class PersonDeleteBlockedError(DocumentError):
"""Raised when a person delete is blocked by linked documents."""
@dataclass(frozen=True) @dataclass(frozen=True)
class UploadJobResult: class UploadJobResult:
"""Summary of created upload records.""" """Summary of created upload records."""
@@ -100,14 +113,182 @@ class DocumentService(ServiceBase):
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document: async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
"""Update an existing document in the database.""" """Update an existing document in the database."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
document.updated_at = datetime.now(UTC)
merged = await _session.merge(document) merged = await _session.merge(document)
await self._finalize(session=_session, caller_session=session, refresh=(merged,)) await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged return merged
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None: async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
"""Delete a document from the database.""" """Delete a document from the database."""
document_id = document.id
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
await _session.delete(document) existing = await _session.get(
Document,
document.id,
options=(
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
),
)
if existing is None:
raise DocumentError(
f"Document with id {document.id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the document id and retry.",
)
has_jobs = bool(existing.jobs)
has_sources = bool(existing.sources)
if has_jobs or has_sources:
blocked_by: list[str] = []
if has_sources:
blocked_by.append("Sources")
if has_jobs:
blocked_by.append("Jobs")
raise DocumentDeleteBlockedError(
f"Document delete blocked by related records: {', '.join(blocked_by)}",
category=ErrorCategory.VALIDATION,
suggestion="Remove related Sources and Jobs first, then retry deletion.",
)
await _session.delete(existing)
await self._finalize(session=_session, caller_session=session)
self._delete_document_storage_folder(document_id=document_id)
def _delete_document_storage_folder(self, *, document_id: UUID) -> None:
"""Best-effort cleanup for document-scoped source storage."""
document_dir = self.settings.upload_dir / "documents" / str(document_id)
if not document_dir.exists():
return
try:
shutil.rmtree(document_dir)
logger.info("Deleted document storage folder: %s", document_dir)
except OSError:
logger.warning("Failed to delete document storage folder: %s", document_dir)
async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
"""Create a new person in the database."""
async with self._session_scope(session) as _session:
_session.add(person)
await self._finalize(session=_session, caller_session=session, refresh=(person,))
return person
async def read_person(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
"""Read an existing person from the database."""
async with self._session_scope(session) as _session:
person = await _session.get(Person, person_id)
if person is None:
raise DocumentError(
f"Person with id {person_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the person id and retry.",
)
return person
async def read_person_detail(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
"""Read a person with eagerly loaded document links for UI detail rendering."""
async with self._session_scope(session) as _session:
query = (
select(Person)
.options(
selectinload(Person.document_people).selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
)
.where(Person.id == person_id)
.execution_options(populate_existing=True)
)
person = (await _session.exec(query)).first()
if person is None:
raise DocumentError(
f"Person with id {person_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the person id and retry.",
)
return person
async def update_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
"""Update an existing person in the database."""
async with self._session_scope(session) as _session:
person.updated_at = datetime.now(UTC)
merged = await _session.merge(person)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
async def delete_person(self, person: Person, *, session: AsyncSession | None = None) -> None:
"""Delete a person from the database."""
async with self._session_scope(session) as _session:
existing = await _session.get(
Person,
person.id,
options=(
selectinload(Person.document_people), # pyright: ignore[reportArgumentType]
),
)
if existing is None:
raise DocumentError(
f"Person with id {person.id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the person id and retry.",
)
for link in list(existing.document_people):
await _session.delete(link)
await _session.delete(existing)
await self._finalize(session=_session, caller_session=session)
async def create_document_person(
self,
document_person: DocumentPerson,
*,
session: AsyncSession | None = None,
) -> DocumentPerson:
"""Create a document-person association in the database."""
async with self._session_scope(session) as _session:
_session.add(document_person)
await self._finalize(session=_session, caller_session=session, refresh=(document_person,))
return document_person
async def read_document_person(
self,
document_person_id: UUID,
*,
session: AsyncSession | None = None,
) -> DocumentPerson:
"""Read an existing document-person association from the database."""
async with self._session_scope(session) as _session:
document_person = await _session.get(DocumentPerson, document_person_id)
if document_person is None:
raise DocumentError(
f"DocumentPerson with id {document_person_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the document-person id and retry.",
)
return document_person
async def update_document_person(
self,
document_person: DocumentPerson,
*,
session: AsyncSession | None = None,
) -> DocumentPerson:
"""Update an existing document-person association in the database."""
async with self._session_scope(session) as _session:
merged = await _session.merge(document_person)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
async def delete_document_person(
self,
document_person: DocumentPerson,
*,
session: AsyncSession | None = None,
) -> None:
"""Delete a document-person association from the database."""
async with self._session_scope(session) as _session:
await _session.delete(document_person)
await self._finalize(session=_session, caller_session=session) await self._finalize(session=_session, caller_session=session)
# Query Operations # Query Operations
@@ -128,3 +309,51 @@ class DocumentService(ServiceBase):
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
result = await _session.exec(select(Document)) result = await _session.exec(select(Document))
return result.all() return result.all()
async def read_document_detail(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
"""Read a document with eagerly loaded relations for UI detail rendering."""
async with self._session_scope(session) as _session:
query = (
select(Document)
.options(
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
selectinload(Document.document_people).selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
)
.where(Document.id == document_id)
.execution_options(populate_existing=True)
)
document = (await _session.exec(query)).first()
if document is None:
raise DocumentError(
f"Document with id {document_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the document id and retry.",
)
return document
async def list_people(self, *, session: AsyncSession | None = None) -> Sequence[Person]:
"""List all people in the database."""
async with self._session_scope(session) as _session:
result = await _session.exec(select(Person))
return result.all()
async def list_document_people(
self,
*,
document_id: UUID | None = None,
person_id: UUID | None = None,
session: AsyncSession | None = None,
) -> Sequence[DocumentPerson]:
"""List document-person associations, optionally filtered by document or person."""
async with self._session_scope(session) as _session:
query = select(DocumentPerson).options(
selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
)
if document_id is not None:
query = query.where(DocumentPerson.document_id == document_id)
if person_id is not None:
query = query.where(DocumentPerson.person_id == person_id)
result = await _session.exec(query)
return result.all()
+138 -6
View File
@@ -7,12 +7,28 @@ from sqlalchemy.orm import selectinload
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ..errors import AppError
from ..errors import ErrorCategory
from ..db.models import Job from ..db.models import Job
from ..db.models import JobSource
from ..db.models import JobSourceStatus
from ..db.models import JobStatus from ..db.models import JobStatus
from ..db.models import Source from ..db.models import Source
from .base import ServiceBase from .base import ServiceBase
class JobDeleteBlockedError(AppError):
"""Raised when a job delete operation is blocked by lifecycle policy."""
class JobCancelBlockedError(AppError):
"""Raised when a job cancel operation is blocked by lifecycle policy."""
class JobResubmitBlockedError(AppError):
"""Raised when a job resubmit operation is blocked by lifecycle policy."""
class JobService(ServiceBase): class JobService(ServiceBase):
"""Thin service class for managing jobs in the database.""" """Thin service class for managing jobs in the database."""
@@ -38,7 +54,7 @@ class JobService(ServiceBase):
select(Job) select(Job)
.options( .options(
selectinload(Job.document), # pyright: ignore[reportArgumentType] selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.sources).selectinload(Source.revision), # pyright: ignore[reportArgumentType] selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
) )
.where(Job.id == job_id) .where(Job.id == job_id)
.execution_options(populate_existing=True) .execution_options(populate_existing=True)
@@ -74,12 +90,12 @@ class JobService(ServiceBase):
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
query = select(Job).options( query = select(Job).options(
selectinload(Job.document), # pyright: ignore[reportArgumentType] selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.sources), # pyright: ignore[reportArgumentType] selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
) )
if status is not None: if status is not None:
query = query.where(Job.status == status) query = query.where(Job.status == status)
if filename is not None: if filename is not None:
query = query.where(Job.sources.any(Source.filename == filename)) query = query.where(Job.job_sources.any(JobSource.source.has(Source.filename == filename)))
result = await _session.exec(query) result = await _session.exec(query)
return result.all() return result.all()
@@ -94,7 +110,7 @@ class JobService(ServiceBase):
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
query = select(Job).options( query = select(Job).options(
selectinload(Job.document), # pyright: ignore[reportArgumentType] selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.sources), # pyright: ignore[reportArgumentType] selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
) )
result = await _session.exec(query) result = await _session.exec(query)
return result.all() return result.all()
@@ -151,10 +167,11 @@ class JobService(ServiceBase):
select(Job) select(Job)
.options( .options(
selectinload(Job.document), # pyright: ignore[reportArgumentType] selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.sources).selectinload(Source.revision), # pyright: ignore[reportArgumentType] selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
) )
.where(Job.status == JobStatus.QUEUED) .where(Job.status == JobStatus.QUEUED)
.order_by(Job.date_created) # pyright: ignore[reportArgumentType] # Break ties by id so "next" is stable when two rows share close timestamps.
.order_by(Job.date_created, Job.id) # pyright: ignore[reportArgumentType]
) )
return (await _session.exec(query)).first() return (await _session.exec(query)).first()
@@ -182,3 +199,118 @@ class JobService(ServiceBase):
await self._finalize(session=_session, caller_session=session, refresh=stale_jobs) await self._finalize(session=_session, caller_session=session, refresh=stale_jobs)
return len(stale_jobs) return len(stale_jobs)
async def delete_job_with_guardrails(self, *, job_id: UUID, session: AsyncSession | None = None) -> None:
"""Delete a job with lifecycle guardrails and dependent cleanup policy.
Policy:
- Block when the job is actively processing.
- Otherwise remove related JobSource rows, then delete the job.
"""
async with self._session_scope(session) as _session:
query = (
select(Job)
.options(selectinload(Job.job_sources)) # pyright: ignore[reportArgumentType]
.where(Job.id == job_id)
.execution_options(populate_existing=True)
)
job = (await _session.exec(query)).first()
if job is None:
raise ValueError(f"Job with id {job_id} not found")
if job.status == JobStatus.PROCESSING:
raise JobDeleteBlockedError(
"Job delete blocked while status is processing",
category=ErrorCategory.VALIDATION,
suggestion="Wait for processing to complete, or move the job out of processing before deleting.",
)
for job_source in list(job.job_sources):
await _session.delete(job_source)
await _session.delete(job)
await self._finalize(session=_session, caller_session=session)
async def cancel_job(self, *, job_id: UUID, session: AsyncSession | None = None) -> Job:
"""Cancel a queued/processing job and stop remaining source work."""
async with self._session_scope(session) as _session:
query = (
select(Job)
.options(
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
)
.where(Job.id == job_id)
.execution_options(populate_existing=True)
)
job = (await _session.exec(query)).first()
if job is None:
raise ValueError(f"Job with id {job_id} not found")
if job.status in {JobStatus.TRANSCRIBED, JobStatus.COMPLETED}:
raise JobCancelBlockedError(
"Job cancel is not allowed for transcribed/completed jobs",
category=ErrorCategory.VALIDATION,
suggestion="Use resubmit for reprocessing needs, or leave the terminal job unchanged.",
)
now = datetime.now(UTC)
job.status = JobStatus.FAILED
job.date_updated = now
for job_source in job.job_sources:
if job_source.status == JobSourceStatus.TRANSCRIBED:
continue
job_source.status = JobSourceStatus.FAILED
job_source.raw_transcription = None
job_source.error_detail = "Cancelled by user"
job_source.executed_at = now
if job_source.source is not None:
job_source.source.raw_transcription = None
await self._finalize(session=_session, caller_session=session, refresh=(job,))
return job
async def resubmit_non_transcribed_sources(self, *, job_id: UUID, session: AsyncSession | None = None) -> int:
"""Reset non-transcribed source executions and queue the job for reprocessing."""
async with self._session_scope(session) as _session:
query = (
select(Job)
.options(
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
)
.where(Job.id == job_id)
.execution_options(populate_existing=True)
)
job = (await _session.exec(query)).first()
if job is None:
raise ValueError(f"Job with id {job_id} not found")
if job.status == JobStatus.PROCESSING:
raise JobResubmitBlockedError(
"Job resubmit is blocked while processing is active",
category=ErrorCategory.VALIDATION,
suggestion="Cancel processing first, then resubmit remaining sources.",
)
candidates = [job_source for job_source in job.job_sources if job_source.status != JobSourceStatus.TRANSCRIBED]
if not candidates:
raise JobResubmitBlockedError(
"Job has no non-transcribed sources to resubmit",
category=ErrorCategory.VALIDATION,
suggestion="Only failed or pending sources can be resubmitted.",
)
now = datetime.now(UTC)
for job_source in candidates:
job_source.status = JobSourceStatus.PENDING
job_source.raw_transcription = None
job_source.error_detail = None
job_source.executed_at = now
if job_source.source is not None:
job_source.source.raw_transcription = None
job.status = JobStatus.QUEUED
job.date_updated = now
await self._finalize(session=_session, caller_session=session, refresh=(job,))
return len(candidates)
+222 -12
View File
@@ -1,9 +1,13 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
import logging import logging
from pathlib import Path from pathlib import Path
from uuid import UUID
from uuid import uuid4 from uuid import uuid4
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings from transcription.config import Settings
@@ -13,18 +17,39 @@ from transcription.errors import ErrorCategory
from ..db.models import Document from ..db.models import Document
from ..db.models import Job from ..db.models import Job
from ..db.models import JobSource
from ..db.models import JobSourceStatus
from ..db.models import Source from ..db.models import Source
from .documents import UploadJobResult from .documents import UploadJobResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"} SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
SUPPORTED_PORTRAIT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
class UploadError(AppError): class UploadError(AppError):
"""Raised when uploaded content cannot be persisted safely.""" """Raised when uploaded content cannot be persisted safely."""
@dataclass(frozen=True)
class JobCreateResult:
"""Summary of explicit Job create records."""
document_id: UUID
job_id: UUID
source_ids: tuple[UUID, ...]
@dataclass(frozen=True)
class PendingStoredUpload:
"""Pre-staged upload artifact tied to a source id."""
source_id: UUID
original_filename: str
stored_path: Path
async def create_upload_job( async def create_upload_job(
*, *,
filename: str, filename: str,
@@ -34,14 +59,20 @@ async def create_upload_job(
) -> UploadJobResult: ) -> UploadJobResult:
"""Create upload-backed document and queued job records.""" """Create upload-backed document and queued job records."""
runtime_settings = settings or get_settings() runtime_settings = settings or get_settings()
document_id = uuid4()
source_id = uuid4()
stored_path = store_file( stored_path = store_file(
filename=filename, filename=filename,
file_bytes=file_bytes, file_bytes=file_bytes,
settings=runtime_settings, settings=runtime_settings,
relative_directory=Path("documents") / str(document_id),
filename_stem=str(source_id),
) )
try: try:
document, job = await _create_upload_records( document, job = await _create_upload_records(
session=session, session=session,
document_id=document_id,
source_id=source_id,
original_filename=filename, original_filename=filename,
stored_path=stored_path, stored_path=stored_path,
) )
@@ -63,13 +94,80 @@ async def create_upload_job(
) )
async def create_job_for_document(
*,
document_id: UUID,
uploads: Sequence[tuple[str, bytes]],
session: AsyncSession,
provider: str | None = None,
model: str | None = None,
prompt_name: str | None = None,
settings: Settings | None = None,
) -> JobCreateResult:
"""Create a queued job for an existing document with one or more uploaded sources."""
if not uploads:
raise UploadError(
"At least one upload is required to create a job",
category=ErrorCategory.VALIDATION,
suggestion="Upload one or more files and try again.",
)
runtime_settings = settings or get_settings()
sorted_uploads = sorted(uploads, key=lambda item: Path(item[0]).name.casefold())
stored_uploads: list[PendingStoredUpload] = []
for filename, file_bytes in sorted_uploads:
source_id = uuid4()
stored_uploads.append(
PendingStoredUpload(
source_id=source_id,
original_filename=filename,
stored_path=store_file(
filename=filename,
file_bytes=file_bytes,
settings=runtime_settings,
relative_directory=Path("documents") / str(document_id),
filename_stem=str(source_id),
),
)
)
try:
job, source_ids = await _create_job_for_document_records(
session=session,
document_id=document_id,
stored_uploads=stored_uploads,
provider=provider,
model=model,
prompt_name=prompt_name,
)
except Exception as exc:
for upload in stored_uploads:
_best_effort_delete(upload.stored_path)
raise UploadError(
"Failed to create job records from uploads",
category=ErrorCategory.INFRA_TRANSIENT,
suggestion="Retry creation. If this keeps happening, verify database availability.",
retriable=True,
) from exc
logger.info("Created explicit job document_id=%s job_id=%s sources=%s", document_id, job.id, len(source_ids))
return JobCreateResult(
document_id=document_id,
job_id=job.id,
source_ids=tuple(source_ids),
)
async def _create_upload_records( async def _create_upload_records(
*, *,
session: AsyncSession, session: AsyncSession,
document_id: UUID,
source_id: UUID,
original_filename: str, original_filename: str,
stored_path: Path, stored_path: Path,
) -> tuple[Document, Job]: ) -> tuple[Document, Job]:
document = Document( document = Document(
id=document_id,
name=Path(original_filename).name, name=Path(original_filename).name,
) )
session.add(document) session.add(document)
@@ -80,13 +178,23 @@ async def _create_upload_records(
await session.flush() await session.flush()
source = Source( source = Source(
id=source_id,
document_id=document.id, document_id=document.id,
job_id=job.id, page_number=1,
upload_name=Path(original_filename).name, upload_name=Path(original_filename).name,
filename=stored_path.name, filename=stored_path.name,
file_path=str(stored_path), file_path=str(stored_path),
) )
session.add(source) session.add(source)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit() await session.commit()
await session.refresh(document) await session.refresh(document)
@@ -94,6 +202,64 @@ async def _create_upload_records(
return document, job return document, job
async def _create_job_for_document_records(
*,
session: AsyncSession,
document_id: UUID,
stored_uploads: Sequence[PendingStoredUpload],
provider: str | None,
model: str | None,
prompt_name: str | None,
) -> tuple[Job, list[UUID]]:
document = await session.get(Document, document_id)
if document is None:
raise UploadError(
f"Document with id {document_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Select an existing document and retry.",
)
existing_sources = (
await session.exec(select(Source).where(Source.document_id == document_id))
).all()
next_page_number = (max((source.page_number for source in existing_sources), default=0) + 1)
job = Job(
document_id=document_id,
provider=(provider or None),
model=(model or None),
prompt_name=(prompt_name or None),
)
session.add(job)
await session.flush()
source_ids: list[UUID] = []
for page_offset, upload in enumerate(stored_uploads):
source = Source(
id=upload.source_id,
document_id=document_id,
page_number=next_page_number + page_offset,
upload_name=Path(upload.original_filename).name,
filename=upload.stored_path.name,
file_path=str(upload.stored_path),
)
session.add(source)
await session.flush()
source_ids.append(source.id)
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
await session.refresh(job)
return job, source_ids
def _best_effort_delete(path: Path) -> None: def _best_effort_delete(path: Path) -> None:
try: try:
if path.exists(): if path.exists():
@@ -102,16 +268,58 @@ def _best_effort_delete(path: Path) -> None:
logger.warning("Failed to clean up upload file after DB error: %s", path) logger.warning("Failed to clean up upload file after DB error: %s", path)
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path: def store_file(
*,
filename: str,
file_bytes: bytes,
settings: Settings | None = None,
relative_directory: Path | None = None,
filename_stem: str | None = None,
) -> Path:
"""Persist an uploaded file to the configured upload directory.""" """Persist an uploaded file to the configured upload directory."""
runtime_settings = settings or get_settings() runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes) _validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_UPLOAD_EXTENSIONS)
return _store_file_bytes(
filename=filename,
file_bytes=file_bytes,
settings=runtime_settings,
relative_directory=relative_directory,
filename_stem=filename_stem,
)
upload_dir = runtime_settings.upload_dir
upload_dir.mkdir(parents=True, exist_ok=True)
stored_name = _build_stored_filename(filename) def store_person_portrait(
stored_path = upload_dir / stored_name *,
person_id: UUID,
filename: str,
file_bytes: bytes,
settings: Settings | None = None,
) -> Path:
"""Persist a portrait upload under persons/<person_id>."""
runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_PORTRAIT_EXTENSIONS)
return _store_file_bytes(
filename=filename,
file_bytes=file_bytes,
settings=runtime_settings,
relative_directory=Path("persons") / str(person_id),
)
def _store_file_bytes(
*,
filename: str,
file_bytes: bytes,
settings: Settings,
relative_directory: Path | None = None,
filename_stem: str | None = None,
) -> Path:
upload_dir = settings.upload_dir
target_dir = upload_dir if relative_directory is None else upload_dir / relative_directory
target_dir.mkdir(parents=True, exist_ok=True)
stored_name = _build_stored_filename(filename=filename, filename_stem=filename_stem)
stored_path = target_dir / stored_name
try: try:
stored_path.write_bytes(file_bytes) stored_path.write_bytes(file_bytes)
@@ -126,7 +334,7 @@ def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None =
return stored_path return stored_path
def _validate_upload(*, filename: str, file_bytes: bytes) -> None: def _validate_upload(*, filename: str, file_bytes: bytes, supported_extensions: set[str]) -> None:
if not file_bytes: if not file_bytes:
raise UploadError( raise UploadError(
"Upload payload is empty", "Upload payload is empty",
@@ -143,14 +351,16 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
) )
suffix = Path(safe_name).suffix.lower() suffix = Path(safe_name).suffix.lower()
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS: if suffix not in supported_extensions:
raise UploadError( raise UploadError(
f"Unsupported upload extension: {suffix}", f"Unsupported upload extension: {suffix}",
category=ErrorCategory.USER_INPUT, category=ErrorCategory.USER_INPUT,
suggestion="Upload JPG, JPEG, PNG, TIFF, or PDF files only.", suggestion="Upload a supported image or document file and retry.",
) )
def _build_stored_filename(filename: str) -> str: def _build_stored_filename(*, filename: str, filename_stem: str | None = None) -> str:
safe_name = Path(filename).name safe_name = Path(filename).name
return f"{uuid4()}_{safe_name}" suffix = Path(safe_name).suffix.lower()
stem = filename_stem or str(uuid4())
return f"{stem}{suffix}"
+337 -60
View File
@@ -19,7 +19,8 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings from transcription.config import Settings
from transcription.config import get_settings from transcription.config import get_settings
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import Revision from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import Source from transcription.db.models import Source
from transcription.errors import AppError from transcription.errors import AppError
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
@@ -50,8 +51,12 @@ class TranscriptionNotFoundError(TranscriptionError):
"""Raised when a transcription-related resource is not found.""" """Raised when a transcription-related resource is not found."""
class SourceDeleteBlockedError(TranscriptionError):
"""Raised when source deletion is blocked by dependency policy."""
class TranscriptionService(ServiceBase): class TranscriptionService(ServiceBase):
"""Service class for job transcription output and optional source revisions.""" """Service class for job transcription output and page-level source revisions."""
provider: TranscriptionProvider provider: TranscriptionProvider
@@ -59,51 +64,247 @@ class TranscriptionService(ServiceBase):
super().__init__(session_factory=session_factory) super().__init__(session_factory=session_factory)
self.provider = get_transcription_provider(settings=self.settings) self.provider = get_transcription_provider(settings=self.settings)
async def create_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> Revision: async def create_source(self, source: Source, *, session: AsyncSession | None = None) -> Source:
"""Create a new revision in the database.""" """Create a new source page record in the database."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
_session.add(revision) _session.add(source)
await self._finalize(session=_session, caller_session=session, refresh=(revision,)) await self._finalize(session=_session, caller_session=session, refresh=(source,))
return revision return source
async def read_revision(self, revision_id: UUID, *, session: AsyncSession | None = None) -> Revision: async def read_source(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source:
"""Read an existing revision from the database.""" """Read an existing source page record."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
revision = await _session.get( source = await _session.get(Source, source_id)
Revision, if source is None:
revision_id,
options=(selectinload(Revision.source),), # pyright: ignore[reportArgumentType]
)
if revision is None:
raise TranscriptionNotFoundError( raise TranscriptionNotFoundError(
f"Revision with id {revision_id} not found", f"Source with id {source_id} not found",
category=ErrorCategory.NOT_FOUND, category=ErrorCategory.NOT_FOUND,
suggestion="Verify the revision id and retry.", suggestion="Verify the source id and retry.",
) )
return revision return source
async def update_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> Revision: async def read_source_detail(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source:
"""Update an existing revision in the database.""" """Read a source page record with job-source context for UI detail rendering."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
merged = await _session.merge(revision) query = (
select(Source)
.options(
selectinload(Source.job_sources).selectinload(JobSource.job), # pyright: ignore[reportArgumentType]
)
.where(Source.id == source_id)
.execution_options(populate_existing=True)
)
source = (await _session.exec(query)).first()
if source is None:
raise TranscriptionNotFoundError(
f"Source with id {source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the source id and retry.",
)
return source
async def update_source(self, source: Source, *, session: AsyncSession | None = None) -> Source:
"""Update an existing source page record."""
async with self._session_scope(session) as _session:
merged = await _session.merge(source)
await self._finalize(session=_session, caller_session=session, refresh=(merged,)) await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged return merged
async def delete_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> None: async def delete_source(self, source: Source, *, session: AsyncSession | None = None) -> None:
"""Delete a revision from the database.""" """Delete a source page record."""
source_file_path = source.file_path
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
await _session.delete(revision) await _session.delete(source)
await self._finalize(session=_session, caller_session=session) await self._finalize(session=_session, caller_session=session)
# Temporary compatibility methods for callers still using transcript naming. self._delete_source_file(source_file_path=source_file_path)
async def read_transcript(self, transcript_id: UUID, *, session: AsyncSession | None = None) -> Revision: async def delete_unlinked_source(self, *, source_id: UUID, session: AsyncSession | None = None) -> None:
"""Backward-compatible alias for read_revision.""" """Delete a source only when no JobSource links exist."""
return await self.read_revision(transcript_id, session=session) async with self._session_scope(session) as _session:
source = await _session.get(
Source,
source_id,
options=(
selectinload(Source.job_sources), # pyright: ignore[reportArgumentType]
),
)
if source is None:
raise TranscriptionNotFoundError(
f"Source with id {source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the source id and retry.",
)
async def delete_transcript(self, transcript: Revision, *, session: AsyncSession | None = None) -> None: if source.job_sources:
"""Backward-compatible alias for delete_revision.""" raise SourceDeleteBlockedError(
await self.delete_revision(transcript, session=session) "Source delete blocked because it is linked to one or more jobs",
category=ErrorCategory.VALIDATION,
suggestion="Remove JobSource links first, then retry deletion.",
)
source_file_path = source.file_path
await _session.delete(source)
await self._finalize(session=_session, caller_session=session)
self._delete_source_file(source_file_path=source_file_path)
async def list_sources(
self,
*,
document_id: UUID | None = None,
session: AsyncSession | None = None,
) -> Sequence[Source]:
"""List source pages, optionally filtered by document."""
async with self._session_scope(session) as _session:
query = select(Source)
if document_id is not None:
query = query.where(Source.document_id == document_id)
result = await _session.exec(query)
return result.all()
async def query_sources(
self,
*,
document_id: UUID | None = None,
page_number: int | None = None,
session: AsyncSession | None = None,
) -> Sequence[Source]:
"""Query source pages using the provided filters."""
async with self._session_scope(session) as _session:
query = select(Source)
if document_id is not None:
query = query.where(Source.document_id == document_id)
if page_number is not None:
query = query.where(Source.page_number == page_number)
result = await _session.exec(query)
return result.all()
async def create_job_source(
self,
job_source: JobSource,
*,
session: AsyncSession | None = None,
) -> JobSource:
"""Create a new job_source execution record in the database."""
async with self._session_scope(session) as _session:
_session.add(job_source)
await self._finalize(session=_session, caller_session=session, refresh=(job_source,))
return job_source
async def read_job_source(self, job_source_id: UUID, *, session: AsyncSession | None = None) -> JobSource:
"""Read an existing job_source record."""
async with self._session_scope(session) as _session:
job_source = await _session.get(
JobSource,
job_source_id,
options=(selectinload(JobSource.source),), # pyright: ignore[reportArgumentType]
)
if job_source is None:
raise TranscriptionNotFoundError(
f"JobSource with id {job_source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the job source id and retry.",
)
return job_source
async def update_job_source(self, job_source: JobSource, *, session: AsyncSession | None = None) -> JobSource:
"""Update an existing job_source record."""
async with self._session_scope(session) as _session:
merged = await _session.merge(job_source)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
async def delete_job_source(self, job_source: JobSource, *, session: AsyncSession | None = None) -> None:
"""Delete a job_source record."""
async with self._session_scope(session) as _session:
await _session.delete(job_source)
await self._finalize(session=_session, caller_session=session)
async def delete_source_from_job_context(
self,
*,
job_id: UUID,
source_id: UUID,
session: AsyncSession | None = None,
) -> None:
"""Delete a source from an active job context with dependency guardrails.
Policy:
- Allowed when exactly one JobSource link exists and it points at ``job_id``.
- Blocked when additional JobSource links exist (history/shared dependencies).
"""
async with self._session_scope(session) as _session:
source = await _session.get(
Source,
source_id,
options=(
selectinload(Source.job_sources), # pyright: ignore[reportArgumentType]
),
)
if source is None:
raise TranscriptionNotFoundError(
f"Source with id {source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the source id and retry.",
)
linked_job_sources = list(source.job_sources)
matching_links = [job_source for job_source in linked_job_sources if job_source.job_id == job_id]
if not matching_links:
raise TranscriptionNotFoundError(
f"Source {source_id} is not linked to job {job_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Open the source from its linked job context and retry.",
)
if len(linked_job_sources) > len(matching_links):
raise SourceDeleteBlockedError(
"Source delete blocked by related job history",
category=ErrorCategory.VALIDATION,
suggestion="Remove additional JobSource links first, then retry deletion.",
)
for job_source in matching_links:
await _session.delete(job_source)
source_file_path = source.file_path
await _session.delete(source)
await self._finalize(session=_session, caller_session=session)
self._delete_source_file(source_file_path=source_file_path)
def _delete_source_file(self, *, source_file_path: str) -> None:
"""Best-effort cleanup for source media files."""
candidate_path = Path(source_file_path)
resolved_path = candidate_path if candidate_path.is_absolute() else self.settings.upload_dir / candidate_path
if not resolved_path.exists():
return
try:
resolved_path.unlink()
logger.info("Deleted source file: %s", resolved_path)
except OSError:
logger.warning("Failed to delete source file: %s", resolved_path)
async def list_job_sources(
self,
*,
job_id: UUID | None = None,
session: AsyncSession | None = None,
) -> Sequence[JobSource]:
"""List job-source records, optionally filtered by job."""
async with self._session_scope(session) as _session:
query = select(JobSource).options(
selectinload(JobSource.job), # pyright: ignore[reportArgumentType]
selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
)
if job_id is not None:
query = query.where(JobSource.job_id == job_id)
result = await _session.exec(query)
return result.all()
async def transcribe_document( async def transcribe_document(
self, self,
@@ -141,7 +342,11 @@ class TranscriptionService(ServiceBase):
prompt_name: str = DEFAULT_PROMPT_FILE, prompt_name: str = DEFAULT_PROMPT_FILE,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> Job: ) -> Job:
"""Persist original transcription output fields on a job.""" """Persist transcription output for the first ordered source in a job's document.
This compatibility helper keeps legacy single-source workflows working.
New multi-source flows should use ``update_job_source_transcription``.
"""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
job = await _session.get(Job, job_id) job = await _session.get(Job, job_id)
if job is None: if job is None:
@@ -151,24 +356,106 @@ class TranscriptionService(ServiceBase):
suggestion="Verify the job id and retry.", suggestion="Verify the job id and retry.",
) )
job.text = text
job.error_detail = error_detail
job.provider = provider or job.provider or self.settings.provider.value job.provider = provider or job.provider or self.settings.provider.value
job.model = model or job.model or _resolve_transcript_model(provider=self.provider, settings=self.settings) job.model = model or job.model or _resolve_transcript_model(provider=self.provider, settings=self.settings)
job.prompt_name = prompt_name or job.prompt_name or DEFAULT_PROMPT_FILE job.prompt_name = prompt_name or job.prompt_name or DEFAULT_PROMPT_FILE
job.date_updated = datetime.now(UTC) job.date_updated = datetime.now(UTC)
source = await _session.exec(
select(Source)
.where(Source.document_id == job.document_id)
.order_by(Source.page_number) # pyright: ignore[reportArgumentType]
)
source_row = source.first()
if source_row is not None:
await self.update_job_source_transcription(
job_id=job.id,
source_id=source_row.id,
text=text,
error_detail=error_detail,
provider=provider,
model=model,
prompt_name=prompt_name,
session=_session,
)
await self._finalize(session=_session, caller_session=session, refresh=(job,)) await self._finalize(session=_session, caller_session=session, refresh=(job,))
return job return job
async def update_job_source_transcription(
self,
*,
job_id: UUID,
source_id: UUID,
text: str | None,
error_detail: str | None = None,
provider: str | None = None,
model: str | None = None,
prompt_name: str = DEFAULT_PROMPT_FILE,
session: AsyncSession | None = None,
) -> JobSource:
"""Persist transcription fields for one source within a specific job."""
async with self._session_scope(session) as _session:
job = await _session.get(Job, job_id)
if job is None:
raise TranscriptionNotFoundError(
f"Job with id {job_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the job id and retry.",
)
source = await _session.get(Source, source_id)
if source is None:
raise TranscriptionNotFoundError(
f"Source with id {source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the source id and retry.",
)
if source.document_id != job.document_id:
raise TranscriptionError(
f"Source {source_id} does not belong to job {job_id}",
category=ErrorCategory.VALIDATION,
suggestion="Link the source to the same document as the job and retry.",
)
job.provider = provider or job.provider or self.settings.provider.value
job.model = model or job.model or _resolve_transcript_model(provider=self.provider, settings=self.settings)
job.prompt_name = prompt_name or job.prompt_name or DEFAULT_PROMPT_FILE
job.date_updated = datetime.now(UTC)
source.raw_transcription = text
existing_job_source = await _session.exec(
select(JobSource).where(JobSource.job_id == job_id).where(JobSource.source_id == source_id)
)
job_source = existing_job_source.first()
if job_source is None:
job_source = JobSource(
job_id=job_id,
source_id=source_id,
status=JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED,
raw_transcription=text,
error_detail=error_detail,
)
_session.add(job_source)
else:
job_source.raw_transcription = text
job_source.error_detail = error_detail
job_source.status = JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED
job_source.executed_at = datetime.now(UTC)
await self._finalize(session=_session, caller_session=session, refresh=(job, source, job_source))
return job_source
async def upsert_revision_for_source( async def upsert_revision_for_source(
self, self,
*, *,
source_id: UUID, source_id: UUID,
text: str, text: str,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> Revision: ) -> Source:
"""Create or replace the single optional revision for a source.""" """Persist a human revision on a source page."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
source = await _session.get(Source, source_id) source = await _session.get(Source, source_id)
if source is None: if source is None:
@@ -178,45 +465,35 @@ class TranscriptionService(ServiceBase):
suggestion="Verify the source id and retry.", suggestion="Verify the source id and retry.",
) )
query = select(Revision).where(Revision.source_id == source_id) source.revised_text = text
existing = (await _session.exec(query)).one_or_none() source.date_revised = datetime.now(UTC)
await self._finalize(session=_session, caller_session=session, refresh=(source,))
if existing is None: return source
revision = Revision(source_id=source_id, text=text)
_session.add(revision)
await self._finalize(session=_session, caller_session=session, refresh=(revision,))
return revision
existing.text = text
merged = await _session.merge(existing)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
async def read_revision_by_source( async def read_revision_by_source(
self, self,
source_id: UUID, source_id: UUID,
*, *,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> Revision | None: ) -> Source | None:
"""Read the single optional revision for a source.""" """Read the source record for a given page, including any revision text."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
query = select(Revision).where(Revision.source_id == source_id) return await _session.get(Source, source_id)
result = await _session.exec(query)
return result.one_or_none()
async def list_revisions_by_job( async def list_revisions_by_job(
self, self,
job_id: UUID, job_id: UUID,
*, *,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> Sequence[Revision]: ) -> Sequence[Source]:
"""List revisions connected to all sources for a job.""" """List source pages for a job that carry revision text."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
query = ( query = (
select(Revision) select(Source)
.join(Source, Source.id == Revision.source_id) .join(JobSource, JobSource.source_id == Source.id)
.where(Source.job_id == job_id) .where(JobSource.job_id == job_id)
.order_by(Revision.date_created) # pyright: ignore[reportArgumentType] .where(Source.revised_text.is_not(None))
.order_by(Source.date_revised) # pyright: ignore[reportArgumentType]
) )
result = await _session.exec(query) result = await _session.exec(query)
return result.all() return result.all()
+146 -19
View File
@@ -6,6 +6,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..config import Settings from ..config import Settings
from ..config import get_settings from ..config import get_settings
from ..db.models import Job from ..db.models import Job
from ..db.models import JobSourceStatus
from ..db.models import JobStatus from ..db.models import JobStatus
from ..db.models import Source from ..db.models import Source
from ..errors import AppError from ..errors import AppError
@@ -69,10 +70,24 @@ async def process_queued_job(
await session.commit() await session.commit()
source_job = await services.jobs.read_job(job_id=job.id, session=session) source_job = await services.jobs.read_job(job_id=job.id, session=session)
source = _resolve_primary_source(source_job) sources = _resolve_job_sources(source_job)
assert source is not None, f"Job {job.id} has no associated source record." if not sources and not source_job.job_sources:
started_at = asyncio.get_running_loop().time() candidate_sources = await services.transcriptions.list_sources(document_id=job.document_id, session=session)
sources = list(sorted(candidate_sources, key=lambda item: (item.page_number, item.upload_name.casefold())))
if not sources:
return await services.jobs.mark_job_status(job.id, JobStatus.TRANSCRIBED, session=session)
successful_pages: list[tuple[Source, TranscriptionResult]] = []
failed_pages: list[tuple[Source, AppError]] = []
externally_stopped = False
for source in sources:
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
externally_stopped = True
break
started_at = asyncio.get_running_loop().time()
try: try:
result = await asyncio.wait_for( result = await asyncio.wait_for(
transcribe_document_image(source.file_path), transcribe_document_image(source.file_path),
@@ -99,15 +114,7 @@ async def process_queued_job(
) )
_validate_transcription_quality(result=result, settings=runtime_settings) _validate_transcription_quality(result=result, settings=runtime_settings)
successful_pages.append((source, result))
job = await _finalize_transcribed(job=job, services=services, result=result, session=session)
logger.info(
"Job transcribed operation=worker.process_job job_id=%s document_id=%s source_id=%s provider=%s",
job.id,
job.document_id,
source.id,
result.provider,
)
except TimeoutError: except TimeoutError:
error = AppError( error = AppError(
f"Provider call timed out after {runtime_settings.worker_provider_timeout_seconds:.1f}s", f"Provider call timed out after {runtime_settings.worker_provider_timeout_seconds:.1f}s",
@@ -115,9 +122,9 @@ async def process_queued_job(
suggestion="Retry the job. If this repeats, verify provider latency and request payload size.", suggestion="Retry the job. If this repeats, verify provider latency and request payload size.",
retriable=True, retriable=True,
) )
job = await _finalize_failed(job=job, services=services, error=error, session=session) failed_pages.append((source, error))
logger.error( logger.error(
"Job failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s", "Source failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
job.id, job.id,
job.document_id, job.document_id,
source.id, source.id,
@@ -131,16 +138,46 @@ async def process_queued_job(
case _: case _:
error = classify_unexpected_error(exc, operation="worker.process_job") error = classify_unexpected_error(exc, operation="worker.process_job")
job = await _finalize_failed(job=job, services=services, error=error, session=session) failed_pages.append((source, error))
logger.error( logger.error(
"Job failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s", "Source failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
job.id, job.id,
job.document_id, job.document_id,
source.id, source.id,
error.error_id, error.error_id,
error.category.value, error.category.value,
) )
return job
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
externally_stopped = True
break
terminal_status = JobStatus.TRANSCRIBED
if externally_stopped:
terminal_status = JobStatus.FAILED
elif failed_pages and successful_pages:
terminal_status = JobStatus.PARTIAL_SUCCESS
elif failed_pages and not successful_pages:
terminal_status = JobStatus.FAILED
updated_job = await _finalize_batch_outcome(
job=job,
services=services,
successful_pages=successful_pages,
failed_pages=failed_pages,
status=terminal_status,
session=session,
)
logger.info(
"Job finished operation=worker.process_job job_id=%s document_id=%s status=%s success_pages=%s failed_pages=%s",
updated_job.id,
updated_job.document_id,
updated_job.status.value,
len(successful_pages),
len(failed_pages),
)
return updated_job
async def process_next_queued_job( async def process_next_queued_job(
@@ -151,6 +188,7 @@ async def process_next_queued_job(
) -> bool: ) -> bool:
"""Process the next queued job if one exists.""" """Process the next queued job if one exists."""
job = await services.jobs.read_next_queued_job(session=session) job = await services.jobs.read_next_queued_job(session=session)
if job is None: if job is None:
return False return False
@@ -291,9 +329,98 @@ async def _finalize_failed(
def _resolve_primary_source(job: Job) -> Source | None: def _resolve_primary_source(job: Job) -> Source | None:
if not job.sources: if not job.job_sources:
return None return None
return job.sources[0] return next((job_source.source for job_source in job.job_sources if job_source.source is not None), None)
def _resolve_job_sources(job: Job) -> list[Source]:
"""Resolve non-transcribed linked sources for a job in deterministic page order."""
if not job.job_sources:
return []
sources = [
job_source.source
for job_source in job.job_sources
if job_source.source is not None and job_source.status != JobSourceStatus.TRANSCRIBED
]
return list(sorted(sources, key=lambda item: (item.page_number, item.upload_name.casefold())))
async def _job_no_longer_processing(
*,
job_id,
services: ServiceBundle,
session: AsyncSession | None = None,
) -> bool:
"""Return True when job status changed externally from PROCESSING."""
latest_job = await services.jobs.read_job(job_id=job_id, session=session)
return latest_job.status != JobStatus.PROCESSING
async def _finalize_batch_outcome(
*,
job: Job,
services: ServiceBundle,
successful_pages: list[tuple[Source, TranscriptionResult]],
failed_pages: list[tuple[Source, AppError]],
status: JobStatus,
session: AsyncSession | None = None,
) -> Job:
"""Transaction B: write per-source outcomes and terminal job status atomically."""
if session is None:
async with services.jobs._session_scope() as local_session:
for source, result in successful_pages:
await services.transcriptions.update_job_source_transcription(
job_id=job.id,
source_id=source.id,
text=result.text,
error_detail=None,
provider=result.provider,
model=result.model,
prompt_name=result.prompt_name,
session=local_session,
)
for source, error in failed_pages:
await services.transcriptions.update_job_source_transcription(
job_id=job.id,
source_id=source.id,
text=None,
error_detail=format_error_detail(error),
prompt_name=DEFAULT_PROMPT_FILE,
session=local_session,
)
updated_job = await services.jobs.mark_job_status(job.id, status, session=local_session)
await local_session.commit()
return updated_job
for source, result in successful_pages:
await services.transcriptions.update_job_source_transcription(
job_id=job.id,
source_id=source.id,
text=result.text,
error_detail=None,
provider=result.provider,
model=result.model,
prompt_name=result.prompt_name,
session=session,
)
for source, error in failed_pages:
await services.transcriptions.update_job_source_transcription(
job_id=job.id,
source_id=source.id,
text=None,
error_detail=format_error_detail(error),
prompt_name=DEFAULT_PROMPT_FILE,
session=session,
)
updated_job = await services.jobs.mark_job_status(job.id, status, session=session)
await session.commit()
return updated_job
def _validate_transcription_quality(*, result: TranscriptionResult, settings: Settings) -> None: def _validate_transcription_quality(*, result: TranscriptionResult, settings: Settings) -> None:
+11 -21
View File
@@ -1,38 +1,24 @@
"""UI page registration exports.""" """UI page registration exports."""
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from nicegui import app as nicegui_app
from nicegui import ui from nicegui import ui
from transcription.ui.pages.home_page import register_page as register_home_page
from transcription.ui.pages.documents_page import register_page as register_documents_page
from transcription.ui.pages.jobs_page import register_page as register_jobs_page from transcription.ui.pages.jobs_page import register_page as register_jobs_page
from transcription.ui.pages.people_page import register_page as register_people_page
from transcription.ui.pages.sources_page import register_page as register_sources_page
from transcription.ui.pages.upload_page import register_page as register_upload_page from transcription.ui.pages.upload_page import register_page as register_upload_page
from transcription.ui.resources import read_css
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered" _THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
_THEME_COLORS: dict[str, str] = {
"primary": "#6f97e8",
"secondary": "#92b5f5",
"accent": "#7fc0de",
"dark": "#22304a",
"dark_page": "#1a2538",
"positive": "#86c8ad",
"negative": "#d98a9a",
"info": "#7ebdda",
"warning": "#e2c083",
}
def _register_global_styles(app: FastAPI) -> None: def _register_global_styles(app: FastAPI) -> None:
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False): if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
return return
nicegui_app.colors(**_THEME_COLORS) ui.add_css(read_css("theme.css"), shared=True)
css_path = Path(__file__).resolve().parent / "static" / "colors.css"
if css_path.exists():
ui.add_css(css_path, shared=True)
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True) setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
@@ -40,6 +26,10 @@ def _register_global_styles(app: FastAPI) -> None:
def register_pages(app: FastAPI) -> None: def register_pages(app: FastAPI) -> None:
"""Register all NiceGUI pages and mount them onto the FastAPI app.""" """Register all NiceGUI pages and mount them onto the FastAPI app."""
_register_global_styles(app) _register_global_styles(app)
register_home_page()
register_upload_page() register_upload_page()
register_documents_page()
register_people_page()
register_sources_page()
register_jobs_page() register_jobs_page()
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=True) ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
+13 -1
View File
@@ -1,7 +1,19 @@
"""Reusable UI component exports.""" """Reusable UI component exports."""
from transcription.ui.components.app_shell import NAV_ITEMS from transcription.ui.components.app_shell import NAV_ITEMS
from transcription.ui.components.app_shell import render_app_shell
from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.document_panzoom import render_document_panzoom from transcription.ui.components.document_panzoom import render_document_panzoom
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
__all__ = ["NAV_ITEMS", "render_document_panzoom", "render_navigation_header"] __all__ = [
"NAV_ITEMS",
"destructive_button",
"render_app_shell",
"render_document_panzoom",
"render_empty_state",
"render_navigation_header",
"section_header_row",
]
+39 -25
View File
@@ -4,56 +4,70 @@ from __future__ import annotations
from nicegui import ui from nicegui import ui
from transcription.ui.resources import read_css
NAV_ITEMS: tuple[tuple[str, str, str], ...] = ( NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
("Upload", "/upload", "upload_file"), ("Documents", "/documents", "description"),
("People", "/people", "group"),
("Sources", "/sources", "folder"),
("Jobs", "/jobs", "work_history"), ("Jobs", "/jobs", "work_history"),
) )
from transcription.ui.theme import VIBESCRIBE_LOGO_SVG
def _is_active_path(*, current_path: str, item_path: str) -> bool: def _is_active_path(*, current_path: str, item_path: str) -> bool:
if item_path == "/jobs": if item_path == "/jobs":
return current_path == "/jobs" or current_path.startswith("/jobs/") return current_path == "/jobs" or current_path.startswith("/jobs/")
if item_path == "/documents":
return current_path == "/documents" or current_path.startswith("/documents/")
if item_path == "/people":
return current_path == "/people" or current_path.startswith("/people/")
if item_path == "/sources":
return current_path == "/sources" or current_path.startswith("/sources/")
return current_path == item_path return current_path == item_path
def _button_props(*, icon: str, is_active: bool) -> str:
if is_active:
return f"icon={icon} no-caps unelevated color=primary text-color=white"
return f"icon={icon} no-caps outline color=secondary text-color=secondary"
def _button_classes(*, is_active: bool) -> str:
base = "w-full sm:w-auto min-h-[40px] px-3 rounded-lg text-body2 text-weight-medium"
if is_active:
return f"{base}"
return f"{base}"
def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) -> None: def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) -> None:
is_active = _is_active_path(current_path=current_path, item_path=path) is_active = _is_active_path(current_path=current_path, item_path=path)
button = ui.button( classes = "app-shell__nav-item"
if is_active:
classes = f"{classes} app-shell__nav-item--active"
ui.button(
label, label,
icon=icon, icon=icon,
on_click=lambda _=None, route=path: ui.navigate.to(route), on_click=lambda _=None, route=path: ui.navigate.to(route),
) ).props("flat no-caps").classes(classes)
button.props(_button_props(icon=icon, is_active=is_active)).classes(_button_classes(is_active=is_active))
def _normalize_path(current_path: str | None) -> str: def _normalize_path(current_path: str | None) -> str:
normalized = (current_path or "").strip() normalized = (current_path or "").strip()
if not normalized: if not normalized:
return "/upload" return "/homepage"
return normalized.rstrip("/") or "/" return normalized.rstrip("/") or "/"
def render_navigation_header(*, current_path: str | None = None) -> None: def render_app_shell(*, current_path: str | None = None) -> None:
"""Render a shared app header with links for top-level pages.""" """Render the shared application shell header."""
ui.add_css(read_css("components/app_shell.css"))
normalized_path = _normalize_path(current_path) normalized_path = _normalize_path(current_path)
with ( with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"):
ui.header(elevated=True).props("bordered").classes("bg-dark text-white q-px-sm q-py-xs"), with ui.element("a").props('href="/ui/homepage"').style(
ui.row().classes("w-full items-center justify-end q-gutter-xs"), "display:flex; align-items:center; gap:0.75rem; text-decoration:none; color:inherit;"
ui.element("div").classes("w-full grid grid-cols-2 gap-2 sm:flex sm:justify-start sm:gap-2"), ).classes("app-shell__brand no-wrap"):
): ui.html(VIBESCRIBE_LOGO_SVG).classes("app-shell__brand-mark")
ui.label("VibeScribe").classes("app-shell__brand-name")
with ui.element("nav").props('aria-label="Primary navigation"').classes("app-shell__nav"):
for label, path, icon in NAV_ITEMS: for label, path, icon in NAV_ITEMS:
_render_nav_button(label=label, path=path, icon=icon, current_path=normalized_path) _render_nav_button(label=label, path=path, icon=icon, current_path=normalized_path)
with ui.row().classes("app-shell__actions no-wrap"):
ui.label("Saved").classes("app-shell__save-state")
ui.button(icon="more_horiz").props("flat round dense").tooltip("More actions")
def render_navigation_header(*, current_path: str | None = None) -> None:
"""Render the app shell using the legacy page-level entry point."""
render_app_shell(current_path=current_path)
+15
View File
@@ -0,0 +1,15 @@
# transcription/ui/components/cards.py
from contextlib import contextmanager
from nicegui import ui
@contextmanager
def archival_card(title: str | None = None, extra_classes: str = ""):
"""Reusable container for Flat 2.0 Bento Grid cards."""
with ui.card().classes(f"w-full ui-card-surface p-4 {extra_classes}") as card:
if title:
ui.label(title.upper()).classes(
"text-xs font-bold ui-text-muted tracking-wider mb-3 ui-header-divider pb-1"
)
yield card
@@ -0,0 +1,14 @@
# transcription/ui/components/data_display.py
from nicegui import ui
def metadata_row(label: str, value: str):
"""Render a high-density, low-contrast key-value pair."""
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.label(value).classes("font-semibold ui-text-primary")
def archival_badge(text: str):
"""Standardized Aged Sepia badge."""
return ui.badge(text, color="secondary", text_color="dark").classes("text-[10px]")
@@ -24,10 +24,10 @@ def render_document_panzoom(*, source: Source) -> None:
document_url = _document_url(source) document_url = _document_url(source)
document_kind = _document_kind(source) document_kind = _document_kind(source)
with ui.card().classes("w-full q-pa-md"): with ui.card().classes("w-full q-pa-md vibe-card"):
with ui.row().classes("w-full items-center justify-between no-wrap"): with ui.row().classes("w-full items-center justify-between no-wrap"):
ui.label("Document preview").classes("text-subtitle1 text-weight-medium") ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
ui.label(source.filename).classes("text-caption text-grey-4 ellipsis").style( ui.label(source.filename).classes("text-caption vibe-text-muted ellipsis").style(
"max-width: 60%; text-align: right;" "max-width: 60%; text-align: right;"
) )
@@ -90,7 +90,7 @@ def _register_panzoom_assets() -> None:
height: 100%; height: 100%;
border: 0; border: 0;
pointer-events: none; pointer-events: none;
background: white; background: var(--theme-surface-raised);
} }
</style> </style>
""", """,
@@ -26,7 +26,7 @@ def show_error(exc: Exception, *, title: str, operation: str) -> None:
close_button="Dismiss", close_button="Dismiss",
) )
with ui.card().classes("bg-red-1 text-red-10 q-mt-md q-pa-md"): with ui.card().classes("vibe-card--error q-mt-md q-pa-md"):
ui.label(title).classes("text-subtitle1") ui.label(title).classes("text-subtitle1")
ui.label(error.message) ui.label(error.message)
ui.label(f"Suggested action: {error.suggestion}").classes("text-weight-medium") ui.label(f"Suggested action: {error.suggestion}").classes("text-weight-medium")
@@ -1,91 +0,0 @@
"""Reusable job detail rendering helpers."""
from __future__ import annotations
import logging
from nicegui import ui
from transcription.db.models import Job
from transcription.db.models import Revision
from transcription.db.models import Source
from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.transcript import render_original_transcription_card
from transcription.ui.components.transcript import render_revision_row
logger = logging.getLogger(__name__)
def _status_chip_classes(status: str) -> str:
if status == "queued":
return "bg-blue-1 text-blue-10"
if status == "processing":
return "bg-amber-1 text-amber-10"
if status == "transcribed":
return "bg-green-1 text-green-10"
if status == "failed":
return "bg-red-1 text-red-10"
return "bg-grey-2 text-grey-9"
def _metadata_row(label: str, value: str) -> None:
with ui.row().classes("w-full items-start justify-between q-gutter-x-md"):
ui.label(label).classes("text-caption text-grey-5 text-uppercase w-28")
ui.label(value).classes("text-body2 text-right text-grey-1 break-all")
def _render_source_section(source: Source) -> None:
with ui.card().classes("w-full q-pa-md"):
ui.label("Source").classes("text-subtitle1 text-weight-medium")
ui.separator().classes("q-my-sm")
with ui.column().classes("w-full q-gutter-y-xs"):
_metadata_row("Upload name", source.upload_name)
_metadata_row("Stored filename", source.filename)
_metadata_row("File path", source.file_path)
_metadata_row("Uploaded", source.date_uploaded.isoformat())
ui.separator().classes("q-my-md")
render_document_panzoom(source=source)
def _render_revision_section(revision: Revision | None) -> None:
with ui.card().classes("w-full q-pa-md"):
ui.label("Revision").classes("text-subtitle1 text-weight-medium")
ui.separator().classes("q-my-sm")
if revision is None:
ui.label("No revision exists for this source.").classes("text-body2 text-grey-3")
return
render_revision_row(revision=revision, initially_expanded=True)
def render_job_detail(*, job: Job, source: Source | None, revision: Revision | None) -> None:
"""Render all sections for the job detail page."""
logger.debug("Rendering job detail for job ID %s", job.id)
status_text = job.status.value
with ui.column().classes("w-full max-w-4xl q-gutter-md"):
with ui.card().classes("w-full q-pa-lg"):
with ui.row().classes("w-full items-center justify-between q-gutter-md"):
with ui.column().classes("q-gutter-none"):
ui.label("Job overview").classes("text-h6 text-weight-bold")
ui.label(str(job.id)).classes("text-caption text-grey-5")
status_chip_classes = (
"q-px-sm q-py-xs rounded-borders "
"text-weight-medium text-capitalize "
f"{_status_chip_classes(status_text)}"
)
ui.label(status_text).classes(status_chip_classes)
ui.separator().classes("q-my-md bg-blue-grey-7")
with ui.column().classes("w-full q-gutter-y-xs"):
_metadata_row("Created", job.date_created.isoformat())
_metadata_row("Updated", job.date_updated.isoformat())
_metadata_row("Retries", str(job.retry_count))
render_original_transcription_card(job=job)
if source is not None:
_render_source_section(source)
_render_revision_section(revision)
@@ -0,0 +1,38 @@
from contextlib import contextmanager
from nicegui import ui
@contextmanager
def section_header_row(*, classes: str = ""):
"""Render a standardized section header row container."""
base_classes = "w-full items-center justify-between pb-2 ui-header-divider"
with ui.row().classes(f"{base_classes} {classes}".strip()) as row:
yield row
def render_empty_state(message: str, *, italic: bool = False, extra_classes: str = "") -> None:
"""Render standardized empty-state helper text."""
classes = "text-xs ui-text-muted"
if italic:
classes = f"{classes} italic"
ui.label(message).classes(f"{classes} {extra_classes}".strip())
def destructive_button(
label: str,
*,
on_click,
icon: str,
variant: str = "outlined",
extra_classes: str = "",
):
"""Render a standardized destructive action button."""
button = ui.button(label, on_click=on_click, icon=icon)
if variant == "solid":
button.props("unelevated color=negative")
else:
button.props("outlined color=negative")
if extra_classes:
button.classes(extra_classes)
return button
@@ -57,6 +57,7 @@ def build_table(
pagination["sortBy"] = default_sort_by pagination["sortBy"] = default_sort_by
pagination["descending"] = default_descending pagination["descending"] = default_descending
# Quasar props enforce behavior; visual styling is centralized in theme.css.
table = ( table = (
ui.table( ui.table(
rows=rows, rows=rows,
@@ -64,9 +65,14 @@ def build_table(
row_key="id", row_key="id",
pagination=pagination, pagination=pagination,
) )
.classes(classes) .classes(f"w-full ui-table {classes}")
.props('table-style="table-layout: fixed; width: 100%;"') .props(
'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" '
'header-cell-class="ui-table-header text-xs uppercase tracking-wider" '
'table-class="ui-table-body text-xs"'
) )
)
logger.info("Table built with %d rows and %d columns", len(rows), len(columns)) logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
if on_row_click_id is not None: if on_row_click_id is not None:
_bind_row_click_handler(table, on_row_click_id=on_row_click_id) _bind_row_click_handler(table, on_row_click_id=on_row_click_id)
@@ -0,0 +1,59 @@
"""Documents table rendering helpers."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from nicegui import ui
from transcription.ui.components.cards import archival_card
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.table.common import build_table
@dataclass(frozen=True, slots=True)
class DocumentTableRow:
"""Read model consumed by the documents table component."""
id: UUID
name: str
document_type: str
archive_identifier: str
created_at: str
def _serialize_rows(rows: Sequence[DocumentTableRow]) -> list[dict[str, Any]]:
return [
{
"id": str(row.id),
"name": row.name,
"document_type": row.document_type or "Unspecified",
"archive_identifier": row.archive_identifier or "N/A",
"created_at": row.created_at,
}
for row in rows
]
def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
"""Render documents table and open detail page when clicking a row."""
if not rows:
with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No documents in repository yet.")
return
build_table(
rows=_serialize_rows(rows),
columns=[
{"name": "name", "label": "Document Title", "field": "name", "sortable": True, "classes": "font-serif font-semibold"},
{"name": "document_type", "label": "Type", "field": "document_type", "sortable": True},
{"name": "archive_identifier", "label": "Archive Ref", "field": "archive_identifier", "sortable": True, "classes": "font-mono"},
{"name": "created_at", "label": "Created", "field": "created_at", "sortable": True},
],
default_sort_by="name",
classes="app-table w-full",
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"),
)
@@ -11,6 +11,8 @@ from uuid import UUID
from nicegui import ui from nicegui import ui
from transcription.ui.components.cards import archival_card
from transcription.ui.components.primitives import render_empty_state
from .common import build_table from .common import build_table
@@ -40,7 +42,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
return [ return [
{ {
"id": str(row.id), "id": str(row.id),
"status": row.status, "status": row.status.upper(),
"filename": row.filename, "filename": row.filename,
"retry_count": row.retry_count, "retry_count": row.retry_count,
"date_created": _format_timestamp(row.date_created), "date_created": _format_timestamp(row.date_created),
@@ -55,15 +57,16 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
def render_jobs_table(rows: Sequence[JobTableRow]) -> None: def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"""Render jobs table and open a detail page when clicking a row.""" """Render jobs table and open a detail page when clicking a row."""
if not rows: if not rows:
ui.label("No jobs yet.") with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No active or historical processing jobs found.")
return return
build_table( build_table(
rows=_serialize_rows(rows), rows=_serialize_rows(rows),
columns=[ columns=[
{"name": "id", "label": "Job ID", "field": "id", "sortable": True}, {"name": "id", "label": "Job ID", "field": "id", "sortable": True, "classes": "font-mono"},
{"name": "status", "label": "Status", "field": "status", "sortable": True}, {"name": "status", "label": "Status", "field": "status", "sortable": True, "classes": "font-semibold ui-link-primary"},
{"name": "filename", "label": "Filename", "field": "filename", "sortable": True}, {"name": "filename", "label": "Source Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True}, {"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
{"name": "date_created", "label": "Created", "field": "date_created", "sortable": True}, {"name": "date_created", "label": "Created", "field": "date_created", "sortable": True},
{"name": "date_updated", "label": "Updated", "field": "date_updated", "sortable": True}, {"name": "date_updated", "label": "Updated", "field": "date_updated", "sortable": True},
@@ -0,0 +1,59 @@
"""People table rendering helpers."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from nicegui import ui
from transcription.ui.components.cards import archival_card
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.table.common import build_table
@dataclass(frozen=True, slots=True)
class PersonTableRow:
"""Read model consumed by the people table component."""
id: UUID
full_name: str
display_name: str
maiden_name: str
birth_date: str
def _serialize_rows(rows: Sequence[PersonTableRow]) -> list[dict[str, Any]]:
return [
{
"id": str(row.id),
"full_name": row.full_name,
"display_name": row.display_name or "Not set",
"maiden_name": row.maiden_name or "N/A",
"birth_date": row.birth_date or "Unknown",
}
for row in rows
]
def render_people_table(rows: Sequence[PersonTableRow]) -> None:
"""Render people table and open detail page when clicking a row."""
if not rows:
with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No person records found in repository.")
return
build_table(
rows=_serialize_rows(rows),
columns=[
{"name": "full_name", "label": "Full Name", "field": "full_name", "sortable": True, "classes": "font-serif font-semibold"},
{"name": "display_name", "label": "Display Name", "field": "display_name", "sortable": True},
{"name": "maiden_name", "label": "Maiden Name", "field": "maiden_name", "sortable": True},
{"name": "birth_date", "label": "Birth Date", "field": "birth_date", "sortable": True},
],
default_sort_by="full_name",
classes="app-table w-full",
on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"),
)
@@ -0,0 +1,77 @@
"""Sources table rendering helpers."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from nicegui import ui
from transcription.ui.components.cards import archival_card
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.table.common import build_table
@dataclass(frozen=True, slots=True)
class SourceTableRow:
"""Read model consumed by the sources table component."""
id: UUID
page_number: int
upload_name: str
filename: str
document_id: UUID
job_source_status: str | None = None
job_source_error_detail: str | None = None
def _serialize_rows(rows: Sequence[SourceTableRow]) -> list[dict[str, Any]]:
return [
{
"id": str(row.id),
"page_number": row.page_number,
"upload_name": row.upload_name,
"filename": row.filename,
"document_id": str(row.document_id),
"job_source_status": row.job_source_status or "-",
"job_source_error_detail": row.job_source_error_detail or "-",
}
for row in rows
]
def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
"""Render sources table and open detail page when clicking a row."""
if not rows:
with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No source file records found.")
return
build_table(
rows=_serialize_rows(rows),
columns=[
{"name": "page_number", "label": "Page", "field": "page_number", "sortable": True},
{"name": "upload_name", "label": "Upload Title", "field": "upload_name", "sortable": True, "classes": "font-serif"},
{"name": "filename", "label": "Stored Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
{
"name": "job_source_status",
"label": "Job Source Status",
"field": "job_source_status",
"sortable": True,
"classes": "font-mono",
},
{
"name": "job_source_error_detail",
"label": "Job Source Error Detail",
"field": "job_source_error_detail",
"sortable": False,
"classes": "font-mono text-xs",
},
{"name": "document_id", "label": "Document ID", "field": "document_id", "sortable": True, "classes": "font-mono"},
],
default_sort_by="page_number",
classes="app-table w-full",
on_row_click_id=lambda source_id: ui.navigate.to(f"/sources/{source_id}"),
)
+43 -24
View File
@@ -10,62 +10,67 @@ from typing import Any
from nicegui import ui from nicegui import ui
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import Revision from transcription.db.models import JobSource
from transcription.db.models import Source
type RevisionAction = Callable[[Revision], Awaitable[None] | None] type RevisionAction = Callable[[Source], Awaitable[None] | None]
def render_original_transcription_card(*, job: Job, classes: str = "w-full") -> Any: def render_original_transcription_card(*, job: Job, classes: str = "w-full") -> Any:
"""Render the immutable original job transcription output.""" """Render the immutable original job transcription output."""
status_label = "Failed" if job.error_detail else "Transcribed" latest_error_detail = _latest_job_error_detail(job)
status_label = "Failed" if latest_error_detail else "Transcribed"
header = f"Original Transcription | {status_label}" header = f"Original Transcription | {status_label}"
provider = job.provider or "unknown" provider = job.provider or "unknown"
model = job.model or "unknown" model = job.model or "unknown"
caption = f"{provider} | {model} | {_format_created_at(job.date_updated)}" caption = f"{provider} | {model} | {_format_created_at(job.date_updated)}"
card = ui.card().classes(f"{classes} q-pa-md bg-blue-grey-10") card = ui.card().classes(f"{classes} q-pa-md vibe-card")
with card, ui.column().classes("w-full q-gutter-y-sm"): with card, ui.column().classes("w-full q-gutter-y-sm"):
ui.label(header).classes("text-subtitle1 text-weight-medium") ui.label(header).classes("text-subtitle1 text-weight-medium")
ui.label(caption).classes("text-caption text-grey-5") ui.label(caption).classes("text-caption vibe-text-muted")
_metadata_row(label="Prompt", value=job.prompt_name or "unknown") _metadata_row(label="Prompt", value=job.prompt_name or "unknown")
_metadata_row(label="Updated", value=_format_created_at(job.date_updated)) _metadata_row(label="Updated", value=_format_created_at(job.date_updated))
if job.text: latest_transcription = _latest_job_transcription(job)
with ui.card().classes("w-full q-pa-sm"):
ui.markdown(job.text)
if job.error_detail: if latest_transcription:
with ui.card().classes("w-full bg-red-1 text-red-10 q-pa-sm"): with ui.card().classes("w-full q-pa-sm"):
ui.markdown(latest_transcription)
if latest_error_detail:
with ui.card().classes("w-full vibe-card--error q-pa-sm"):
ui.label("Failure detail").classes("text-caption text-uppercase") ui.label("Failure detail").classes("text-caption text-uppercase")
ui.label(job.error_detail).classes("text-body2") ui.label(latest_error_detail).classes("text-body2")
return card return card
def render_revision_row( def render_revision_row(
*, *,
revision: Revision, revision: Source | None,
initially_expanded: bool = False, initially_expanded: bool = False,
classes: str = "w-full", classes: str = "w-full",
on_delete: RevisionAction | None = None, on_delete: RevisionAction | None = None,
) -> Any: ) -> Any:
"""Render a collapsible row for the single optional source revision.""" """Render a collapsible row for the single optional source revision."""
header = "Revision | User-authored" if revision is None:
caption = _format_created_at(revision.date_created) return None
expansion = ui.expansion(value=initially_expanded, group="group").classes( header = "Source revision | User-authored"
f"{classes} rounded-borders bg-blue-grey-10" caption = _format_created_at(revision.date_revised or revision.date_uploaded)
)
expansion = ui.expansion(value=initially_expanded, group="group").classes(f"{classes} rounded-borders vibe-card")
with expansion, ui.column().classes("w-full q-gutter-y-sm q-pa-sm"): with expansion, ui.column().classes("w-full q-gutter-y-sm q-pa-sm"):
with expansion.add_slot("header"), ui.row().classes("w-full items-start justify-between q-gutter-md"): with expansion.add_slot("header"), ui.row().classes("w-full items-start justify-between q-gutter-md"):
with ui.column().classes("q-gutter-none"): with ui.column().classes("q-gutter-none"):
ui.label(header).classes("text-subtitle1 text-weight-medium") ui.label(header).classes("text-subtitle1 text-weight-medium")
ui.label(caption).classes("text-caption text-grey-5") ui.label(caption).classes("text-caption vibe-text-muted")
if on_delete is not None: if on_delete is not None:
with ui.dialog() as delete_dialog, ui.card().classes("q-pa-md"): with ui.dialog() as delete_dialog, ui.card().classes("q-pa-md"):
ui.label("Delete this transcript revision?").classes("text-body1") ui.label("Delete this source revision?").classes("text-body1")
with ui.row().classes("w-full justify-end q-gutter-sm"): with ui.row().classes("w-full justify-end q-gutter-sm"):
ui.button("Cancel", on_click=lambda: delete_dialog.submit(False)).props("flat") ui.button("Cancel", on_click=lambda: delete_dialog.submit(False)).props("flat")
ui.button("Delete", on_click=lambda: delete_dialog.submit(True)).props( ui.button("Delete", on_click=lambda: delete_dialog.submit(True)).props(
@@ -86,15 +91,29 @@ def render_revision_row(
ui.button(icon="delete", on_click=delete_current_transcript).props( ui.button(icon="delete", on_click=delete_current_transcript).props(
'flat round dense color="negative"' 'flat round dense color="negative"'
) )
_metadata_row(label="Created", value=_format_created_at(revision.date_created)) _metadata_row(label="Created", value=_format_created_at(revision.date_revised or revision.date_uploaded))
if revision.text: if revision.revised_text:
with ui.card().classes("w-full q-pa-sm"): with ui.card().classes("w-full q-pa-sm"):
ui.markdown(revision.text) ui.markdown(revision.revised_text)
return expansion return expansion
def _latest_job_transcription(job: Job) -> str | None:
for job_source in sorted(job.job_sources, key=lambda item: item.executed_at, reverse=True):
if job_source.raw_transcription:
return job_source.raw_transcription
return None
def _latest_job_error_detail(job: Job) -> str | None:
for job_source in sorted(job.job_sources, key=lambda item: item.executed_at, reverse=True):
if job_source.error_detail:
return job_source.error_detail
return None
def _format_created_at(value: datetime) -> str: def _format_created_at(value: datetime) -> str:
"""Return a compact UTC-like timestamp for row captions.""" """Return a compact UTC-like timestamp for row captions."""
return value.strftime("%Y-%m-%d %H:%M:%S %Z") return value.strftime("%Y-%m-%d %H:%M:%S %Z")
@@ -102,5 +121,5 @@ def _format_created_at(value: datetime) -> str:
def _metadata_row(*, label: str, value: str) -> None: def _metadata_row(*, label: str, value: str) -> None:
with ui.row().classes("w-md items-start justify-between q-gutter-x-md"): with ui.row().classes("w-md items-start justify-between q-gutter-x-md"):
ui.label(label).classes("text-caption text-grey-5 text-uppercase") ui.label(label).classes("text-caption vibe-text-muted text-uppercase")
ui.label(value).classes("text-body2 text-right text-grey-1 break-all") ui.label(value).classes("text-body2 text-right break-all")
-66
View File
@@ -1,66 +0,0 @@
"""Reusable upload widget for document submission."""
from __future__ import annotations
from collections.abc import Awaitable
from collections.abc import Callable
from nicegui import ui
from nicegui.binding import bindable_dataclass
from nicegui.events import UploadEventArguments
from transcription.errors import AppError
from transcription.services.documents import UploadJobResult
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.error_presenter import summarize_error
from transcription.worker import WorkerNotifier
type UploadSubmitter = Callable[[str, bytes], Awaitable[UploadJobResult]]
@bindable_dataclass
class UploadWidgetState:
"""Simple state container for upload feedback."""
loading: bool = False
message: str = ""
def render_upload_widget(*, submitter: UploadSubmitter, notifier: WorkerNotifier | None = None) -> None:
"""Render upload controls and common status/error handling."""
state = UploadWidgetState()
status_label = ui.label("Upload a document to start transcription.")
status_label.bind_text(state, "message")
async def on_upload(event: UploadEventArguments) -> None:
if state.loading:
ui.notify("Upload already in progress. Please wait.", type="warning")
return
state.loading = True
status_label.text = "Uploading..."
try:
payload = await event.file.read()
result = await submitter(event.file.name, payload)
job_id = result.job_id
state.message = f"Created job {job_id}" if job_id is not None else "Upload complete"
status_label.text = state.message
if notifier is not None:
notifier.notify()
ui.notify(state.message, type="positive")
except AppError as exc:
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
except Exception as exc: # noqa: BLE001
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
finally:
state.loading = False
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select document file",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf"')
+111
View File
@@ -0,0 +1,111 @@
"""Media viewer components for high-contrast image inspection."""
from nicegui import ui
def dark_room_viewer(
image_path: str | None,
count_label: str = "1 Source Linked",
*,
container_height: str = "500px",
) -> None:
"""Isolated high-contrast container for image inspection with pan and zoom capabilities."""
with ui.card().classes("ui-bg-viewer ui-border-viewer rounded-sm p-3 flex flex-col justify-between w-full"):
# Viewer Header Bar
with ui.row().classes("w-full justify-between items-center mb-2 ui-text-inverse text-xs"):
ui.label("SOURCE MEDIA VIEWER").classes("font-mono font-bold tracking-wider")
ui.label(count_label).classes("ui-text-accent")
# Interactive Pan/Zoom Canvas Area
if image_path:
# Container with fixed height and hidden overflow for contained panning/zooming
with ui.element("div").classes(
"relative w-full overflow-hidden border ui-border-viewer ui-bg-viewer-overlay rounded-sm flex items-center justify-center cursor-grab active:cursor-grabbing"
).style(f"height: {container_height};") as viewport:
# Image element targeted by client-side pan/zoom JS
img = (
ui.image(image_path)
.classes("max-h-full max-w-full select-none transition-transform duration-75 ease-out")
.style("transform-origin: center center;")
)
# Client-side JavaScript state management for smooth panning and scaling
js_pan_zoom = f"""
const viewport = getElement('{viewport.id}');
const img = getElement('{img.id}');
let scale = 1;
let pointX = 0;
let pointY = 0;
let startX = 0;
let startY = 0;
let isDragging = false;
function updateTransform() {{
img.style.transform = `translate(${{pointX}}px, ${{pointY}}px) scale(${{scale}})`;
}}
// Mouse Wheel Zooming
viewport.onwheel = function(e) {{
e.preventDefault();
const xs = (e.clientX - pointX) / scale;
const ys = (e.clientY - pointY) / scale;
const delta = -e.deltaY;
(delta > 0) ? (scale *= 1.15) : (scale /= 1.15);
scale = Math.min(Math.max(0.5, scale), 8); // Constrain zoom level (0.5x to 8x)
updateTransform();
}};
// Mouse Drag Panning
viewport.onmousedown = function(e) {{
e.preventDefault();
startX = e.clientX - pointX;
startY = e.clientY - pointY;
isDragging = true;
}};
window.onmouseup = function() {{
isDragging = false;
}};
viewport.onmousemove = function(e) {{
if (!isDragging) return;
e.preventDefault();
pointX = e.clientX - startX;
pointY = e.clientY - startY;
updateTransform();
}};
// Global function handles for external control toolbar
window.resetZoom_{img.id} = function() {{ scale = 1; pointX = 0; pointY = 0; updateTransform(); }};
window.zoomIn_{img.id} = function() {{ scale = Math.min(scale * 1.25, 8); updateTransform(); }};
window.zoomOut_{img.id} = function() {{ scale = Math.max(scale / 1.25, 0.5); updateTransform(); }};
"""
ui.run_javascript(js_pan_zoom)
# Control Toolbar
with ui.row().classes("w-full justify-center items-center gap-2 mt-2 pt-2 border-t ui-border-viewer"):
ui.button(
icon="zoom_in",
on_click=lambda: ui.run_javascript(f"window.zoomIn_{img.id}()"),
).props("flat round dense color=white text-xs").tooltip("Zoom In")
ui.button(
icon="zoom_out",
on_click=lambda: ui.run_javascript(f"window.zoomOut_{img.id}()"),
).props("flat round dense color=white text-xs").tooltip("Zoom Out")
ui.button(
icon="center_focus_strong",
on_click=lambda: ui.run_javascript(f"window.resetZoom_{img.id}()"),
).props("flat round dense color=white text-xs").tooltip("Reset View")
else:
# Fallback state when no image source is linked
with ui.column().classes(
"w-full flex-grow items-center justify-center border ui-border-viewer ui-bg-viewer-overlay-soft rounded-sm p-8"
).style(f"min-height: {container_height};"):
ui.label("No source media available for inspection.").classes("ui-text-muted text-xs italic")
+62
View File
@@ -0,0 +1,62 @@
"""File-backed storage helpers for the homepage content."""
from __future__ import annotations
from pathlib import Path
HOME_PAGE_DIR = Path(__file__).resolve().parents[3] / "data" / "homepage"
HOME_PAGE_MARKDOWN_PATH = HOME_PAGE_DIR / "homepage.md"
SUPPORTED_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
def ensure_homepage_storage() -> None:
"""Create the homepage storage directory when needed."""
HOME_PAGE_DIR.mkdir(parents=True, exist_ok=True)
def read_homepage_markdown() -> str:
"""Read the saved homepage markdown text."""
ensure_homepage_storage()
if not HOME_PAGE_MARKDOWN_PATH.exists():
return ""
return HOME_PAGE_MARKDOWN_PATH.read_text(encoding="utf-8")
def save_homepage_markdown(markdown_text: str) -> None:
"""Persist the homepage markdown text."""
ensure_homepage_storage()
HOME_PAGE_MARKDOWN_PATH.write_text(markdown_text, encoding="utf-8")
def store_homepage_image(*, filename: str, file_bytes: bytes) -> Path:
"""Persist an uploaded homepage image in the shared homepage folder."""
ensure_homepage_storage()
safe_name = Path(filename).name
if not safe_name:
msg = "Homepage image filename is required"
raise ValueError(msg)
stored_path = HOME_PAGE_DIR / safe_name
stored_path.write_bytes(file_bytes)
return stored_path
def list_homepage_images() -> list[Path]:
"""List stored homepage images in the order they were last updated."""
ensure_homepage_storage()
image_paths = [
path
for path in HOME_PAGE_DIR.iterdir()
if path.is_file() and path.suffix.lower() in SUPPORTED_IMAGE_SUFFIXES
]
return sorted(image_paths, key=lambda path: (path.stat().st_mtime, path.name))
def latest_homepage_image() -> Path | None:
"""Return the most recently updated homepage image, if one exists."""
image_paths = list_homepage_images()
if not image_paths:
return None
return image_paths[-1]
@@ -0,0 +1,600 @@
"""Documents list and detail page registration."""
from __future__ import annotations
from datetime import date
from uuid import UUID
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import archival_badge
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error
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.documents import DocumentTableRow
from transcription.ui.components.table.documents import render_documents_table
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
CREATE_NEW_PERSON_OPTION = "__create_new_person__"
def register_page() -> None:
"""Register documents list and detail routes."""
@ui.page("/documents/new")
async def document_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Document", subtitle="Document name is required.")
with archival_card(extra_classes="gap-3"):
name_input = ui.input(label="Document name").props("outlined bg-white").classes("w-full")
document_type_input = ui.input(label="Document type").props("outlined bg-white").classes("w-full")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined bg-white type="date"')
date_raw_input = ui.input(label="Approximate date").props("outlined bg-white")
location_input = ui.input(label="Document location").props("outlined bg-white").classes("w-full")
archive_input = ui.input(label="Archive identifier").props("outlined bg-white").classes("w-full")
notes_input = ui.textarea(label="Notes").props("outlined bg-white autogrow").classes("w-full")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = (
{"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
| {str(person.id): person.full_name for person in people}
)
def on_author_change(event) -> None:
selected = str(event.value or "").strip()
if selected == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
author_select = (
ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change)
.props("outlined bg-white")
.classes("w-full")
)
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
return_to = request.query_params.get("return_to")
async def submit_create() -> None:
candidate_name = (name_input.value or "").strip()
if not candidate_name:
ui.notify("Document name is required.", type="warning")
return
parsed_date: date | None = None
candidate_date_text = (date_input.value or "").strip()
if candidate_date_text:
try:
parsed_date = date.fromisoformat(candidate_date_text)
except ValueError:
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
return
candidate = Document(
name=candidate_name,
document_type=(document_type_input.value or "").strip() or None,
document_date=parsed_date,
document_date_raw=(date_raw_input.value or "").strip() or None,
location_created=(location_input.value or "").strip() or None,
notes=(notes_input.value or "").strip() or None,
archive_identifier=(archive_input.value or "").strip() or None,
)
try:
created = await document_service.create_document(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Create failed", operation="documents.create")
return
selected_author = (author_select.value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
return
if selected_author:
try:
parsed_person_id = UUID(selected_author)
except ValueError:
ui.notify("Selected author is invalid.", type="warning")
return
try:
await document_service.create_document_person(
DocumentPerson(
document_id=created.id,
person_id=parsed_person_id,
role=DocumentPersonRole.AUTHOR,
)
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Author link failed", operation="documents.create.link_author")
return
ui.notify("Document created", type="positive")
if return_to == "jobs_new":
ui.navigate.to(f"/jobs/new?document_id={created.id}")
return
ui.navigate.to(f"/documents/{created.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save document", on_click=submit_create, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back").props("flat")
@ui.page("/documents")
async def documents_page(session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with section_header_row():
page_header("Archival Documents")
ui.button(
"Create new document",
on_click=lambda: ui.navigate.to("/documents/new"),
icon="note_add",
).classes("ui-btn-primary")
try:
documents = sorted(
await document_service.list_documents(),
key=lambda item: item.created_at,
reverse=True,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.list")
return
# Format documents into read-model rows for the table renderer
rows = [
DocumentTableRow(
id=doc.id,
name=doc.name,
document_type=doc.document_type or "",
archive_identifier=doc.archive_identifier or "",
created_at=doc.created_at.strftime("%b %d, %Y"),
)
for doc in documents
]
render_documents_table(rows)
@ui.page("/documents/{document_id}")
async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.read")
return
author_link = next(
(
item
for item in document.document_people
if item.role == DocumentPersonRole.AUTHOR and item.person is not None
),
None,
)
# Main Bento Grid Wrapper
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
# Header Bar
with section_header_row():
page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}")
with ui.row().classes("items-center gap-2"):
ui.button(
"Edit Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"),
icon="edit",
).classes("ui-btn-primary text-xs")
destructive_button(
"Delete",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
icon="delete",
extra_classes="text-xs",
)
# High-Density Bento Grid Layout
with ui.grid().classes("w-full grid-cols-12 gap-4"):
# ZONE 1: Source Image Viewer (Cols 1-5)
with ui.column().classes("col-span-12 lg:col-span-5"):
source_path = document.sources[0].file_path if document.sources else None
dark_room_viewer(source_path, count_label=f"{len(document.sources)} Source(s) Linked")
with ui.row().classes("w-full justify-between items-center mt-2"):
ui.button(
"View All Sources",
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
icon="description",
).props("flat dense text-xs").classes("ui-link-primary")
ui.button(
"+ Add Source",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("ui-btn-primary text-xs")
# ZONE 2: Metadata & Archival Attributes (Cols 6-8)
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Archival Metadata"):
metadata_row("Author:", author_link.person.full_name if author_link and author_link.person else "Not set")
metadata_row("Exact Date:", document.document_date.isoformat() if document.document_date else "Not set")
metadata_row("Approx. Date:", document.document_date_raw or "Not set")
metadata_row("Location Created:", document.location_created or "Not set")
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
with ui.column().classes("w-full mt-2"):
ui.label("Archival Notes:").classes("ui-text-muted text-xs mb-1")
ui.label(document.notes or "No notes added.").classes(
"p-2 ui-note-box text-xs"
)
with archival_card(title="System Logistics"):
ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
# ZONE 3: Related Entities & Pipeline Jobs (Cols 9-12)
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
with archival_card(title="Related People"):
if not document.document_people:
render_empty_state("No linked people yet.", italic=True)
else:
with ui.column().classes("w-full gap-2"):
for link in document.document_people:
person_label = link.person.full_name if link.person is not None else "Unknown person"
with ui.row().classes(
"w-full justify-between items-center ui-row-surface p-2"
):
ui.label(person_label).classes("text-xs font-semibold ui-text-primary")
archival_badge(link.role.value)
with archival_card(title="Pipeline Jobs"):
with ui.row().classes("w-full justify-between items-center mb-2"):
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs ui-link-primary font-bold")
with ui.row().classes("w-full gap-2 mt-2"):
ui.button(
"View Jobs",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"),
icon="work_history",
).props("flat dense text-xs").classes("ui-link-primary")
ui.button(
"+ Add Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("ui-btn-primary text-xs")
@ui.page("/documents/{document_id}/jobs")
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
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"):
with 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, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"),
icon="open_in_new",
).props("flat dense").classes("text-xs ui-link-primary")
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
_ = session_factory
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:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.edit.read")
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Edit Document Record", subtitle="Document name and document type are required.")
with archival_card(extra_classes="gap-3"):
name_input = ui.input(label="Document name", value=document.name).props("outlined bg-white").classes("w-full")
document_type_input = (
ui.input(label="Document type", value=document.document_type or "")
.props("outlined bg-white")
.classes("w-full")
)
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
date_input = ui.input(
label="Exact date (YYYY-MM-DD)",
value=document.document_date.isoformat() if document.document_date else "",
).props('outlined bg-white type="date"')
date_raw_input = (
ui.input(label="Approximate date", value=document.document_date_raw or "")
.props("outlined bg-white")
)
location_input = (
ui.input(label="Document location", value=document.location_created or "")
.props("outlined bg-white")
.classes("w-full")
)
archive_input = (
ui.input(label="Archive identifier", value=document.archive_identifier or "")
.props("outlined bg-white")
.classes("w-full")
)
notes_input = (
ui.textarea(label="Notes", value=document.notes or "").props("outlined bg-white autogrow").classes("w-full")
)
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = (
{"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
| {str(person.id): person.full_name for person in people}
)
existing_author = next(
(item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR),
None,
)
author_value = str(existing_author.person_id) if existing_author is not None else ""
def on_author_change(event) -> None:
selected = str(event.value or "").strip()
if selected == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
author_select = (
ui.select(
author_options,
label="Author (Person)",
value=author_value,
on_change=on_author_change,
)
.props("outlined bg-white")
.classes("w-full")
)
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
async def submit_edit() -> None:
candidate_name = (name_input.value or "").strip()
candidate_type = (document_type_input.value or "").strip()
if not candidate_name:
ui.notify("Document name is required.", type="warning")
return
if not candidate_type:
ui.notify("Document type is required.", type="warning")
return
parsed_date: date | None = None
candidate_date_text = (date_input.value or "").strip()
if candidate_date_text:
try:
parsed_date = date.fromisoformat(candidate_date_text)
except ValueError:
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
return
candidate = Document(
id=document.id,
name=candidate_name,
document_type=candidate_type,
document_date=parsed_date,
document_date_raw=(date_raw_input.value or "").strip() or None,
location_created=(location_input.value or "").strip() or None,
notes=(notes_input.value or "").strip() or None,
archive_identifier=(archive_input.value or "").strip() or None,
created_at=document.created_at,
updated_at=document.updated_at,
)
try:
await document_service.update_document(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="documents.edit.save")
return
selected_author = (author_select.value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
return
existing_author_links = [
link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR
]
try:
if not selected_author:
for link in existing_author_links:
await document_service.delete_document_person(link)
else:
selected_author_id = UUID(selected_author)
if not any(link.person_id == selected_author_id for link in existing_author_links):
for link in existing_author_links:
await document_service.delete_document_person(link)
await document_service.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=selected_author_id,
role=DocumentPersonRole.AUTHOR,
)
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Author update failed", operation="documents.edit.link_author")
return
ui.notify("Document updated", type="positive")
ui.navigate.to(f"/documents/{document.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
"flat"
)
@ui.page("/documents/{document_id}/delete")
async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.delete.read")
return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Document")
with archival_card(extra_classes="gap-2"):
ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary")
has_sources = bool(document.sources)
has_jobs = bool(document.jobs)
if has_sources or has_jobs:
ui.label("Delete is blocked because related records exist.").classes("text-xs text-red-800 font-bold mt-2")
categories: list[str] = []
if has_sources:
categories.append("Sources")
if has_jobs:
categories.append("Jobs")
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-xs ui-text-muted")
ui.label("Remove related records first, then retry deletion.").classes("text-xs ui-text-muted italic")
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
icon="arrow_back",
).classes("ui-btn-primary text-xs")
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs"
)
return
ui.label("This action permanently deletes the document.").classes("text-xs text-red-800 font-medium")
async def submit_delete() -> None:
try:
await document_service.delete_document(document)
except DocumentDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
ui.navigate.to(f"/documents/{document.id}/delete")
return
except DocumentError as exc:
if exc.category == ErrorCategory.NOT_FOUND:
ui.notify("Document not found.", type="warning")
ui.navigate.to("/documents")
return
show_error(exc, title="Delete failed", operation="documents.delete")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete failed", operation="documents.delete")
return
ui.notify("Document deleted", type="positive")
ui.navigate.to("/documents")
with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button(
"Delete document permanently",
on_click=submit_delete,
icon="delete_forever",
variant="solid",
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
"flat"
)
+110
View File
@@ -0,0 +1,110 @@
"""Homepage registration and handlers."""
from __future__ import annotations
from nicegui import ui
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.homepage_store import latest_homepage_image
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 apply_archival_theme
from transcription.ui.theme import page_header
def _render_homepage_view(*, markdown_text: str, image_path) -> 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")
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"), archival_card(title="Home Text"):
if markdown_text:
ui.markdown(markdown_text)
else:
render_empty_state("No homepage text saved yet.")
with ui.column().classes("col-span-12 lg:col-span-3"):
ui.element("div")
def _render_homepage_editor(*, render_image_panel, markdown_input, on_upload) -> None:
with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Homepage Image"):
ui.upload(on_upload=on_upload, auto_upload=True, label="Upload image").props(
'accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"'
).classes("w-full")
render_image_panel()
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"), archival_card(title="Home Text"):
markdown_input[0] = ui.textarea(
label="Homepage markdown",
value=read_homepage_markdown(),
).props("outlined autogrow").classes("w-full")
with ui.column().classes("col-span-12 lg:col-span-3"):
ui.element("div")
def register_page() -> None:
"""Register the homepage routes."""
@ui.page("/homepage", title="VibeScribe Home")
def homepage_page() -> None:
apply_archival_theme()
render_navigation_header(current_path="/homepage")
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row():
page_header("Home")
ui.button(
"Edit Home Page",
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(),
)
@ui.page("/homepage/edit", title="Edit Homepage")
def homepage_edit_page() -> None:
apply_archival_theme()
render_navigation_header(current_path="/homepage")
preview_image = [latest_homepage_image()]
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")
async def on_upload(event) -> None:
payload = await event.file.read()
preview_image[0] = store_homepage_image(filename=event.file.name, file_bytes=payload)
ui.notify(f"Uploaded {event.file.name}", type="positive")
render_image_panel.refresh()
async def save_homepage() -> None:
save_homepage_markdown((markdown_input[0].value if markdown_input[0] is not None else "") or "")
ui.notify("Homepage saved", type="positive")
ui.navigate.to("/homepage")
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row():
page_header("Edit Home Page")
with ui.row().classes("items-center gap-2"):
ui.button("Save", on_click=save_homepage, icon="save").classes("ui-btn-primary text-xs")
ui.button("Cancel", on_click=lambda: ui.navigate.to("/homepage"), icon="close").props("flat")
_render_homepage_editor(
render_image_panel=render_image_panel,
markdown_input=markdown_input,
on_upload=on_upload,
)
+378 -87
View File
@@ -2,24 +2,36 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from uuid import UUID from uuid import UUID
from fastapi import Request
from nicegui import ui from nicegui import ui
from transcription.db.models import Job from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
from transcription.db.models import Source from transcription.db.session import session_scope
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobCancelBlockedError
from transcription.services.jobs import JobResubmitBlockedError
from transcription.services.jobs import JobService from transcription.services.jobs import JobService
from transcription.services.transcription import TranscriptionService from transcription.services.store import create_job_for_document
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.data_display import archival_badge
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
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.jobs import render_jobs_table from transcription.ui.components.table.jobs import render_jobs_table
from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from transcription.worker import resolve_worker_notifier
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
from ..components.document_panzoom import render_document_panzoom
from ..components.table.jobs import JobTableRow from ..components.table.jobs import JobTableRow
from ..components.transcript import render_original_transcription_card
from ..components.transcript import render_revision_row
def register_page() -> None: # noqa: PLR0915 def register_page() -> None: # noqa: PLR0915
@@ -27,9 +39,19 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/jobs") @ui.page("/jobs")
async def jobs_page(session_factory: SessionFactoryDep) -> None: async def jobs_page(session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory)
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 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")
@ui.refreshable @ui.refreshable
async def render_table() -> None: async def render_table() -> None:
jobs = [ jobs = [
@@ -45,115 +67,384 @@ def register_page() -> None: # noqa: PLR0915
] ]
render_jobs_table(jobs) render_jobs_table(jobs)
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
await render_table() await render_table()
@ui.page("/jobs/new")
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
documents_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Processing Job", subtitle="Queue source files for AI transcription and entity processing.")
documents = await documents_service.list_documents()
if not documents:
with archival_card(extra_classes="p-6 text-center"):
render_empty_state(
"No documents available. Create a Document before creating a Job.",
extra_classes="text-red-800 font-medium mb-4",
)
with ui.row().classes("justify-center gap-2"):
ui.button(
"Create document",
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
icon="note_add",
).classes("ui-btn-primary")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
return
uploaded_files: list[tuple[str, bytes]] = []
with archival_card(extra_classes="gap-3"):
document_options = {str(document.id): document.name for document in documents}
document_select = (
ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full")
)
requested_document_id = request.query_params.get("document_id")
if requested_document_id in document_options:
document_select.value = requested_document_id
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
provider_input = ui.input(label="Provider").props("outlined bg-white")
model_input = ui.input(label="Model").props("outlined bg-white")
prompt_input = ui.input(label="Prompt").props("outlined bg-white")
with archival_card(title="Source Files"):
ui.label(
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
).classes("text-xs ui-text-muted mb-2")
@ui.refreshable
def render_upload_list() -> None:
if not uploaded_files:
render_empty_state("No files uploaded yet.", italic=True)
return
def remove_file(index: int) -> None:
if 0 <= index < len(uploaded_files):
removed_name, _ = uploaded_files.pop(index)
ui.notify(f"Removed {removed_name}", type="info")
render_upload_list.refresh()
def clear_files() -> None:
uploaded_files.clear()
ui.notify("Cleared queued files", type="info")
render_upload_list.refresh()
ordered_uploads = sorted(
enumerate(uploaded_files),
key=lambda item: Path(item[1][0]).name.casefold(),
)
with ui.column().classes("gap-1 w-full mt-2"):
for index, (filename, _) in ordered_uploads:
with ui.row().classes(
"w-full items-center justify-between ui-row-surface p-2"
):
ui.label(Path(filename).name).classes("text-xs font-mono ui-text-primary")
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
"flat round dense color=negative text-xs"
)
with ui.row().classes("w-full justify-end mt-2"):
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat dense").classes(
"text-xs text-red-800"
)
async def on_upload(event) -> None:
payload = await event.file.read()
uploaded_files.append((event.file.name, payload))
ui.notify(f"Added {event.file.name}", type="positive")
render_upload_list.refresh()
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select source files or a folder",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple').classes("w-full")
render_upload_list()
async def submit_create() -> None:
selected_document = document_select.value
if not selected_document:
ui.notify("Document is required.", type="warning")
return
if not uploaded_files:
ui.notify("At least one source file is required.", type="warning")
return
try:
document_id = UUID(str(selected_document))
except ValueError:
ui.notify("Selected document id is invalid.", type="warning")
return
try:
async with session_scope(session_factory=session_factory) as session:
result = await create_job_for_document(
document_id=document_id,
uploads=uploaded_files,
provider=(provider_input.value or None),
model=(model_input.value or None),
prompt_name=(prompt_input.value or None),
session=session,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Create job failed", operation="jobs.create")
return
resolve_worker_notifier(request.app.state).notify()
ui.notify(f"Created job {result.job_id}", type="positive")
ui.navigate.to(f"/jobs/{result.job_id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").classes(
"ui-btn-primary"
)
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
@ui.page("/jobs/{job_id}") @ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915 async def job_detail_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory)
transcription_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/jobs") render_navigation_header(current_path="/jobs")
try: try:
parsed_job_id = UUID(job_id) parsed_job_id = UUID(job_id)
except ValueError: except ValueError:
ui.label("Invalid job id").classes("text-h6 text-negative") ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return return
try: try:
job = await jobs_service.read_job(job_id=parsed_job_id) job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError: except ValueError:
ui.label("Job not found").classes("text-h6 text-negative") ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return return
source = _resolve_primary_source(job) with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
with section_header_row(classes="justify-between items-center"):
page_header(f"Job Record: {job.id}")
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value.upper())
with ui.splitter(value=30).classes("w-full h-[calc(100vh-64px)]") as splitter: if job.status in {JobStatus.QUEUED, JobStatus.PROCESSING}:
with splitter.before, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"): destructive_button(
if source is not None: "Cancel",
render_document_panzoom(source=source) on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/cancel"),
else: icon="stop_circle",
ui.label("No source preview is available for this job.").classes("text-body2 text-grey-3") extra_classes="text-xs",
with splitter.after, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
with ui.row():
ui.button(icon="arrow_back", on_click=ui.navigate.back)
with ui.row().classes("w-full items-center justify-between"):
ui.label(f"{job.id}").classes("text-h6 text-weight-bold")
match job.status:
case JobStatus.TRANSCRIBED:
ui.chip(job.status.value.upper(), color="green", text_color="white").props("outline")
case _:
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
render_original_transcription_card(job=job)
async def delete_revision_by_id(revision_id: UUID) -> None:
try:
revision = await transcription_service.read_revision(revision_id=revision_id)
await transcription_service.delete_revision(revision)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete failed", operation="jobs.delete_revision")
return
ui.notify("Deleted revision", type="positive")
await render_revision_panel.refresh()
@ui.refreshable
async def render_revision_panel() -> None:
refreshed_job = await jobs_service.read_job(job_id=parsed_job_id)
refreshed_source = _resolve_primary_source(refreshed_job)
if refreshed_source is None:
ui.label("No source is available for revision editing.").classes("text-body2 text-grey-3")
return
current_revision = refreshed_source.revision
default_revision_text = (
current_revision.text if current_revision is not None else (refreshed_job.text or "")
) )
ui.label("Revision Editor").classes("text-subtitle1 text-weight-medium") if job.status != JobStatus.TRANSCRIBED:
editor = ui.textarea(label="Revision text", value=default_revision_text).props("autogrow outlined") ui.button("Resubmit", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/resubmit"), icon="replay").props(
editor.classes("w-full") "outlined"
).classes("text-xs")
async def save_revision() -> None: destructive_button(
candidate = (editor.value or "").strip() "Delete Job",
if not candidate: on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/delete"),
ui.notify("Revision text is required.", type="warning") icon="delete",
return extra_classes="text-xs",
try:
await transcription_service.upsert_revision_for_source(
source_id=refreshed_source.id,
text=candidate,
) )
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="jobs.save_revision")
return
ui.notify("Revision saved", type="positive") with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"):
await render_revision_panel.refresh() with archival_card(title="Execution Logistics"):
metadata_row("Provider:", job.provider or "pending")
metadata_row("Model:", job.model or "pending")
metadata_row("Prompt:", job.prompt_name or "pending")
metadata_row("Retry Count:", str(job.retry_count))
metadata_row("Last Updated:", job.date_updated.isoformat())
with ui.row().classes("w-full justify-end"): 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"):
ui.button( ui.button(
"Create revision" if current_revision is None else "Update revision", "View Linked Document",
on_click=save_revision, on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
icon="save", icon="description",
).props('unelevated color="primary"') ).classes("ui-btn-primary text-xs w-full")
ui.button(
"View Linked Sources",
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
icon="description",
).props("flat text-xs").classes("ui-link-primary w-full")
if current_revision is None: @ui.page("/jobs/{job_id}/cancel")
ui.label("No revision exists for this source.").classes("text-body2 text-grey-3") async def job_cancel_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
try:
parsed_job_id = UUID(job_id)
except ValueError:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return return
render_revision_row( try:
revision=current_revision, job = await jobs_service.read_job(job_id=parsed_job_id)
initially_expanded=True, except ValueError:
on_delete=lambda _revision, rid=current_revision.id: delete_revision_by_id(rid), ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Cancel Processing Job")
with archival_card(extra_classes="gap-2"):
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
metadata_row("Current Status:", job.status.value)
ui.label("Cancel stops processing and marks remaining non-transcribed sources as failed.").classes(
"text-xs ui-text-muted"
) )
await render_revision_panel() async def submit_cancel() -> None:
try:
await jobs_service.cancel_job(job_id=job.id)
except JobCancelBlockedError as exc:
ui.notify(exc.message, type="warning")
return
except ValueError:
ui.notify("Job not found.", type="warning")
ui.navigate.to("/jobs")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Cancel job failed", operation="jobs.cancel")
return
resolve_worker_notifier(request.app.state).notify()
ui.notify("Job cancelled", type="positive")
ui.navigate.to(f"/jobs/{job.id}")
def _resolve_primary_source(job: Job) -> Source | None: with ui.row().classes("w-full items-center gap-2 mt-2"):
if not job.sources: destructive_button(
return None "Cancel job",
return job.sources[0] on_click=submit_cancel,
icon="stop_circle",
variant="solid",
)
ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
@ui.page("/jobs/{job_id}/resubmit")
async def job_resubmit_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
try:
parsed_job_id = UUID(job_id)
except ValueError:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
non_transcribed_count = sum(1 for job_source in job.job_sources if job_source.status != JobSourceStatus.TRANSCRIBED)
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Resubmit Job")
with archival_card(extra_classes="gap-2"):
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
metadata_row("Current Status:", job.status.value)
metadata_row("Non-Transcribed Sources:", str(non_transcribed_count))
ui.label("Resubmit queues all non-transcribed linked sources. New results overwrite prior page-level results.").classes(
"text-xs ui-text-muted"
)
async def submit_resubmit() -> None:
try:
resubmitted_count = await jobs_service.resubmit_non_transcribed_sources(job_id=job.id)
except JobResubmitBlockedError as exc:
ui.notify(exc.message, type="warning")
return
except ValueError:
ui.notify("Job not found.", type="warning")
ui.navigate.to("/jobs")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Resubmit failed", operation="jobs.resubmit")
return
resolve_worker_notifier(request.app.state).notify()
ui.notify(f"Resubmitted {resubmitted_count} source(s)", type="positive")
ui.navigate.to(f"/jobs/{job.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Resubmit now", on_click=submit_resubmit, icon="replay").classes("ui-btn-primary")
ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
@ui.page("/jobs/{job_id}/delete")
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
try:
parsed_job_id = UUID(job_id)
except ValueError:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Processing Job")
with archival_card(extra_classes="gap-2"):
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
if job.status == JobStatus.PROCESSING:
ui.label("Delete is blocked while the job is processing.").classes(
"text-xs text-red-800 font-bold mt-2"
)
ui.label("Wait for processing to complete, then retry delete.").classes("text-xs ui-text-muted italic")
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Job",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"),
icon="arrow_back",
).classes("ui-btn-primary text-xs")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs"
)
return
ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium")
if job.job_sources:
ui.label("Related JobSource links will be removed as part of delete.").classes("text-xs ui-text-muted")
async def submit_delete() -> None:
try:
await jobs_service.delete_job_with_guardrails(job_id=job.id)
except JobDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
return
except ValueError:
ui.notify("Job not found.", type="warning")
ui.navigate.to("/jobs")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete job failed", operation="jobs.delete")
return
ui.notify("Job deleted", type="positive")
ui.navigate.to("/jobs")
with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button(
"Delete job permanently",
on_click=submit_delete,
icon="delete_forever",
variant="solid",
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
+476
View File
@@ -0,0 +1,476 @@
"""People list and detail page registration."""
from __future__ import annotations
from datetime import date
from urllib.parse import quote
from uuid import UUID
from uuid import uuid4
from fastapi import Request
from nicegui import ui
from transcription.config import Settings, get_settings
from transcription.db.models import Person
from transcription.errors import ErrorCategory
from transcription.services.documents import (
DocumentError,
DocumentService,
)
from transcription.services.store import UploadError, 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.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error
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.people import PersonTableRow, render_people_table
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
def _parse_optional_date(value: str | None, *, label: str) -> date | None:
candidate = (value or "").strip()
if not candidate:
return None
try:
return date.fromisoformat(candidate)
except ValueError as exc:
raise ValueError(f"{label} must use YYYY-MM-DD.") from exc
def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings, person_id: UUID) -> None:
async def on_portrait_selected(event) -> None:
payload = await event.file.read()
try:
stored_path = store_person_portrait(
person_id=person_id,
filename=event.file.name,
file_bytes=payload,
settings=settings,
)
except UploadError as exc:
ui.notify(str(exc), type="negative")
return
except Exception: # noqa: BLE001
ui.notify("Unable to store portrait image.", type="negative")
return
try:
relative_path = stored_path.resolve().relative_to(settings.upload_dir.resolve()).as_posix()
except ValueError:
relative_path = stored_path.name
portrait_path_input.value = relative_path
ui.notify("Portrait uploaded.", type="positive")
ui.upload(
on_upload=on_portrait_selected,
auto_upload=True,
label="Choose portrait file",
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
portrait_dir = settings.upload_dir / "persons" / str(person_id)
ui.label(f"Portraits are stored under {portrait_dir}.").classes("text-xs ui-text-muted")
def _resolve_portrait_src(path: str | None) -> str | None:
candidate = (path or "").strip()
if not candidate:
return None
normalized = candidate.replace("\\", "/")
lowered = normalized.casefold()
if lowered.startswith("http://") or lowered.startswith("https://") or lowered.startswith("data:"):
return normalized
if normalized.startswith("/"):
return normalized
if lowered.startswith("uploads/"):
return f"/{normalized}"
return f"/uploads/{quote(normalized)}"
def _resolve_runtime_settings(request: Request) -> Settings:
app_settings = getattr(request.app.state, "settings", None)
if isinstance(app_settings, Settings):
return app_settings
return get_settings()
def register_page() -> None: # noqa: PLR0915
"""Register people list and CRUD routes."""
@ui.page("/people")
async def people_page(session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with section_header_row():
page_header("Archival Entities: People")
ui.button(
"Create new person",
on_click=lambda: ui.navigate.to("/people/new"),
icon="person_add",
).classes("ui-btn-primary")
try:
people = sorted(
await people_service.list_people(),
key=lambda item: item.created_at,
reverse=True,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.list")
return
# Format person records into read-model rows for the table renderer
rows = [
PersonTableRow(
id=person.id,
full_name=person.full_name,
display_name=person.display_name or "",
maiden_name=person.maiden_name or "",
birth_date=person.birth_date.isoformat() if person.birth_date else "",
)
for person in people
]
render_people_table(rows)
@ui.page("/people/new")
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
draft_person_id = uuid4()
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Person Record", subtitle="Full name is required.")
with archival_card(extra_classes="gap-3"):
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
full_name_input = ui.input(label="Full name").props("outlined bg-white")
display_name_input = ui.input(label="Display name").props("outlined bg-white")
maiden_name_input = ui.input(label="Maiden name").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props('outlined bg-white type="date"')
birth_date_raw_input = ui.input(label="Birth date (approximate)").props("outlined bg-white")
birth_place_input = ui.input(label="Birth place").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props('outlined bg-white type="date"')
death_date_raw_input = ui.input(label="Death date (approximate)").props("outlined bg-white")
death_place_input = ui.input(label="Death place").props("outlined bg-white")
biography_input = ui.textarea(label="Biography").props("outlined bg-white autogrow").classes("w-full")
portrait_path_input = ui.input(label="Portrait path").props("outlined bg-white").classes("w-full")
_bind_portrait_file_picker(
portrait_path_input,
settings=_resolve_runtime_settings(request),
person_id=draft_person_id,
)
async def submit_create() -> None:
full_name = (full_name_input.value or "").strip()
if not full_name:
ui.notify("Full name is required.", type="warning")
return
try:
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
death_date = _parse_optional_date(death_date_input.value, label="Death date")
except ValueError as exc:
ui.notify(str(exc), type="warning")
return
candidate = Person(
id=draft_person_id,
full_name=full_name,
display_name=(display_name_input.value or "").strip() or None,
maiden_name=(maiden_name_input.value or "").strip() or None,
birth_date=birth_date,
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
birth_place=(birth_place_input.value or "").strip() or None,
death_date=death_date,
death_date_raw=(death_date_raw_input.value or "").strip() or None,
death_place=(death_place_input.value or "").strip() or None,
biography=(biography_input.value or "").strip() or None,
portrait_path=(portrait_path_input.value or "").strip() or None,
)
try:
created = await people_service.create_person(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Create failed", operation="people.create")
return
ui.notify("Person created", type="positive")
ui.navigate.to(f"/people/{created.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save person", on_click=submit_create, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}")
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.read")
return
portrait_src = _resolve_portrait_src(person.portrait_path)
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row():
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
with ui.row().classes("items-center gap-2"):
ui.button(
"Edit Person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
icon="edit",
).classes("ui-btn-primary text-xs")
destructive_button(
"Delete",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
icon="delete",
extra_classes="text-xs",
)
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(portrait_src, count_label="Portrait Media")
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
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")
metadata_row("Birth Date:", person.birth_date.isoformat() if person.birth_date else "Not set")
metadata_row("Approx. Birth Date:", person.birth_date_raw or "Not set")
metadata_row("Birth Place:", person.birth_place or "Not set")
metadata_row("Death Date:", person.death_date.isoformat() if person.death_date else "Not set")
metadata_row("Approx. Death Date:", person.death_date_raw or "Not set")
metadata_row("Death Place:", person.death_place or "Not set")
with archival_card(title="System Logistics"):
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biography"):
ui.label(person.biography or "No biography recorded.").classes(
"p-2 ui-note-box text-xs w-full"
)
with archival_card(title="Linked Documents"):
if not person.document_people:
render_empty_state("No linked documents yet.", italic=True)
render_empty_state("Link this person from a Document workflow.")
else:
with ui.column().classes("w-full gap-2"):
for link in person.document_people:
document = link.document
if document is None:
continue
with ui.row().classes(
"w-full justify-between items-center ui-row-surface p-2"
):
with ui.column().classes("gap-0"):
ui.label(document.name).classes("text-xs font-semibold ui-text-primary")
ui.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
ui.button(
"Open",
on_click=lambda _=None, doc_id=document.id: ui.navigate.to(
f"/documents/{doc_id}"
),
icon="open_in_new",
).props("flat dense text-xs").classes("ui-link-primary")
@ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.edit.read")
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Edit Person Record", subtitle="Full name is required.")
with archival_card(extra_classes="gap-3"):
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined bg-white")
display_name_input = ui.input(label="Display name", value=person.display_name or "").props(
"outlined bg-white"
)
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_input = ui.input(
label="Birth date (YYYY-MM-DD)",
value=person.birth_date.isoformat() if person.birth_date else "",
).props('outlined bg-white type="date"')
birth_date_raw_input = ui.input(
label="Birth date (approximate)", value=person.birth_date_raw or ""
).props("outlined bg-white")
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_input = ui.input(
label="Death date (YYYY-MM-DD)",
value=person.death_date.isoformat() if person.death_date else "",
).props('outlined bg-white type="date"')
death_date_raw_input = ui.input(
label="Death date (approximate)", value=person.death_date_raw or ""
).props("outlined bg-white")
death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined bg-white")
biography_input = (
ui.textarea(label="Biography", value=person.biography or "").props("outlined bg-white autogrow").classes("w-full")
)
portrait_path_input = (
ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined bg-white").classes("w-full")
)
_bind_portrait_file_picker(
portrait_path_input,
settings=_resolve_runtime_settings(request),
person_id=person.id,
)
async def submit_edit() -> None:
full_name = (full_name_input.value or "").strip()
if not full_name:
ui.notify("Full name is required.", type="warning")
return
try:
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
death_date = _parse_optional_date(death_date_input.value, label="Death date")
except ValueError as exc:
ui.notify(str(exc), type="warning")
return
candidate = Person(
id=person.id,
full_name=full_name,
display_name=(display_name_input.value or "").strip() or None,
maiden_name=(maiden_name_input.value or "").strip() or None,
birth_date=birth_date,
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
birth_place=(birth_place_input.value or "").strip() or None,
death_date=death_date,
death_date_raw=(death_date_raw_input.value or "").strip() or None,
death_place=(death_place_input.value or "").strip() or None,
biography=(biography_input.value or "").strip() or None,
portrait_path=(portrait_path_input.value or "").strip() or None,
metadata_=person.metadata_,
created_at=person.created_at,
updated_at=person.updated_at,
)
try:
await people_service.update_person(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="people.edit.save")
return
ui.notify("Person updated", type="positive")
ui.navigate.to(f"/people/{person.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}/delete")
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
try:
parsed_person_id = UUID(person_id)
except ValueError:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.delete.read")
return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Person Record")
with archival_card(extra_classes="gap-2"):
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold ui-text-primary")
if person.document_people:
ui.label(
f"This will also remove {len(person.document_people)} linked document relationship(s)."
).classes("text-xs text-red-800 font-bold mt-2")
ui.label("This action permanently deletes the person record.").classes("text-xs text-red-800 font-medium")
async def submit_delete() -> None:
try:
await people_service.delete_person(person)
except DocumentError as exc:
if exc.category == ErrorCategory.NOT_FOUND:
ui.notify("Person not found.", type="warning")
ui.navigate.to("/people")
return
show_error(exc, title="Delete failed", operation="people.delete")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete failed", operation="people.delete")
return
ui.notify("Person deleted", type="positive")
ui.navigate.to("/people")
with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button(
"Delete person permanently",
on_click=submit_delete,
icon="delete_forever",
variant="solid",
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
+358
View File
@@ -0,0 +1,358 @@
"""Sources list and detail page registration."""
from __future__ import annotations
from urllib.parse import urlencode
from uuid import UUID
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui
from transcription.db.models import JobSource, Source
from transcription.services.documents import DocumentError, DocumentService
from transcription.services.jobs import JobService
from transcription.services.transcription import (
SourceDeleteBlockedError,
TranscriptionNotFoundError,
TranscriptionService,
)
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
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 show_error
from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.sources import SourceTableRow, render_sources_table
from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
def register_page() -> None:
"""Register source list and detail routes."""
@ui.page("/sources")
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
sources_service = TranscriptionService(session_factory=session_factory)
jobs_service = JobService(session_factory=session_factory)
documents_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/sources")
document_id_text = request.query_params.get("document_id")
job_id_text = request.query_params.get("job_id")
document_id = _parse_uuid(document_id_text)
job_id = _parse_uuid(job_id_text)
document_name = None
job_label = None
back_path = None
sources: list[Source] = []
job_source_by_source_id: dict[UUID, JobSource] = {}
try:
if document_id is not None:
document = await documents_service.read_document_detail(document_id=document_id)
document_name = document.name
back_path = f"/documents/{document.id}"
sources = list(sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold())))
elif job_id is not None:
job = await jobs_service.read_job(job_id=job_id)
job_label = str(job.id)
back_path = f"/jobs/{job.id}"
job_sources = await sources_service.list_job_sources(job_id=job.id)
job_source_by_source_id = {
job_source.source_id: job_source
for job_source in job_sources
}
sources = [job_source.source for job_source in job_sources if job_source.source is not None]
sources.sort(key=lambda item: (item.page_number, item.upload_name.casefold()))
else:
sources = list(sorted(await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number)))
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except ValueError:
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.list")
return
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with section_header_row():
if document_name is not None:
header_title = f"Sources: {document_name}"
elif job_label is not None:
header_title = f"Sources for Job {job_label}"
else:
header_title = "Archival Source Media"
page_header(header_title)
if back_path is not None:
back_label = "Back to Document" if document_id is not None else "Back to Job"
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
"ui-btn-primary text-xs"
)
# Format source records into read-model rows for the table renderer
rows = [
SourceTableRow(
id=source.id,
page_number=source.page_number,
upload_name=source.upload_name,
filename=source.filename,
document_id=source.document_id,
job_source_status=(
job_source_by_source_id[source.id].status.value
if source.id in job_source_by_source_id
else None
),
job_source_error_detail=(
job_source_by_source_id[source.id].error_detail
if source.id in job_source_by_source_id
else None
),
)
for source in sources
]
render_sources_table(rows)
@ui.page("/sources/{source_id}")
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
sources_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/sources")
try:
parsed_source_id = UUID(source_id)
except ValueError:
ui.label("Invalid source id").classes("text-h6 text-red-800 p-4")
return
try:
source = await sources_service.read_source_detail(source_id=parsed_source_id)
except TranscriptionNotFoundError:
ui.label("Source not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.read")
return
back_path = _back_path_from_query(request.query_params)
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row():
page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}")
with ui.row().classes("items-center gap-2"):
if back_path is not None:
back_label = (
"Back to Document"
if "document_id" in request.query_params
else "Back to Job"
if "job_id" in request.query_params
else "Back to Sources"
)
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
"ui-btn-primary text-xs"
)
else:
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
"flat text-xs"
)
destructive_button(
"Delete Source",
on_click=lambda: ui.navigate.to(f"/sources/{source.id}/delete{_back_query(request.query_params)}"),
icon="delete",
extra_classes="text-xs",
)
with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-7 gap-4"):
with archival_card(title="Source Inspection Viewer", extra_classes="p-2"):
render_document_panzoom(source=source)
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"):
with archival_card(title="Source Metadata"):
metadata_row("Page Number:", str(source.page_number))
metadata_row("Upload Name:", source.upload_name)
metadata_row("Stored Filename:", source.filename)
metadata_row("Document ID:", str(source.document_id))
metadata_row("Date Uploaded:", source.date_uploaded.isoformat())
metadata_row(
"Date Revised:",
source.date_revised.isoformat() if source.date_revised else "Not revised",
)
with archival_card(title="Job Source Outcomes"):
if not source.job_sources:
ui.label("No job-source execution records found for this source.").classes("text-xs ui-text-muted")
else:
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
with ui.column().classes("w-full gap-1 p-2 ui-row-surface rounded"):
metadata_row("Job ID:", str(job_source.job_id))
metadata_row("Status:", job_source.status.value)
metadata_row("Executed At:", job_source.executed_at.isoformat())
metadata_row("Error Detail:", job_source.error_detail or "None")
with archival_card(title="Automated Raw Transcription"):
ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly bg-white").classes(
"w-full text-xs font-mono"
)
with archival_card(title="Curated Human Transcription"):
revision_input = (
ui.textarea(value=source.revised_text or "")
.props("outlined autogrow bg-white")
.classes("w-full text-xs")
)
async def save_revision() -> None:
candidate = (revision_input.value or "").strip()
if not candidate:
ui.notify("Revision text is required.", type="warning")
return
try:
await sources_service.upsert_revision_for_source(source_id=source.id, text=candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="sources.save_revision")
return
ui.notify("Revision saved", type="positive")
ui.navigate.to(request.url.path + _back_query(request.query_params))
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save Revision", on_click=save_revision, icon="save").classes("ui-btn-primary text-xs")
@ui.page("/sources/{source_id}/delete")
async def source_delete_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
sources_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/sources")
try:
parsed_source_id = UUID(source_id)
except ValueError:
ui.label("Invalid source id").classes("text-h6 text-red-800 p-4")
return
try:
source = await sources_service.read_source_detail(source_id=parsed_source_id)
except TranscriptionNotFoundError:
ui.label("Source not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.delete.load")
return
back_path = _back_path_from_query(request.query_params) or "/sources"
next_sources_path = f"/sources{_back_query(request.query_params)}"
linked_count = len(source.job_sources)
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Source Record")
with archival_card(extra_classes="gap-2"):
metadata_row("Source ID:", str(source.id))
metadata_row("Upload Name:", source.upload_name)
metadata_row("Linked Jobs:", str(linked_count))
if linked_count > 0:
ui.label("Delete is only available for unlinked sources.").classes("text-xs text-red-800 font-bold mt-2")
ui.label("This source is linked to one or more jobs and cannot be deleted from this view.").classes(
"text-xs ui-text-muted italic"
)
else:
ui.label("This action permanently deletes the source record.").classes("text-xs text-red-800 font-medium")
async def submit_delete() -> None:
try:
await sources_service.delete_unlinked_source(source_id=source.id)
except SourceDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
return
except TranscriptionNotFoundError:
ui.notify("Source not found.", type="warning")
ui.navigate.to(next_sources_path)
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete source failed", operation="sources.delete")
return
ui.notify("Source deleted", type="positive")
ui.navigate.to(next_sources_path)
with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button(
"Delete source permanently",
on_click=submit_delete,
icon="delete_forever",
variant="solid",
)
ui.button("Cancel", on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").props("flat")
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str) -> RedirectResponse:
return RedirectResponse(url=f"/ui/sources?document_id={document_id}")
@ui.page("/jobs/{job_id}/sources")
async def job_sources_page(job_id: str) -> RedirectResponse:
return RedirectResponse(url=f"/ui/sources?job_id={job_id}")
def _parse_uuid(value: str | None) -> UUID | None:
if not value:
return None
try:
return UUID(value)
except ValueError:
return None
def _build_filter_query(*, document_id: UUID | None, job_id: UUID | None) -> str:
params: dict[str, str] = {}
if document_id is not None:
params["document_id"] = str(document_id)
if job_id is not None:
params["job_id"] = str(job_id)
return f"?{urlencode(params)}" if params else ""
def _source_detail_path(*, source_id: UUID, document_id: UUID | None, job_id: UUID | None) -> str:
return f"/sources/{source_id}{_build_filter_query(document_id=document_id, job_id=job_id)}"
def _back_query(query_params) -> str:
params = {}
for key in ("document_id", "job_id"):
if query_params.get(key):
params[key] = query_params.get(key)
return f"?{urlencode(params)}" if params else ""
def _back_path_from_query(query_params) -> str | None:
document_id = query_params.get("document_id")
if document_id:
return f"/documents/{document_id}"
job_id = query_params.get("job_id")
if job_id:
return f"/jobs/{job_id}"
return None
def _source_transcription_text(source: Source) -> str | None:
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
if job_source.raw_transcription:
return job_source.raw_transcription
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
if job_source.error_detail:
return job_source.error_detail
return None
+5 -16
View File
@@ -3,29 +3,18 @@
from __future__ import annotations from __future__ import annotations
from fastapi import Request from fastapi import Request
from fastapi.responses import RedirectResponse
from starlette import status
from nicegui import ui from nicegui import ui
from transcription.db import session_scope
from transcription.services.store import create_upload_job
from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.upload import render_upload_widget
from transcription.worker import resolve_worker_notifier
def register_page() -> None: def register_page() -> None:
"""Register the upload page route.""" """Register the upload page route."""
@ui.page("/upload", title="Upload Document") @ui.page("/upload", title="Upload Document")
def upload_page(request: Request) -> None: def upload_page(request: Request) -> RedirectResponse:
_ = request
render_navigation_header(current_path="/upload") render_navigation_header(current_path="/upload")
return RedirectResponse(url="/ui/jobs/new", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
async def submit_upload(filename: str, file_bytes: bytes):
async with session_scope() as session:
return await create_upload_job(
filename=filename,
file_bytes=file_bytes,
session=session,
)
notify_worker = resolve_worker_notifier(request.app.state)
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
+19
View File
@@ -0,0 +1,19 @@
"""Package resource helpers for UI presentation assets."""
from __future__ import annotations
from functools import cache
from importlib.resources import files
from pathlib import PurePosixPath
@cache
def read_css(relative_path: str) -> str:
"""Read and cache a CSS resource relative to ``ui/static``."""
resource_path = PurePosixPath(relative_path)
if resource_path.is_absolute() or ".." in resource_path.parts or resource_path.suffix != ".css":
msg = f"Invalid CSS resource path: {relative_path}"
raise ValueError(msg)
resource = files("transcription.ui").joinpath("static", *resource_path.parts)
return resource.read_text(encoding="utf-8")
-30
View File
@@ -1,30 +0,0 @@
:root {
/* Soft blue-night palette tokens */
--ctp-rosewater: #f2dde5;
--ctp-flamingo: #edcfd8;
--ctp-pink: #dcc7de;
--ctp-mauve: #a9bde5;
--ctp-red: #d98a9a;
--ctp-maroon: #d39aa5;
--ctp-peach: #d7af8c;
--ctp-yellow: #e2c083;
--ctp-green: #86c8ad;
--ctp-teal: #77bfbe;
--ctp-sky: #7ebdda;
--ctp-sapphire: #74aed0;
--ctp-blue: #92b5f5;
--ctp-lavender: #6f97e8;
--ctp-text: #d8e2f5;
--ctp-subtext1: #bfcae0;
--ctp-subtext0: #a9b6cf;
--ctp-overlay2: #95a3bf;
--ctp-overlay1: #7c8ca9;
--ctp-overlay0: #657490;
--ctp-surface2: #4d5f7c;
--ctp-surface1: #394a65;
--ctp-surface0: #2a3954;
--ctp-base: #1f2b42;
--ctp-mantle: #1a2538;
--ctp-crust: #141e30;
}
@@ -0,0 +1,86 @@
.app-shell {
min-height: 64px;
padding: 0.75rem 2rem;
border-bottom: 1px solid var(--theme-border);
color: var(--theme-text);
background: var(--theme-surface-raised);
}
.app-shell__inner {
width: 100%;
display: grid;
grid-template-columns: minmax(180px, 1fr) auto minmax(180px, 1fr);
align-items: center;
gap: 1.5rem;
}
.app-shell__brand,
.app-shell__actions {
align-items: center;
}
.app-shell__brand {
gap: 0.75rem;
color: var(--theme-text);
font-family: Georgia, serif;
font-weight: 700;
}
.app-shell__brand-mark {
width: 34px;
height: 34px;
display: grid;
place-items: center;
color: var(--theme-inverse-text);
background: transparent; /* Removes the dark box */
font-family: "Trebuchet MS", sans-serif;
font-size: 0.72rem;
}
.app-shell__nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.app-shell__nav-item {
min-height: 40px;
color: var(--theme-text-muted);
}
.app-shell__nav-item--active {
color: var(--theme-primary-hover);
border-bottom: 3px solid var(--theme-primary);
}
.app-shell__actions {
justify-content: flex-end;
gap: 0.75rem;
}
.app-shell__save-state {
color: var(--theme-text-muted);
font-size: 0.82rem;
font-weight: 700;
}
@media (max-width: 700px) {
.app-shell {
padding-inline: 0.75rem;
}
.app-shell__inner {
grid-template-columns: 1fr auto;
}
.app-shell__nav {
grid-column: 1 / -1;
grid-row: 2;
justify-content: center;
}
.app-shell__brand-name,
.app-shell__save-state {
display: none;
}
}
+256
View File
@@ -0,0 +1,256 @@
:root {
--palette-carbon-black: #1c2321;
--palette-cool-steel: #7d98a1;
--palette-blue-slate: #5e6572;
--palette-powder-blue: #a9b4c2;
--palette-platinum: #eef1ef;
--theme-text: var(--palette-carbon-black);
--theme-text-muted: var(--palette-blue-slate);
--theme-page: var(--palette-platinum);
--theme-surface: color-mix(in srgb, var(--palette-platinum) 88%, var(--palette-powder-blue));
--theme-surface-raised: var(--palette-platinum);
--theme-surface-muted: color-mix(in srgb, var(--palette-platinum) 68%, var(--palette-powder-blue));
--theme-border: var(--palette-powder-blue);
--theme-primary: var(--palette-blue-slate);
--theme-primary-hover: var(--palette-carbon-black);
--theme-secondary: var(--palette-cool-steel);
--theme-focus: var(--palette-cool-steel);
--theme-inverse-text: var(--palette-platinum);
--theme-viewer: var(--palette-carbon-black);
--theme-viewer-border: var(--palette-blue-slate);
--theme-viewer-muted: var(--palette-powder-blue);
--theme-shadow: 0 10px 28px color-mix(in srgb, var(--palette-carbon-black) 14%, transparent);
--q-primary: var(--palette-blue-slate);
--q-secondary: var(--palette-cool-steel);
--q-accent: var(--palette-powder-blue);
--q-dark: var(--palette-carbon-black);
--q-dark-page: var(--palette-carbon-black);
--q-positive: var(--palette-cool-steel);
--q-negative: var(--palette-carbon-black);
--q-info: var(--palette-cool-steel);
--q-warning: var(--palette-powder-blue);
}
body,
.q-layout,
.q-page-container {
color: var(--theme-text);
background: var(--theme-page);
}
body {
font-family: "Aptos", "Trebuchet MS", sans-serif;
}
.q-card,
.vibe-card {
border: 1px solid var(--theme-border);
color: var(--theme-text);
background: var(--theme-surface-raised);
box-shadow: none;
}
.vibe-card--error {
border-color: var(--palette-carbon-black);
color: var(--theme-inverse-text);
background: var(--palette-carbon-black);
}
.vibe-text-muted {
color: var(--theme-text-muted);
}
.vibe-separator {
background: var(--theme-border);
}
.vibe-status {
border: 1px solid currentColor;
}
.vibe-status--queued {
color: var(--palette-blue-slate);
background: var(--palette-platinum);
}
.vibe-status--processing {
color: var(--palette-carbon-black);
background: var(--palette-powder-blue);
}
.vibe-status--transcribed {
color: var(--palette-carbon-black);
background: var(--palette-cool-steel);
}
.vibe-status--failed {
color: var(--palette-platinum);
background: var(--palette-carbon-black);
}
.vibe-status--default {
color: var(--palette-blue-slate);
background: var(--theme-surface-muted);
}
button:focus-visible,
a:focus-visible,
textarea:focus-visible,
input:focus-visible,
[tabindex="0"]:focus-visible {
outline: 3px solid var(--theme-focus);
outline-offset: 2px;
}
/* Semantic utility classes for incremental migration away from inline hex styles. */
.ui-text-primary {
color: var(--theme-text);
}
.ui-text-muted {
color: var(--theme-text-muted);
}
.ui-text-inverse {
color: var(--theme-inverse-text);
}
.ui-bg-page {
background: var(--theme-page);
}
.ui-bg-surface {
background: var(--theme-surface);
}
.ui-bg-surface-raised {
background: var(--theme-surface-raised);
}
.ui-bg-surface-muted {
background: var(--theme-surface-muted);
}
.ui-bg-viewer {
background: var(--theme-viewer);
}
.ui-bg-viewer-overlay {
background: color-mix(in srgb, var(--theme-viewer) 50%, transparent);
}
.ui-bg-viewer-overlay-soft {
background: color-mix(in srgb, var(--theme-viewer) 40%, transparent);
}
.ui-border-subtle {
border-color: var(--theme-border);
}
.ui-border-viewer {
border-color: var(--theme-viewer-border);
}
.ui-header-divider {
border-bottom: 1px solid var(--theme-border);
}
.ui-card-surface {
border: 1px solid var(--theme-border);
color: var(--theme-text);
background: var(--theme-surface-raised);
border-radius: 0.125rem;
}
.ui-row-surface {
border: 1px solid var(--theme-border);
color: var(--theme-text);
background: var(--theme-surface);
border-radius: 0.125rem;
}
.ui-note-box {
border: 1px solid var(--theme-border);
color: var(--theme-text);
background: var(--theme-surface);
border-radius: 0.125rem;
font-style: italic;
}
.ui-btn-primary {
color: var(--theme-inverse-text);
background: var(--theme-primary);
}
.ui-btn-primary:hover {
background: var(--theme-primary-hover);
}
.ui-btn-secondary {
color: var(--theme-primary);
background: transparent;
}
.ui-btn-secondary:hover {
color: var(--theme-primary-hover);
}
.ui-link-primary {
color: var(--theme-primary);
}
.ui-link-primary:hover {
color: var(--theme-primary-hover);
}
.ui-text-accent {
color: var(--theme-secondary);
}
.ui-page-header {
display: flex;
flex-direction: column;
gap: 0;
width: 100%;
}
.ui-page-title {
color: var(--theme-text);
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino, serif;
font-size: 1.75rem;
font-weight: 700;
line-height: 1.2;
}
.ui-page-subtitle {
color: var(--theme-text-muted);
font-size: 0.75rem;
}
.ui-table {
border: 1px solid var(--theme-border);
color: var(--theme-text);
background: var(--theme-surface-raised);
border-radius: 0.125rem;
}
.ui-table .q-table tbody tr:hover {
background: var(--theme-surface) !important;
cursor: pointer;
}
.ui-table .q-table td {
border-bottom: 1px solid var(--theme-border) !important;
}
.ui-table-header {
color: var(--theme-inverse-text);
background: var(--theme-primary);
font-weight: 700;
}
.ui-table-body {
color: var(--theme-text);
}
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -152,8 +152,15 @@ async def run_worker_loop(
wake_event.clear() wake_event.clear()
processed_any = False processed_any = False
while await process_next_queued_job(session_factory=session_factory): while True:
with handle_worker_exceptions(operation="worker.process_next_queued_job"):
processed = await process_next_queued_job(session_factory=session_factory)
if not processed:
break
processed_any = True processed_any = True
continue
break
if wake_event is None and not processed_any: if wake_event is None and not processed_any:
await asyncio.sleep(poll_interval_seconds) await asyncio.sleep(poll_interval_seconds)
+10 -5
View File
@@ -15,7 +15,6 @@ from transcription.config import Settings
from transcription.config import get_settings from transcription.config import get_settings
from transcription.db.engine import get_database_url from transcription.db.engine import get_database_url
from transcription.db.engine import get_engine from transcription.db.engine import get_engine
from transcription.db.operations import create_all
from transcription.db.session import dispose_session_factory from transcription.db.session import dispose_session_factory
from transcription.db.session import get_session_factory from transcription.db.session import get_session_factory
from transcription.db.session import session_scope from transcription.db.session import session_scope
@@ -31,6 +30,7 @@ def session():
connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False},
poolclass=StaticPool, poolclass=StaticPool,
) )
SQLModel.metadata.drop_all(engine)
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
with Session(engine) as sync_session: with Session(engine) as sync_session:
yield sync_session yield sync_session
@@ -41,8 +41,15 @@ async def default_settings():
"""Provide default settings for tests.""" """Provide default settings for tests."""
settings = get_settings(database_url="sqlite:///:memory:") settings = get_settings(database_url="sqlite:///:memory:")
db_url = get_database_url(settings) db_url = get_database_url(settings)
await create_all(engine=get_engine(database_url=db_url)) engine = get_engine(database_url=db_url)
return settings
# Cached in-memory engines persist across tests; reset schema per test for isolation.
async with engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.drop_all)
await connection.run_sync(SQLModel.metadata.create_all)
yield settings
await dispose_session_factory(db_url)
@pytest_asyncio.fixture @pytest_asyncio.fixture
@@ -52,8 +59,6 @@ async def async_session(default_settings: Settings):
async with session_scope(database_url=db_url) as async_session: async with session_scope(database_url=db_url) as async_session:
yield async_session yield async_session
await dispose_session_factory(db_url)
@pytest.fixture @pytest.fixture
def default_session_factory(default_settings: Settings): def default_session_factory(default_settings: Settings):
+247 -14
View File
@@ -1,15 +1,40 @@
"""Integration tests for end-to-end upload and worker pipeline behavior.""" """Integration tests for end-to-end upload and worker pipeline behavior."""
from pathlib import Path from pathlib import Path
from uuid import uuid4
import pytest import pytest
from transcription.config import Settings from transcription.config import Settings
from transcription.db.models import Document
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
from transcription.providers.base import TranscriptionResult from transcription.providers.base import TranscriptionResult
from transcription.services import ServiceBundle
from transcription.services.store import create_job_for_document
from transcription.services.store import create_upload_job from transcription.services.store import create_upload_job
from transcription.worker import process_next_queued_job from transcription.services.workflows import advance_job
def _build_services(default_session_factory) -> ServiceBundle:
services = ServiceBundle()
object.__setattr__(
services,
"documents",
services.documents.__class__(session_factory=default_session_factory),
)
object.__setattr__(
services,
"jobs",
services.jobs.__class__(session_factory=default_session_factory),
)
object.__setattr__(
services,
"transcriptions",
services.transcriptions.__class__(session_factory=default_session_factory),
)
return services
@pytest.mark.integration @pytest.mark.integration
@@ -18,7 +43,7 @@ class TestPipelineSuccessFlow:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upload_then_worker_persists_transcribed_terminal_state( async def test_upload_then_worker_persists_transcribed_terminal_state(
self, async_session, tmp_path: Path, monkeypatch self, async_session, default_session_factory, tmp_path: Path, monkeypatch
): ):
"""Upload followed by worker processing persists job transcription and transcribed status.""" """Upload followed by worker processing persists job transcription and transcribed status."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path) settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
@@ -58,14 +83,211 @@ class TestPipelineSuccessFlow:
_fake_transcribe_document_image, _fake_transcribe_document_image,
) )
processed = await process_next_queued_job(session=async_session) services = _build_services(default_session_factory)
job = await async_session.get(Job, upload_result.job_id) queued_job = await services.jobs.read_job(job_id=upload_result.job_id, session=async_session)
processed = queued_job is not None
if queued_job is not None:
await advance_job(job=queued_job, services=services, session=async_session)
job = await services.jobs.read_job(job_id=upload_result.job_id, session=async_session)
assert processed is True assert processed is True
assert job is not None assert job is not None
assert job.status == JobStatus.TRANSCRIBED assert job.status == JobStatus.TRANSCRIBED
assert job.text == "Pipeline transcript" assert any(job_source.raw_transcription == "Pipeline transcript" for job_source in job.job_sources)
assert job.error_detail is None assert all(job_source.error_detail is None for job_source in job.job_sources)
@pytest.mark.asyncio
async def test_worker_transcribes_all_sources_for_multi_page_job(
self,
async_session,
default_session_factory,
tmp_path: Path,
monkeypatch,
):
"""Worker stores transcription output for every source linked to the queued job."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
document = Document(id=uuid4(), name="multi-page-document")
async_session.add(document)
await async_session.commit()
create_result = await create_job_for_document(
document_id=document.id,
uploads=[
("page-01.jpg", b"one"),
("page-02.jpg", b"two"),
("page-03.jpg", b"three"),
],
session=async_session,
settings=settings,
)
async def _fake_transcribe_document_image(
image_path,
*,
prompt_name="transcribe_document.md",
settings=None,
provider=None,
) -> TranscriptionResult:
page_name = Path(image_path).name
_ = (prompt_name, settings, provider)
return TranscriptionResult(
text=f"Transcript for {page_name}",
provider="openrouter",
model="test-model",
prompt_name="transcribe_document.md",
)
monkeypatch.setattr(
"transcription.services.workflows.transcribe_document_image",
_fake_transcribe_document_image,
)
services = _build_services(default_session_factory)
queued_job = await services.jobs.read_job(job_id=create_result.job_id, session=async_session)
assert queued_job is not None
await advance_job(job=queued_job, services=services, session=async_session)
job = await services.jobs.read_job(job_id=create_result.job_id, session=async_session)
assert job.status == JobStatus.TRANSCRIBED
assert len(job.job_sources) == 3
assert all(job_source.status == JobSourceStatus.TRANSCRIBED for job_source in job.job_sources)
assert all(job_source.raw_transcription for job_source in job.job_sources)
assert all(job_source.source is not None and job_source.source.raw_transcription for job_source in job.job_sources)
@pytest.mark.asyncio
async def test_worker_marks_partial_success_when_some_sources_fail(
self,
async_session,
default_session_factory,
tmp_path: Path,
monkeypatch,
):
"""Mixed page outcomes produce PARTIAL_SUCCESS and preserve per-source status."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
document = Document(id=uuid4(), name="partial-page-document")
async_session.add(document)
await async_session.commit()
create_result = await create_job_for_document(
document_id=document.id,
uploads=[
("page-01.jpg", b"one"),
("page-02.jpg", b"two"),
],
session=async_session,
settings=settings,
)
call_count = 0
async def _fake_transcribe_document_image(
image_path,
*,
prompt_name="transcribe_document.md",
settings=None,
provider=None,
) -> TranscriptionResult:
nonlocal call_count
call_count += 1
_ = (prompt_name, settings, provider)
if call_count == 2:
raise RuntimeError("simulated page failure")
return TranscriptionResult(
text="Transcript for first page",
provider="openrouter",
model="test-model",
prompt_name="transcribe_document.md",
)
monkeypatch.setattr(
"transcription.services.workflows.transcribe_document_image",
_fake_transcribe_document_image,
)
services = _build_services(default_session_factory)
queued_job = await services.jobs.read_job(job_id=create_result.job_id, session=async_session)
assert queued_job is not None
await advance_job(job=queued_job, services=services, session=async_session)
job = await services.jobs.read_job(job_id=create_result.job_id, session=async_session)
assert job.status == JobStatus.PARTIAL_SUCCESS
assert len(job.job_sources) == 2
statuses = {job_source.status for job_source in job.job_sources}
assert statuses == {JobSourceStatus.TRANSCRIBED, JobSourceStatus.FAILED}
assert any(job_source.error_detail is not None for job_source in job.job_sources)
@pytest.mark.asyncio
async def test_worker_skips_already_transcribed_sources_on_resubmit(
self,
async_session,
default_session_factory,
tmp_path: Path,
monkeypatch,
):
"""Queued jobs only process non-transcribed JobSource records."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
document = Document(id=uuid4(), name="resubmit-filter-document")
async_session.add(document)
await async_session.commit()
create_result = await create_job_for_document(
document_id=document.id,
uploads=[
("page-01.jpg", b"one"),
("page-02.jpg", b"two"),
],
session=async_session,
settings=settings,
)
services = _build_services(default_session_factory)
job = await services.jobs.read_job(job_id=create_result.job_id, session=async_session)
page_one = next(js for js in job.job_sources if js.source is not None and js.source.page_number == 1)
page_two = next(js for js in job.job_sources if js.source is not None and js.source.page_number == 2)
page_one.status = JobSourceStatus.TRANSCRIBED
page_one.raw_transcription = "existing transcript"
page_two.status = JobSourceStatus.PENDING
page_two.raw_transcription = None
await services.transcriptions.update_job_source(job_source=page_one, session=async_session)
await services.transcriptions.update_job_source(job_source=page_two, session=async_session)
await services.jobs.update_job_state(job_id=job.id, status=JobStatus.QUEUED, session=async_session)
await async_session.commit()
call_count = 0
async def _fake_transcribe_document_image(
image_path,
*,
prompt_name="transcribe_document.md",
settings=None,
provider=None,
) -> TranscriptionResult:
nonlocal call_count
_ = (image_path, prompt_name, settings, provider)
call_count += 1
return TranscriptionResult(
text="new transcript",
provider="openrouter",
model="test-model",
prompt_name="transcribe_document.md",
)
monkeypatch.setattr(
"transcription.services.workflows.transcribe_document_image",
_fake_transcribe_document_image,
)
queued_job = await services.jobs.read_job(job_id=create_result.job_id, session=async_session)
assert queued_job is not None
await advance_job(job=queued_job, services=services, session=async_session)
refreshed = await services.jobs.read_job(job_id=create_result.job_id, session=async_session)
assert call_count == 1
statuses = {js.status for js in refreshed.job_sources}
assert statuses == {JobSourceStatus.TRANSCRIBED}
@pytest.mark.integration @pytest.mark.integration
@@ -73,7 +295,13 @@ class TestPipelineFailureFlow:
"""Verify end-to-end failure lifecycle behavior.""" """Verify end-to-end failure lifecycle behavior."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upload_then_worker_persists_failed_terminal_state(self, async_session, tmp_path: Path, monkeypatch): async def test_upload_then_worker_persists_failed_terminal_state(
self,
async_session,
default_session_factory,
tmp_path: Path,
monkeypatch,
):
"""Upload followed by worker processing persists error detail and failed status on the job.""" """Upload followed by worker processing persists error detail and failed status on the job."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path) settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
upload_result = await create_upload_job( upload_result = await create_upload_job(
@@ -98,14 +326,19 @@ class TestPipelineFailureFlow:
_fake_transcribe_document_image, _fake_transcribe_document_image,
) )
processed = await process_next_queued_job(session=async_session) services = _build_services(default_session_factory)
job = await async_session.get(Job, upload_result.job_id) queued_job = await services.jobs.read_job(job_id=upload_result.job_id, session=async_session)
processed = queued_job is not None
if queued_job is not None:
await advance_job(job=queued_job, services=services, session=async_session)
job = await services.jobs.read_job(job_id=upload_result.job_id, session=async_session)
assert processed is True assert processed is True
assert job is not None assert job is not None
assert job.status == JobStatus.FAILED assert job.status == JobStatus.FAILED
assert job.text is None assert all(job_source.raw_transcription is None for job_source in job.job_sources)
assert job.error_detail is not None assert any(job_source.error_detail is not None for job_source in job.job_sources)
assert "pipeline provider failure" in job.error_detail error_detail = next(job_source.error_detail for job_source in job.job_sources if job_source.error_detail is not None)
assert "[internal_unexpected_error]" in job.error_detail assert "pipeline provider failure" in error_detail
assert "error_id=" in job.error_detail assert "[internal_unexpected_error]" in error_detail
assert "error_id=" in error_detail
+227
View File
@@ -0,0 +1,227 @@
from __future__ import annotations
from datetime import UTC
from datetime import datetime
from pathlib import Path
from uuid import uuid4
import pytest
from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Person
from transcription.db.models import Source
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
@pytest.mark.asyncio
async def test_read_document_detail_allows_missing_sources(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
created = await service.create_document(
Document(
id=uuid4(),
name="detail-doc",
document_type="letter",
)
)
detail = await service.read_document_detail(created.id)
assert detail.id == created.id
assert detail.sources == []
@pytest.mark.asyncio
async def test_update_document_refreshes_updated_timestamp(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
created = await service.create_document(
Document(
id=uuid4(),
name="timestamp-doc",
document_type="letter",
updated_at=datetime(2000, 1, 1, tzinfo=UTC),
)
)
original_updated_at = created.updated_at
created.notes = "updated"
updated = await service.update_document(created)
assert updated.notes == "updated"
assert updated.updated_at >= original_updated_at
@pytest.mark.asyncio
async def test_delete_document_blocks_when_dependencies_exist(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
document = await service.create_document(
Document(
id=uuid4(),
name="blocked-delete",
document_type="record",
)
)
async with service._session_scope() as session:
session.add(
Source(
document_id=document.id,
page_number=1,
upload_name="001_page.png",
filename="001_page.png",
file_path="uploads/001_page.png",
)
)
session.add(Job(document_id=document.id))
await session.commit()
with pytest.raises(DocumentDeleteBlockedError):
await service.delete_document(document)
@pytest.mark.asyncio
async def test_delete_document_succeeds_when_unlinked(default_session_factory, tmp_path):
service = DocumentService(session_factory=default_session_factory)
service.settings.upload_dir = tmp_path
document = await service.create_document(
Document(
id=uuid4(),
name="free-delete",
document_type="memo",
)
)
document_dir = service.settings.upload_dir / "documents" / str(document.id)
document_dir.mkdir(parents=True, exist_ok=True)
(document_dir / "leftover.txt").write_text("orphan", encoding="utf-8")
await service.delete_document(document)
assert not document_dir.exists()
with pytest.raises(DocumentError):
await service.read_document_detail(document.id)
@pytest.mark.asyncio
async def test_delete_document_removes_populated_storage_tree(default_session_factory, tmp_path):
service = DocumentService(session_factory=default_session_factory)
service.settings.upload_dir = tmp_path
document = await service.create_document(
Document(
id=uuid4(),
name="tree-delete",
document_type="memo",
)
)
document_dir = service.settings.upload_dir / "documents" / str(document.id)
(document_dir / "page-1.jpg").parent.mkdir(parents=True, exist_ok=True)
(document_dir / "page-1.jpg").write_bytes(b"one")
(document_dir / "page-2.jpg").write_bytes(b"two")
(document_dir / "nested" / "manifest.json").parent.mkdir(parents=True, exist_ok=True)
(document_dir / "nested" / "manifest.json").write_text('{"ok": true}', encoding="utf-8")
assert document_dir.exists()
await service.delete_document(document)
assert not document_dir.exists()
@pytest.mark.asyncio
async def test_read_person_detail_loads_document_links(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
document = await service.create_document(
Document(
id=uuid4(),
name="linked-doc",
document_type="letter",
)
)
person = await service.create_person(Person(full_name="Linked Person"))
await service.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
detail = await service.read_person_detail(person.id)
assert detail.id == person.id
assert len(detail.document_people) == 1
assert detail.document_people[0].document is not None
assert detail.document_people[0].document.name == "linked-doc"
@pytest.mark.asyncio
async def test_update_person_refreshes_updated_timestamp(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
created = await service.create_person(
Person(
full_name="timestamp-person",
updated_at=datetime(2000, 1, 1, tzinfo=UTC),
)
)
original_updated_at = created.updated_at
created.display_name = "updated"
updated = await service.update_person(created)
assert updated.display_name == "updated"
assert updated.updated_at >= original_updated_at
@pytest.mark.asyncio
async def test_delete_person_removes_links_when_linked_documents_exist(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
document = await service.create_document(
Document(
id=uuid4(),
name="block-person-delete-doc",
document_type="record",
)
)
person = await service.create_person(Person(full_name="Blocked Person"))
await service.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
await service.delete_person(person)
links = await service.list_document_people(person_id=person.id)
assert links == []
with pytest.raises(DocumentError):
await service.read_person_detail(person.id)
@pytest.mark.asyncio
async def test_delete_person_succeeds_when_unlinked(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
person = await service.create_person(Person(full_name="Free Person"))
await service.delete_person(person)
with pytest.raises(DocumentError):
await service.read_person_detail(person.id)
+287 -5
View File
@@ -1,12 +1,20 @@
from uuid import uuid4 from uuid import uuid4
from datetime import UTC
from datetime import datetime
from datetime import timedelta
import pytest import pytest
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
from transcription.db.models import Source from transcription.db.models import Source
from transcription.services.documents import DocumentService from transcription.services.documents import DocumentService
from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobCancelBlockedError
from transcription.services.jobs import JobResubmitBlockedError
from transcription.services.jobs import JobService from transcription.services.jobs import JobService
@@ -66,14 +74,21 @@ class TestJobService:
await job_service.create_job(job=job) await job_service.create_job(job=job)
async with job_service._session_scope() as session: async with job_service._session_scope() as session:
session.add( source = Source(
Source(
document_id=document.id, document_id=document.id,
job_id=job.id, page_number=1,
upload_name="letter.jpg", upload_name="letter.jpg",
filename="stored-letter.jpg", filename="stored-letter.jpg",
file_path="/uploads/stored-letter.jpg", file_path="/uploads/stored-letter.jpg",
) )
session.add(source)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
) )
await session.commit() await session.commit()
@@ -90,11 +105,278 @@ class TestJobService:
document = Document(id=uuid4(), name="ordered-doc") document = Document(id=uuid4(), name="ordered-doc")
await document_service.create_document(document=document) await document_service.create_document(document=document)
first = Job(document_id=document.id, status=JobStatus.QUEUED) created_at = datetime.now(UTC)
second = Job(document_id=document.id, status=JobStatus.QUEUED) first = Job(document_id=document.id, status=JobStatus.QUEUED, date_created=created_at)
second = Job(
document_id=document.id,
status=JobStatus.QUEUED,
date_created=created_at + timedelta(microseconds=1),
)
await job_service.create_job(job=first) await job_service.create_job(job=first)
await job_service.create_job(job=second) await job_service.create_job(job=second)
next_job = await job_service.read_next_queued_job() next_job = await job_service.read_next_queued_job()
assert next_job is not None assert next_job is not None
assert next_job.id == first.id assert next_job.id == first.id
@pytest.mark.asyncio
async def test_create_job_persists_provider_model_prompt(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="provider-doc")
await document_service.create_document(document=document)
job = Job(
document_id=document.id,
provider="openrouter",
model="google/gemini-2.5-flash",
prompt_name="transcribe_document.md",
)
await job_service.create_job(job=job)
fetched = await job_service.read_job(job_id=job.id)
assert fetched.provider == "openrouter"
assert fetched.model == "google/gemini-2.5-flash"
assert fetched.prompt_name == "transcribe_document.md"
@pytest.mark.asyncio
async def test_read_job_resolves_filename_from_linked_source(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="filename-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id)
await job_service.create_job(job=job)
async with job_service._session_scope() as session:
source = Source(
document_id=document.id,
page_number=1,
upload_name="page_001.png",
filename="stored_page_001.png",
file_path="/uploads/stored_page_001.png",
)
session.add(source)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
fetched = await job_service.read_job(job_id=job.id)
assert fetched.filename == "stored_page_001.png"
@pytest.mark.asyncio
async def test_delete_job_with_guardrails_blocks_processing_jobs(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="processing-delete-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.PROCESSING)
await job_service.create_job(job=job)
with pytest.raises(JobDeleteBlockedError):
await job_service.delete_job_with_guardrails(job_id=job.id)
@pytest.mark.asyncio
async def test_delete_job_with_guardrails_removes_jobsource_links(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="delete-job-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.QUEUED)
await job_service.create_job(job=job)
async with job_service._session_scope() as session:
source = Source(
document_id=document.id,
page_number=1,
upload_name="delete-job-source.jpg",
filename="stored-delete-job-source.jpg",
file_path="/uploads/stored-delete-job-source.jpg",
)
session.add(source)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
await job_service.delete_job_with_guardrails(job_id=job.id)
with pytest.raises(ValueError):
await job_service.read_job(job_id=job.id)
@pytest.mark.asyncio
async def test_cancel_job_marks_non_transcribed_sources_failed(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="cancel-job-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.QUEUED)
await job_service.create_job(job=job)
async with job_service._session_scope() as session:
source_one = Source(
document_id=document.id,
page_number=1,
upload_name="cancel-1.jpg",
filename="stored-cancel-1.jpg",
file_path="/uploads/stored-cancel-1.jpg",
)
source_two = Source(
document_id=document.id,
page_number=2,
upload_name="cancel-2.jpg",
filename="stored-cancel-2.jpg",
file_path="/uploads/stored-cancel-2.jpg",
)
session.add(source_one)
session.add(source_two)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source_one.id,
status=JobSourceStatus.TRANSCRIBED,
raw_transcription="done",
)
)
session.add(
JobSource(
job_id=job.id,
source_id=source_two.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
cancelled = await job_service.cancel_job(job_id=job.id)
assert cancelled.status == JobStatus.FAILED
refreshed = await job_service.read_job(job_id=job.id)
statuses = {item.status for item in refreshed.job_sources}
assert JobSourceStatus.TRANSCRIBED in statuses
assert JobSourceStatus.FAILED in statuses
pending_entry = next(item for item in refreshed.job_sources if item.status == JobSourceStatus.FAILED)
assert pending_entry.error_detail == "Cancelled by user"
@pytest.mark.asyncio
async def test_resubmit_non_transcribed_sources_resets_only_non_transcribed(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="resubmit-job-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.FAILED)
await job_service.create_job(job=job)
async with job_service._session_scope() as session:
source_one = Source(
document_id=document.id,
page_number=1,
upload_name="resubmit-1.jpg",
filename="stored-resubmit-1.jpg",
file_path="/uploads/stored-resubmit-1.jpg",
raw_transcription="existing text",
)
source_two = Source(
document_id=document.id,
page_number=2,
upload_name="resubmit-2.jpg",
filename="stored-resubmit-2.jpg",
file_path="/uploads/stored-resubmit-2.jpg",
raw_transcription="done text",
)
session.add(source_one)
session.add(source_two)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source_one.id,
status=JobSourceStatus.FAILED,
raw_transcription=None,
error_detail="prior error",
)
)
session.add(
JobSource(
job_id=job.id,
source_id=source_two.id,
status=JobSourceStatus.TRANSCRIBED,
raw_transcription="done text",
)
)
await session.commit()
count = await job_service.resubmit_non_transcribed_sources(job_id=job.id)
assert count == 1
refreshed = await job_service.read_job(job_id=job.id)
assert refreshed.status == JobStatus.QUEUED
failed_entry = next(item for item in refreshed.job_sources if item.source is not None and item.source.page_number == 1)
transcribed_entry = next(item for item in refreshed.job_sources if item.source is not None and item.source.page_number == 2)
assert failed_entry.status == JobSourceStatus.PENDING
assert failed_entry.error_detail is None
assert failed_entry.source is not None
assert failed_entry.source.raw_transcription is None
assert transcribed_entry.status == JobSourceStatus.TRANSCRIBED
@pytest.mark.asyncio
async def test_resubmit_non_transcribed_sources_blocks_when_processing(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="resubmit-blocked-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.PROCESSING)
await job_service.create_job(job=job)
with pytest.raises(JobResubmitBlockedError):
await job_service.resubmit_non_transcribed_sources(job_id=job.id)
@pytest.mark.asyncio
async def test_cancel_job_blocks_transcribed_terminal_jobs(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="cancel-blocked-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED)
await job_service.create_job(job=job)
with pytest.raises(JobCancelBlockedError):
await job_service.cancel_job(job_id=job.id)
+120
View File
@@ -0,0 +1,120 @@
from pathlib import Path
from uuid import uuid4
import pytest
from sqlmodel import select
from transcription.config import Settings
from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import Source
from transcription.services.store import UploadError
from transcription.services.store import create_upload_job
from transcription.services.store import create_job_for_document
from transcription.services.store import store_person_portrait
@pytest.mark.asyncio
async def test_create_job_for_document_requires_at_least_one_upload(async_session, tmp_path):
document = Document(id=uuid4(), name="needs-upload")
async_session.add(document)
await async_session.commit()
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
with pytest.raises(UploadError):
await create_job_for_document(
document_id=document.id,
uploads=[],
session=async_session,
settings=settings,
)
@pytest.mark.asyncio
async def test_create_job_for_document_sorts_uploads_and_creates_links(async_session, tmp_path):
document = Document(id=uuid4(), name="ordered-upload-doc")
async_session.add(document)
await async_session.commit()
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
result = await create_job_for_document(
document_id=document.id,
uploads=[
("folder/b_page.pdf", b"b"),
("folder/A_page.pdf", b"a"),
],
provider="openrouter",
model="test-model",
prompt_name="transcribe_document.md",
session=async_session,
settings=settings,
)
created_job = await async_session.get(Job, result.job_id)
assert created_job is not None
assert created_job.provider == "openrouter"
assert created_job.model == "test-model"
assert created_job.prompt_name == "transcribe_document.md"
sources = (
await async_session.exec(
select(Source)
.where(Source.document_id == document.id)
.order_by(Source.page_number) # pyright: ignore[reportArgumentType]
)
).all()
assert [source.upload_name for source in sources] == ["A_page.pdf", "b_page.pdf"]
assert all(source.filename.endswith(".pdf") for source in sources)
assert all("A_page" not in source.filename and "b_page" not in source.filename for source in sources)
assert all(Path(source.filename).stem == str(source.id) for source in sources)
assert all(Path(source.file_path).parent == (tmp_path / "documents" / str(document.id)) for source in sources)
job_sources = (await async_session.exec(select(JobSource).where(JobSource.job_id == result.job_id))).all()
assert len(job_sources) == 2
assert set(result.source_ids) == {job_source.source_id for job_source in job_sources}
@pytest.mark.asyncio
async def test_create_upload_job_stores_source_under_document_id_directory(async_session, tmp_path):
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
result = await create_upload_job(
filename="single-page.jpg",
file_bytes=b"image-bytes",
session=async_session,
settings=settings,
)
expected_parent = tmp_path / "documents" / str(result.document_id)
assert result.stored_path.parent == expected_parent
assert result.stored_path.exists()
source = (
await async_session.exec(
select(Source)
.where(Source.document_id == result.document_id)
.order_by(Source.page_number) # pyright: ignore[reportArgumentType]
)
).first()
assert source is not None
assert Path(source.filename).stem == str(source.id)
assert result.stored_path.name == source.filename
assert Path(source.file_path).parent == expected_parent
def test_store_person_portrait_stores_file_under_person_id_directory(tmp_path):
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
person_id = uuid4()
stored_path = store_person_portrait(
person_id=person_id,
filename="portrait.png",
file_bytes=b"portrait-bytes",
settings=settings,
)
assert stored_path.parent == (tmp_path / "persons" / str(person_id))
assert stored_path.exists()
+146 -12
View File
@@ -1,4 +1,4 @@
"""Tests for revision behavior in TranscriptionService.""" """Tests for source revision behavior in TranscriptionService."""
from uuid import uuid4 from uuid import uuid4
@@ -6,16 +6,20 @@ import pytest
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
from transcription.db.models import Source from transcription.db.models import Source
from transcription.services.documents import DocumentService from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService from transcription.services.jobs import JobService
from transcription.services.transcription import SourceDeleteBlockedError
from transcription.services.transcription import TranscriptionNotFoundError
from transcription.services.transcription import TranscriptionService from transcription.services.transcription import TranscriptionService
@pytest.mark.integration @pytest.mark.integration
class TestTranscriptionServiceRevisionUpsert: class TestTranscriptionServiceRevisionUpsert:
"""Verify optional single-revision create/update semantics.""" """Verify page-level source revision semantics."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upsert_revision_creates_new_revision(self, default_session_factory): async def test_upsert_revision_creates_new_revision(self, default_session_factory):
@@ -26,29 +30,31 @@ class TestTranscriptionServiceRevisionUpsert:
document = Document(id=uuid4(), name="revision-create") document = Document(id=uuid4(), name="revision-create")
await documents.create_document(document=document) await documents.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED, text="Original text") job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED)
await jobs.create_job(job=job) await jobs.create_job(job=job)
source = Source( source = Source(
document_id=document.id, document_id=document.id,
job_id=job.id, page_number=1,
upload_name="source.jpg", upload_name="source.jpg",
filename="source.jpg", filename="source.jpg",
file_path="uploads/source.jpg", file_path="uploads/source.jpg",
) )
async with transcriptions._session_scope() as session: async with transcriptions._session_scope() as session:
session.add(source) session.add(source)
await session.flush()
session.add(JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING))
await session.commit() await session.commit()
await session.refresh(source) await session.refresh(source)
revision = await transcriptions.upsert_revision_for_source(source_id=source.id, text="User revision") revision = await transcriptions.upsert_revision_for_source(source_id=source.id, text="User revision")
fetched = await transcriptions.read_revision_by_source(source.id) fetched = await transcriptions.read_revision_by_source(source.id)
assert revision.source_id == source.id assert revision.id == source.id
assert revision.text == "User revision" assert revision.revised_text == "User revision"
assert fetched is not None assert fetched is not None
assert fetched.id == revision.id assert fetched.id == source.id
assert fetched.text == "User revision" assert fetched.revised_text == "User revision"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upsert_revision_updates_existing_single_revision(self, default_session_factory): async def test_upsert_revision_updates_existing_single_revision(self, default_session_factory):
@@ -59,18 +65,20 @@ class TestTranscriptionServiceRevisionUpsert:
document = Document(id=uuid4(), name="revision-update") document = Document(id=uuid4(), name="revision-update")
await documents.create_document(document=document) await documents.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED, text="Original text") job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED)
await jobs.create_job(job=job) await jobs.create_job(job=job)
source = Source( source = Source(
document_id=document.id, document_id=document.id,
job_id=job.id, page_number=1,
upload_name="source.jpg", upload_name="source.jpg",
filename="source.jpg", filename="source.jpg",
file_path="uploads/source.jpg", file_path="uploads/source.jpg",
) )
async with transcriptions._session_scope() as session: async with transcriptions._session_scope() as session:
session.add(source) session.add(source)
await session.flush()
session.add(JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING))
await session.commit() await session.commit()
await session.refresh(source) await session.refresh(source)
@@ -79,7 +87,133 @@ class TestTranscriptionServiceRevisionUpsert:
revisions = await transcriptions.list_revisions_by_job(job.id) revisions = await transcriptions.list_revisions_by_job(job.id)
assert first.id == second.id assert first.id == second.id
assert second.text == "Revision v2" assert second.revised_text == "Revision v2"
assert len(revisions) == 1 assert len(revisions) == 1
assert revisions[0].id == first.id assert revisions[0].id == first.id
assert revisions[0].text == "Revision v2" assert revisions[0].revised_text == "Revision v2"
@pytest.mark.asyncio
async def test_delete_source_from_job_context_removes_source_and_single_link(self, default_session_factory, tmp_path):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
transcriptions.settings.upload_dir = tmp_path
document = Document(id=uuid4(), name="delete-source-success")
await documents.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.QUEUED)
await jobs.create_job(job=job)
stored_path = tmp_path / "documents" / str(document.id) / "delete.jpg"
stored_path.parent.mkdir(parents=True, exist_ok=True)
stored_path.write_bytes(b"data")
source = Source(
document_id=document.id,
page_number=1,
upload_name="delete.jpg",
filename="delete.jpg",
file_path=str(stored_path),
)
async with transcriptions._session_scope() as session:
session.add(source)
await session.flush()
session.add(JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING))
await session.commit()
await session.refresh(source)
await transcriptions.delete_source_from_job_context(job_id=job.id, source_id=source.id)
with pytest.raises(TranscriptionNotFoundError):
await transcriptions.read_source(source.id)
assert not stored_path.exists()
@pytest.mark.asyncio
async def test_delete_source_from_job_context_blocks_when_other_job_links_exist(self, default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
document = Document(id=uuid4(), name="delete-source-blocked")
await documents.create_document(document=document)
job_one = Job(document_id=document.id, status=JobStatus.QUEUED)
job_two = Job(document_id=document.id, status=JobStatus.QUEUED)
await jobs.create_job(job=job_one)
await jobs.create_job(job=job_two)
source = Source(
document_id=document.id,
page_number=1,
upload_name="shared.jpg",
filename="shared.jpg",
file_path="uploads/shared.jpg",
)
async with transcriptions._session_scope() as session:
session.add(source)
await session.flush()
session.add(JobSource(job_id=job_one.id, source_id=source.id, status=JobSourceStatus.PENDING))
session.add(JobSource(job_id=job_two.id, source_id=source.id, status=JobSourceStatus.PENDING))
await session.commit()
await session.refresh(source)
with pytest.raises(SourceDeleteBlockedError):
await transcriptions.delete_source_from_job_context(job_id=job_one.id, source_id=source.id)
@pytest.mark.asyncio
async def test_delete_unlinked_source_succeeds(self, default_session_factory, tmp_path):
documents = DocumentService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
transcriptions.settings.upload_dir = tmp_path
document = Document(id=uuid4(), name="delete-unlinked-source")
await documents.create_document(document=document)
stored_path = tmp_path / "documents" / str(document.id) / "orphan.jpg"
stored_path.parent.mkdir(parents=True, exist_ok=True)
stored_path.write_bytes(b"data")
source = Source(
document_id=document.id,
page_number=1,
upload_name="orphan.jpg",
filename="orphan.jpg",
file_path=str(stored_path),
)
await transcriptions.create_source(source=source)
await transcriptions.delete_unlinked_source(source_id=source.id)
with pytest.raises(TranscriptionNotFoundError):
await transcriptions.read_source(source.id)
assert not stored_path.exists()
@pytest.mark.asyncio
async def test_delete_unlinked_source_blocks_when_linked(self, default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
document = Document(id=uuid4(), name="delete-unlinked-blocked")
await documents.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.QUEUED)
await jobs.create_job(job=job)
source = Source(
document_id=document.id,
page_number=1,
upload_name="linked.jpg",
filename="linked.jpg",
file_path="uploads/linked.jpg",
)
async with transcriptions._session_scope() as session:
session.add(source)
await session.flush()
session.add(JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING))
await session.commit()
await session.refresh(source)
with pytest.raises(SourceDeleteBlockedError):
await transcriptions.delete_unlinked_source(source_id=source.id)
+213
View File
@@ -0,0 +1,213 @@
from uuid import uuid4
import pytest
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import Person
from transcription.db.models import Source
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService
from transcription.services.transcription import SourceDeleteBlockedError
from transcription.services.transcription import TranscriptionService
@pytest.mark.asyncio
async def test_document_service_handles_person_and_document_person_crud(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="person-doc"))
person = await documents.create_person(Person(full_name="Ada Lovelace"))
link = await documents.create_document_person(
DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR)
)
fetched = await documents.read_document_person(link.id)
assert fetched.id == link.id
assert fetched.role == DocumentPersonRole.AUTHOR
updated_link = await documents.update_document_person(
DocumentPerson(id=link.id, document_id=document.id, person_id=person.id, role=DocumentPersonRole.RECIPIENT)
)
assert updated_link.role == DocumentPersonRole.RECIPIENT
listed = await documents.list_document_people(document_id=document.id)
assert len(listed) == 1
people = await documents.list_people()
assert len(people) == 1
await documents.delete_document_person(updated_link)
assert len(await documents.list_document_people(document_id=document.id)) == 0
@pytest.mark.asyncio
async def test_transcription_service_manages_source_crud(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="source-doc"))
source = await transcriptions.create_source(
Source(
document_id=document.id,
page_number=1,
upload_name="page-1.jpg",
filename="page-1.jpg",
file_path="uploads/page-1.jpg",
)
)
fetched = await transcriptions.read_source(source.id)
assert fetched.id == source.id
source.page_number = 2
updated = await transcriptions.update_source(source)
assert updated.page_number == 2
listed = await transcriptions.list_sources(document_id=document.id)
assert len(listed) == 1
filtered = await transcriptions.query_sources(document_id=document.id, page_number=2)
assert len(filtered) == 1
await transcriptions.delete_source(updated)
assert len(await transcriptions.list_sources(document_id=document.id)) == 0
@pytest.mark.asyncio
async def test_transcription_service_job_source_crud_uses_caller_session(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
async with transcriptions._session_scope() as session:
document = Document(id=uuid4(), name="job-source-doc")
session.add(document)
await session.flush()
job = Job(document_id=document.id)
session.add(job)
await session.flush()
source = Source(
document_id=document.id,
page_number=1,
upload_name="job-source.jpg",
filename="job-source.jpg",
file_path="uploads/job-source.jpg",
)
session.add(source)
await session.flush()
job_source = await transcriptions.create_job_source(
JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING),
session=session,
)
assert job_source.status == JobSourceStatus.PENDING
job_source.status = JobSourceStatus.TRANSCRIBED
updated = await transcriptions.update_job_source(job_source, session=session)
assert updated.status == JobSourceStatus.TRANSCRIBED
fetched = await transcriptions.read_job_source(job_source.id, session=session)
assert fetched.id == job_source.id
listed = await transcriptions.list_job_sources(job_id=job.id, session=session)
assert len(listed) == 1
await transcriptions.delete_job_source(updated, session=session)
await session.commit()
assert len(await transcriptions.list_job_sources(job_id=job.id)) == 0
@pytest.mark.asyncio
async def test_document_detail_loads_linked_person_relationship(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="detail-person-doc"))
person = await documents.create_person(Person(full_name="Grace Hopper"))
await documents.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
detail = await documents.read_document_detail(document.id)
assert len(detail.document_people) == 1
link = detail.document_people[0]
assert link.person is not None
assert link.person.full_name == "Grace Hopper"
assert link.role == DocumentPersonRole.AUTHOR
@pytest.mark.asyncio
async def test_document_delete_is_blocked_with_source_and_job_dependencies(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="blocked-by-deps"))
job = await jobs.create_job(Job(document_id=document.id))
source = await transcriptions.create_source(
Source(
document_id=document.id,
page_number=1,
upload_name="blocked.jpg",
filename="blocked.jpg",
file_path="uploads/blocked.jpg",
)
)
await transcriptions.create_job_source(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
with pytest.raises(DocumentDeleteBlockedError) as exc_info:
await documents.delete_document(document)
message = exc_info.value.message
assert "Sources" in message
assert "Jobs" in message
@pytest.mark.asyncio
async def test_source_delete_blocks_when_linked_to_multiple_jobs(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="multi-job-source-doc"))
job_one = await jobs.create_job(Job(document_id=document.id))
job_two = await jobs.create_job(Job(document_id=document.id))
source = await transcriptions.create_source(
Source(
document_id=document.id,
page_number=1,
upload_name="shared-page.jpg",
filename="shared-page.jpg",
file_path="uploads/shared-page.jpg",
)
)
await transcriptions.create_job_source(
JobSource(job_id=job_one.id, source_id=source.id, status=JobSourceStatus.PENDING)
)
await transcriptions.create_job_source(
JobSource(job_id=job_two.id, source_id=source.id, status=JobSourceStatus.PENDING)
)
with pytest.raises(SourceDeleteBlockedError):
await transcriptions.delete_source_from_job_context(job_id=job_one.id, source_id=source.id)
+2 -2
View File
@@ -27,7 +27,7 @@ class TestAppLifespan:
"""Startup initializes logging, schema, directories, and worker resources.""" """Startup initializes logging, schema, directories, and worker resources."""
calls = [] calls = []
monkeypatch.setattr("transcription.app.configure_logging", lambda: calls.append("logging")) monkeypatch.setattr("transcription.app.configure_logging", lambda _settings: calls.append("logging"))
async def _create_all(**_kwargs): async def _create_all(**_kwargs):
calls.append("schema") calls.append("schema")
@@ -80,7 +80,7 @@ class TestAppLifespan:
"""Shutdown signals and stops worker resources cleanly.""" """Shutdown signals and stops worker resources cleanly."""
calls = [] calls = []
monkeypatch.setattr("transcription.app.configure_logging", lambda: calls.append("logging")) monkeypatch.setattr("transcription.app.configure_logging", lambda _settings: calls.append("logging"))
async def _create_all(**_kwargs): async def _create_all(**_kwargs):
calls.append("schema") calls.append("schema")
+33 -3
View File
@@ -7,6 +7,7 @@ from pydantic import ValidationError
from transcription.config import Provider from transcription.config import Provider
from transcription.config import Settings from transcription.config import Settings
from transcription.config import parse_cli_settings
def _make_settings(**overrides) -> Settings: def _make_settings(**overrides) -> Settings:
@@ -31,6 +32,30 @@ class TestSettingsLoading:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
Settings(_env_file=None) Settings(_env_file=None)
def test_ignores_process_cli_arguments(self, monkeypatch):
"""Ordinary settings construction does not consume tooling arguments."""
monkeypatch.setattr("sys.argv", ["pytest", "--rootdir=/tmp/project"])
settings = _make_settings()
assert settings.port == 8000
def test_explicit_cli_parser_reads_arguments(self):
"""The executable settings boundary accepts application CLI flags."""
settings = parse_cli_settings(
[
"--openrouter-api-key",
"test-key",
"--port",
"8123",
"--reload",
]
)
assert settings.openrouter_api_key == "test-key"
assert settings.port == 8123
assert settings.reload is True
class TestProviderSettings: class TestProviderSettings:
"""Verify provider enum defaults and validation.""" """Verify provider enum defaults and validation."""
@@ -46,13 +71,18 @@ class TestProviderSettings:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
_make_settings(provider="not-a-provider") _make_settings(provider="not-a-provider")
def test_optional_fields_default_to_none(self): def test_optional_provider_header_fields_default_to_none(self):
"""provider_model, openrouter_http_referer, and openrouter_app_title are None when unset.""" """openrouter_http_referer and openrouter_app_title are None when unset."""
settings = _make_settings() settings = _make_settings()
assert settings.provider_model is None
assert settings.openrouter_http_referer is None assert settings.openrouter_http_referer is None
assert settings.openrouter_app_title is None assert settings.openrouter_app_title is None
def test_provider_model_accepts_env_default(self, monkeypatch):
"""provider_model is sourced when provided through environment configuration."""
monkeypatch.setenv("PROVIDER_MODEL", "google/gemini-2.5-flash")
settings = Settings(openrouter_api_key="test-key-abc123")
assert settings.provider_model == "google/gemini-2.5-flash"
class TestPathSettings: class TestPathSettings:
"""Verify filesystem path field types.""" """Verify filesystem path field types."""
+5 -2
View File
@@ -1,4 +1,4 @@
"""Tests for transcription.db runtime and schema bootstrap behavior.""" """Tests for the database runtime and V2 schema bootstrap behavior."""
import pytest import pytest
from sqlalchemy import inspect from sqlalchemy import inspect
@@ -26,9 +26,12 @@ async def test_create_all_creates_expected_tables(tmp_path):
table_names = set(await conn.run_sync(lambda c: inspect(c).get_table_names())) table_names = set(await conn.run_sync(lambda c: inspect(c).get_table_names()))
assert "document" in table_names assert "document" in table_names
assert "person" in table_names
assert "document_person" in table_names
assert "job" in table_names assert "job" in table_names
assert "source" in table_names assert "source" in table_names
assert "revision" in table_names assert "job_source" in table_names
assert "revision" not in table_names
finally: finally:
await dispose_database_runtime() await dispose_database_runtime()
+58
View File
@@ -0,0 +1,58 @@
"""Tests for the executable application entry point."""
from types import SimpleNamespace
import pytest
from transcription import __main__ as entrypoint
@pytest.mark.unit
def test_main_passes_constructed_app_to_uvicorn(monkeypatch):
"""Non-reload execution keeps the parsed settings instance in the app."""
settings = SimpleNamespace(host="127.0.0.1", port=8123, log_level="debug", reload=False)
application = object()
captured = {}
def create_app(*, settings: object) -> object:
assert settings is expected_settings
return application
expected_settings = settings
monkeypatch.setattr(entrypoint, "parse_cli_settings", lambda: settings)
monkeypatch.setattr(entrypoint, "create_app", create_app)
monkeypatch.setattr(
entrypoint.uvicorn,
"run",
lambda app, **kwargs: captured.update(application=app, **kwargs),
)
entrypoint.main()
assert captured == {
"application": application,
"factory": False,
"host": "127.0.0.1",
"port": 8123,
"log_level": "debug",
"reload": False,
}
@pytest.mark.unit
def test_main_uses_cli_factory_for_reload(monkeypatch):
"""Reload execution gives Uvicorn an importable CLI-aware factory."""
settings = SimpleNamespace(host="127.0.0.1", port=8123, log_level="info", reload=True)
captured = {}
monkeypatch.setattr(entrypoint, "parse_cli_settings", lambda: settings)
monkeypatch.setattr(
entrypoint.uvicorn,
"run",
lambda app, **kwargs: captured.update(application=app, **kwargs),
)
entrypoint.main()
assert captured["application"] == "transcription.__main__:create_cli_app"
assert captured["factory"] is True
assert captured["reload"] is True
+88 -48
View File
@@ -1,19 +1,29 @@
"""Tests for transcription.models — Document, Source, Job, Revision persistence and relationships.""" """Tests for the V2 SQLModel persistence layer and relationships."""
from datetime import UTC
from datetime import datetime
from uuid import UUID from uuid import UUID
import pytest import pytest
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from transcription.db.models import Document from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
from transcription.db.models import Revision from transcription.db.models import Person
from transcription.db.models import Source from transcription.db.models import Source
def _make_document(**overrides) -> Document: def _make_document(**overrides) -> Document:
defaults = {"name": "letter bundle"} defaults = {
"name": "letter bundle",
"document_type": "letter",
"notes": "Family correspondence",
}
defaults.update(overrides) defaults.update(overrides)
return Document(**defaults) return Document(**defaults)
@@ -26,6 +36,16 @@ def _persist_document(session) -> Document:
return document return document
def _persist_person(session, **overrides) -> Person:
defaults = {"full_name": "Ada Lovelace"}
defaults.update(overrides)
person = Person(**defaults)
session.add(person)
session.commit()
session.refresh(person)
return person
def _persist_job(session, document: Document) -> Job: def _persist_job(session, document: Document) -> Job:
job = Job(document_id=document.id) job = Job(document_id=document.id)
session.add(job) session.add(job)
@@ -34,13 +54,14 @@ def _persist_job(session, document: Document) -> Job:
return job return job
def _persist_source(session, document: Document, job: Job, **overrides) -> Source: def _persist_source(session, document: Document, *, page_number: int = 1, **overrides) -> Source:
defaults = { defaults = {
"document_id": document.id, "document_id": document.id,
"job_id": job.id, "page_number": page_number,
"upload_name": "letter.jpg", "upload_name": "letter.jpg",
"filename": "stored-letter.jpg", "filename": "stored-letter.jpg",
"file_path": "/uploads/stored-letter.jpg", "file_path": "/uploads/stored-letter.jpg",
"raw_transcription": "Original machine text",
} }
defaults.update(overrides) defaults.update(overrides)
source = Source(**defaults) source = Source(**defaults)
@@ -50,6 +71,20 @@ def _persist_source(session, document: Document, job: Job, **overrides) -> Sourc
return source return source
def _persist_job_source(session, job: Job, source: Source, **overrides) -> JobSource:
defaults = {
"job_id": job.id,
"source_id": source.id,
"status": JobSourceStatus.PENDING,
}
defaults.update(overrides)
job_source = JobSource(**defaults)
session.add(job_source)
session.commit()
session.refresh(job_source)
return job_source
class TestDocumentModel: class TestDocumentModel:
def test_can_be_persisted(self, session): def test_can_be_persisted(self, session):
document = _persist_document(session) document = _persist_document(session)
@@ -60,6 +95,8 @@ class TestDocumentModel:
def test_defaults_are_populated(self, session): def test_defaults_are_populated(self, session):
document = _persist_document(session) document = _persist_document(session)
assert isinstance(document.id, UUID) assert isinstance(document.id, UUID)
assert document.created_at is not None
assert document.updated_at is not None
class TestJobModel: class TestJobModel:
@@ -78,7 +115,7 @@ class TestJobModel:
assert job.date_created is not None assert job.date_created is not None
assert job.date_updated is not None assert job.date_updated is not None
def test_transitions_to_transcribed(self, session): def test_transitions_to_completed(self, session):
document = _persist_document(session) document = _persist_document(session)
job = _persist_job(session, document) job = _persist_job(session, document)
@@ -87,79 +124,82 @@ class TestJobModel:
session.commit() session.commit()
session.refresh(job) session.refresh(job)
job.status = JobStatus.TRANSCRIBED job.status = JobStatus.COMPLETED
session.add(job) session.add(job)
session.commit() session.commit()
session.refresh(job) session.refresh(job)
assert job.status == JobStatus.TRANSCRIBED assert job.status == JobStatus.COMPLETED
class TestSourceModel: class TestSourceModel:
def test_can_be_created_for_document_and_job(self, session): def test_can_be_created_for_document(self, session):
document = _persist_document(session) document = _persist_document(session)
job = _persist_job(session, document) source = _persist_source(session, document)
source = _persist_source(session, document, job)
fetched = session.get(Source, source.id) fetched = session.get(Source, source.id)
assert fetched is not None assert fetched is not None
assert fetched.document_id == document.id assert fetched.document_id == document.id
assert fetched.job_id == job.id assert fetched.page_number == 1
assert fetched.date_uploaded is not None assert fetched.date_uploaded is not None
def test_revised_text_is_supported(self, session):
class TestRevisionModel:
def test_revision_persists_for_source(self, session):
document = _persist_document(session) document = _persist_document(session)
job = _persist_job(session, document) source = _persist_source(session, document, revised_text="Edited output")
source = _persist_source(session, document, job)
revision = Revision(source_id=source.id, text="Edited revision text") fetched = session.get(Source, source.id)
session.add(revision)
session.commit()
session.refresh(revision)
fetched = session.get(Revision, revision.id)
assert fetched is not None assert fetched is not None
assert fetched.text == "Edited revision text" assert fetched.revised_text == "Edited output"
assert fetched.date_created is not None
def test_source_id_is_unique(self, session):
class TestPersonAndDocumentPersonModel:
def test_document_person_role_is_unique_per_document_person(self, session):
document = _persist_document(session) document = _persist_document(session)
job = _persist_job(session, document) person = _persist_person(session)
source = _persist_source(session, document, job)
first = Revision(source_id=source.id, text="First") first = DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR)
session.add(first) session.add(first)
session.commit() session.commit()
duplicate = Revision(source_id=source.id, text="Duplicate") duplicate = DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR)
session.add(duplicate) session.add(duplicate)
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
session.commit() session.commit()
class TestRelationships: class TestJobSourceModel:
def test_document_exposes_jobs_and_sources(self, session): def test_job_source_persists_json_payloads(self, session):
document = _persist_document(session) document = _persist_document(session)
job = _persist_job(session, document) job = _persist_job(session, document)
_persist_source(session, document, job) source = _persist_source(session, document)
job_source = _persist_job_source(
session,
job,
source,
raw_transcription="Page transcript",
ai_metadata={"confidence": 0.91, "boxes": [{"x": 1, "y": 2}]},
raw_api_response={"provider": "test"},
)
fetched = session.get(JobSource, job_source.id)
assert fetched is not None
assert fetched.status == JobSourceStatus.PENDING
assert fetched.ai_metadata == {"confidence": 0.91, "boxes": [{"x": 1, "y": 2}]}
assert fetched.raw_api_response == {"provider": "test"}
class TestRelationships:
def test_document_exposes_jobs_sources_and_people(self, session):
document = _persist_document(session)
_persist_job(session, document)
_persist_source(session, document)
person = _persist_person(session)
link = DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR)
session.add(link)
session.commit()
session.refresh(document) session.refresh(document)
assert len(document.jobs) == 1 assert len(document.jobs) == 1
assert len(document.sources) == 1 assert len(document.sources) == 1
assert len(document.document_people) == 1
def test_source_exposes_optional_single_revision(self, session):
document = _persist_document(session)
job = _persist_job(session, document)
source = _persist_source(session, document, job)
assert source.revision is None
revision = Revision(source_id=source.id, text="Edited")
session.add(revision)
session.commit()
session.refresh(source)
assert source.revision is not None
assert source.revision.text == "Edited"
+38
View File
@@ -0,0 +1,38 @@
"""Tests for global UI theme registration."""
import re
import pytest
from fastapi import FastAPI
from transcription.ui import register_pages
from transcription.ui.resources import read_css
@pytest.mark.unit
def test_page_registration_uses_vibescribe_theme(monkeypatch):
"""Global UI registration loads the standalone VibeScribe theme in light mode."""
registered_css: list[str] = []
run_options: dict[str, object] = {}
monkeypatch.setattr("transcription.ui.ui.add_css", lambda css, **_kwargs: registered_css.append(css))
monkeypatch.setattr("transcription.ui.register_upload_page", lambda: None)
monkeypatch.setattr("transcription.ui.register_jobs_page", lambda: None)
monkeypatch.setattr(
"transcription.ui.ui.run_with",
lambda _app, **options: run_options.update(options),
)
register_pages(FastAPI())
theme_css = read_css("theme.css")
assert registered_css == [theme_css]
assert set(re.findall(r"#[0-9a-fA-F]{6}", theme_css)) == {
"#1c2321",
"#7d98a1",
"#5e6572",
"#a9b4c2",
"#eef1ef",
}
assert "--q-primary" in theme_css
assert run_options["dark"] is False
+29
View File
@@ -0,0 +1,29 @@
import asyncio
import logging
import pytest
from transcription.worker import run_worker_loop
@pytest.mark.asyncio
async def test_run_worker_loop_survives_process_next_exception(monkeypatch, caplog):
calls = 0
stop_event = asyncio.Event()
async def _fake_process_next_queued_job(*, session=None, session_factory=None):
nonlocal calls
_ = (session, session_factory)
calls += 1
if calls == 1:
raise RuntimeError("boom")
stop_event.set()
return False
monkeypatch.setattr("transcription.worker.process_next_queued_job", _fake_process_next_queued_job)
with caplog.at_level(logging.ERROR):
await run_worker_loop(stop_event=stop_event, poll_interval_seconds=0)
assert calls == 2
assert "Worker loop exception" in caplog.text
+20 -8
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from collections.abc import Generator from collections.abc import Generator
from datetime import UTC
from datetime import datetime
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
@@ -20,9 +22,12 @@ from transcription.db import create_all
from transcription.db import initialize_database_runtime from transcription.db import initialize_database_runtime
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 DocumentPerson
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
from transcription.db.models import Revision from transcription.db.models import Person
from transcription.db.models import Source from transcription.db.models import Source
RevisionSeed = str RevisionSeed = str
@@ -55,10 +60,12 @@ def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
async def _clear() -> None: async def _clear() -> None:
async with session_scope() as session: async with session_scope() as session:
await session.exec(delete(Revision)) await session.exec(delete(JobSource))
await session.exec(delete(DocumentPerson))
await session.exec(delete(Source)) await session.exec(delete(Source))
await session.exec(delete(Job)) await session.exec(delete(Job))
await session.exec(delete(Document)) await session.exec(delete(Document))
await session.exec(delete(Person))
await session.commit() await session.commit()
asyncio.run(_clear()) asyncio.run(_clear())
@@ -94,8 +101,6 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
document_id=document.id, document_id=document.id,
status=status, status=status,
retry_count=0, retry_count=0,
text=transcription_text,
error_detail=error_detail,
provider="openrouter", provider="openrouter",
model="google/gemini-2.5-flash", model="google/gemini-2.5-flash",
prompt_name="transcribe_document.md", prompt_name="transcribe_document.md",
@@ -105,7 +110,6 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
source = Source( source = Source(
document_id=document.id, document_id=document.id,
job_id=job.id,
upload_name=filename, upload_name=filename,
filename=filename, filename=filename,
file_path=str(stored_path), file_path=str(stored_path),
@@ -113,14 +117,22 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
session.add(source) session.add(source)
await session.flush() await session.flush()
if revision_text is not None: if transcription_text is not None or error_detail is not None:
session.add( session.add(
Revision( JobSource(
job_id=job.id,
source_id=source.id, source_id=source.id,
text=revision_text, status=JobSourceStatus.TRANSCRIBED if transcription_text is not None else JobSourceStatus.FAILED,
raw_transcription=transcription_text,
error_detail=error_detail,
) )
) )
if revision_text is not None:
source.revised_text = revision_text
source.date_revised = datetime.now(UTC)
session.add(source)
await session.commit() await session.commit()
return job.id return job.id
+366
View File
@@ -0,0 +1,366 @@
"""Tests for the documents page routes."""
import asyncio
from datetime import UTC
from datetime import date
from datetime import datetime
from uuid import uuid4
import pytest
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Job
from transcription.db.models import Person
from transcription.db.models import Source
@pytest.mark.integration
class TestDocumentsPageRendering:
"""Verify document list/detail routes render expected read states."""
def test_documents_page_renders_empty_state(self, app_client):
"""GET /ui/documents renders empty-state text when no records exist."""
_, client = app_client
response = client.get("/ui/documents")
assert response.status_code == 200
assert "Documents" in response.text
assert "Create new document" in response.text
assert "No documents in repository yet." in response.text
def test_document_create_page_renders_fields(self, app_client):
"""GET /ui/documents/new renders document-create form fields."""
_, client = app_client
response = client.get("/ui/documents/new")
assert response.status_code == 200
assert "Create Document" in response.text
assert "Document name is required." in response.text
assert "Document name" in response.text
assert "Document type" in response.text
assert "Author (Person)" in response.text
assert "Exact date (YYYY-MM-DD)" in response.text
assert "Approximate date" in response.text
assert "Document location" in response.text
assert "Archive identifier" in response.text
assert "Notes" in response.text
assert "Create new item" in response.text
assert "Create new person" in response.text
assert "Save document" in response.text
def test_documents_page_lists_seeded_documents(self, app_client):
"""GET /ui/documents lists seeded document cards."""
_, client = app_client
async def _seed_document() -> None:
async with session_scope() as session:
session.add(Document(name="Seeded Document", document_type="letter"))
await session.commit()
asyncio.run(_seed_document())
response = client.get("/ui/documents")
assert response.status_code == 200
assert "Seeded Document" in response.text
assert "letter" in response.text
def test_document_detail_page_renders_metadata_and_empty_related_sections(self, app_client):
"""GET /ui/documents/{document_id} shows metadata and related empty states."""
_, client = app_client
async def _seed_document() -> str:
async with session_scope() as session:
document = Document(
name="Zenna Letter",
document_type="letter",
document_date=date(1885, 7, 13),
document_date_raw="c. 1885",
location_created="Ohio",
notes="Family archive",
archive_identifier="BOX-1-FOLDER-2",
)
session.add(document)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/documents/{document_id}")
assert response.status_code == 200
assert "Zenna Letter" in response.text
assert "Type: letter" in response.text
assert "Author:" in response.text
assert "Not set" in response.text
assert "Exact Date:" in response.text
assert "1885-07-13" in response.text
assert "Approx. Date:" in response.text
assert "c. 1885" in response.text
assert "Location Created:" in response.text
assert "Ohio" in response.text
assert "Archive Identifier:" in response.text
assert "BOX-1-FOLDER-2" in response.text
assert "Archival Notes:" in response.text
assert "Family archive" in response.text
assert "Created:" in response.text
assert "Updated:" in response.text
assert "No linked people yet." in response.text
assert "0 Source(s) Linked" in response.text
assert "0 Active Jobs" in response.text
assert "+ Add Source" in response.text
assert "+ Add Job" in response.text
assert "Sources" in response.text
assert "Jobs" in response.text
assert "Edit Document" in response.text
assert "Delete" in response.text
def test_document_detail_page_renders_related_people_sources_and_jobs(self, app_client):
"""GET /ui/documents/{document_id} shows related records when present."""
_, client = app_client
async def _seed_related() -> str:
async with session_scope() as session:
document = Document(name="Roster", document_type="record")
person = Person(full_name="Jane Doe")
session.add(document)
session.add(person)
await session.flush()
session.add(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
session.add(
Source(
document_id=document.id,
page_number=1,
upload_name="001_page.png",
filename="stored_001_page.png",
file_path="/tmp/stored_001_page.png",
)
)
session.add(
Job(
document_id=document.id,
)
)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_related())
response = client.get(f"/ui/documents/{document_id}")
assert response.status_code == 200
assert "Jane Doe" in response.text
assert "author" in response.text
assert "Author:" in response.text
assert "1 Source(s) Linked" in response.text
assert "1 Active Jobs" in response.text
def test_document_jobs_page_filters_to_document_context(self, app_client):
_, client = app_client
async def _seed() -> str:
async with session_scope() as session:
target = Document(name="Target", document_type="letter")
other = Document(name="Other", document_type="record")
session.add(target)
session.add(other)
await session.flush()
session.add(Job(document_id=target.id))
session.add(Job(document_id=other.id))
await session.commit()
await session.refresh(target)
return str(target.id)
document_id = asyncio.run(_seed())
response = client.get(f"/ui/documents/{document_id}/jobs")
assert response.status_code == 200
assert "Jobs for Target" in response.text
assert "Jobs for Other" not in response.text
def test_document_sources_page_filters_to_document_context(self, app_client):
_, client = app_client
async def _seed() -> str:
async with session_scope() as session:
target = Document(name="Target", document_type="letter")
other = Document(name="Other", document_type="record")
session.add(target)
session.add(other)
await session.flush()
session.add(
Source(
document_id=target.id,
page_number=1,
upload_name="target_page.png",
filename="target_stored.png",
file_path="/tmp/target_stored.png",
)
)
session.add(
Source(
document_id=other.id,
page_number=1,
upload_name="other_page.png",
filename="other_stored.png",
file_path="/tmp/other_stored.png",
)
)
await session.commit()
await session.refresh(target)
return str(target.id)
document_id = asyncio.run(_seed())
response = client.get(f"/ui/sources?document_id={document_id}")
assert response.status_code == 200
assert "Sources: Target" in response.text
assert "Back to Document" in response.text
assert "target_page.png" in response.text
assert "other_page.png" not in response.text
def test_document_detail_page_rejects_invalid_id(self, app_client):
"""GET /ui/documents/{document_id} shows validation feedback for malformed IDs."""
_, client = app_client
response = client.get("/ui/documents/not-a-uuid")
assert response.status_code == 200
assert "Invalid document id" in response.text
def test_document_detail_page_handles_missing_document(self, app_client):
"""GET /ui/documents/{document_id} shows not-found state for unknown IDs."""
_, client = app_client
response = client.get(f"/ui/documents/{uuid4()}")
assert response.status_code == 200
assert "Document not found" in response.text
def test_document_edit_page_renders_expected_fields(self, app_client):
"""GET /ui/documents/{document_id}/edit renders editable fields and save controls."""
_, client = app_client
async def _seed_document() -> str:
async with session_scope() as session:
document = Document(
name="Editable Document",
document_type="memo",
document_date_raw="c. 1900",
)
session.add(document)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/documents/{document_id}/edit")
assert response.status_code == 200
assert "Edit Document Record" in response.text
assert "Document name and document type are required." in response.text
assert "Document name" in response.text
assert "Document type" in response.text
assert "Author (Person)" in response.text
assert "Exact date (YYYY-MM-DD)" in response.text
assert "Approximate date" in response.text
assert "Document location" in response.text
assert "Archive identifier" in response.text
assert "Notes" in response.text
assert "Create new item" in response.text
assert "Create new person" in response.text
assert "Save changes" in response.text
def test_document_delete_page_shows_confirmation_when_unlinked(self, app_client):
"""GET /ui/documents/{document_id}/delete renders permanent-action confirmation if unlinked."""
_, client = app_client
async def _seed_document() -> str:
async with session_scope() as session:
document = Document(name="Safe Delete", document_type="letter")
session.add(document)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/documents/{document_id}/delete")
assert response.status_code == 200
assert "Delete Document" in response.text
assert "This action permanently deletes the document." in response.text
assert "Delete document permanently" in response.text
def test_document_delete_page_shows_blocked_state_when_dependencies_exist(self, app_client):
"""GET /ui/documents/{document_id}/delete explains blocked deletion with dependency categories."""
_, client = app_client
async def _seed_related() -> str:
async with session_scope() as session:
document = Document(name="Blocked Delete", document_type="record")
session.add(document)
await session.flush()
session.add(
Source(
document_id=document.id,
page_number=1,
upload_name="001_page.png",
filename="stored_001_page.png",
file_path="/tmp/stored_001_page.png",
)
)
session.add(Job(document_id=document.id))
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_related())
response = client.get(f"/ui/documents/{document_id}/delete")
assert response.status_code == 200
assert "Delete is blocked because related records exist." in response.text
assert "Dependencies present: Sources, Jobs" in response.text
assert "Go to Jobs" in response.text
def test_job_create_page_preselects_document_query_param(self, app_client):
"""GET /ui/jobs/new?document_id=... includes the selected document in rendered state."""
_, client = app_client
async def _seed_document() -> str:
async with session_scope() as session:
document = Document(
name="Preselected Document",
document_type="letter",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(document)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/jobs/new?document_id={document_id}")
assert response.status_code == 200
assert "Preselected Document" in response.text
+91 -35
View File
@@ -1,10 +1,12 @@
"""Tests for the jobs page route.""" """Tests for the jobs page route."""
from pathlib import Path import asyncio
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
@@ -18,7 +20,39 @@ class TestPageRendering:
response = client.get("/ui/jobs") response = client.get("/ui/jobs")
assert response.status_code == 200 assert response.status_code == 200
assert "No jobs yet." in response.text assert "Create job" in response.text
assert "No active or historical processing jobs found." in response.text
def test_job_create_page_requires_existing_documents(self, app_client):
"""GET /ui/jobs/new shows guidance when no Documents exist."""
_, client = app_client
response = client.get("/ui/jobs/new")
assert response.status_code == 200
assert "Create Processing Job" in response.text
assert "No documents available. Create a Document before creating a Job." in response.text
assert "Create document" in response.text
def test_job_create_page_lists_available_documents(self, app_client):
"""GET /ui/jobs/new renders document choices when Documents exist."""
_, client = app_client
async def _seed_document() -> None:
async with session_scope() as session:
session.add(Document(name="Seeded Document"))
await session.commit()
asyncio.run(_seed_document())
response = client.get("/ui/jobs/new")
assert response.status_code == 200
assert "Create Processing Job" in response.text
assert "Seeded Document" in response.text
assert "Files are processed alphabetically by original filename." in response.text
assert "No files uploaded yet." in response.text
assert "Select source files or a folder" in response.text
def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job): def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
"""GET /ui/jobs lists seeded jobs from the in-memory database.""" """GET /ui/jobs lists seeded jobs from the in-memory database."""
@@ -31,29 +65,28 @@ class TestPageRendering:
assert "sample.pdf" in response.text assert "sample.pdf" in response.text
assert "transcribed" in response.text assert "transcribed" in response.text
def test_job_detail_page_renders_seeded_job(self, app_client, seed_job): def test_job_detail_page_renders_document_links(self, app_client, seed_job):
"""GET /ui/jobs/{job_id} renders detail content for a real seeded job.""" """GET /ui/jobs/{job_id} renders document-scoped navigation links."""
_, client = app_client _, client = app_client
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "single_page_pdf.pdf"
job_id = seed_job( job_id = seed_job(
filename="detail.pdf", filename="detail.pdf",
status=JobStatus.TRANSCRIBED, status=JobStatus.TRANSCRIBED,
transcription_text="original text", transcription_text="original text",
revision_text="hello",
source_file=fixture_path,
) )
response = client.get(f"/ui/jobs/{job_id}") response = client.get(f"/ui/jobs/{job_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Original Transcription" in response.text assert "Job" in response.text
assert "detail.pdf" in response.text assert "Provider:" in response.text
assert "Revision" in response.text assert "Model:" in response.text
assert "Revision" in response.text assert "Prompt:" in response.text
assert "hello" in response.text assert "Retry Count:" in response.text
assert "original text" in response.text assert "Last Updated:" in response.text
assert "Document preview" in response.text assert "document links" in response.text.lower()
assert "/uploads/detail.pdf" in response.text assert "Sources" in response.text
assert "Jobs" in response.text
assert "Delete Job" in response.text
def test_job_detail_page_rejects_invalid_id(self, app_client): def test_job_detail_page_rejects_invalid_id(self, app_client):
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs.""" """GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
@@ -72,36 +105,59 @@ class TestPageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Job not found" in response.text assert "Job not found" in response.text
def test_job_detail_page_shows_revision_editor_when_none_exists(self, app_client, seed_job): def test_job_detail_page_shows_cancel_and_resubmit_when_queued(self, app_client, seed_job):
"""GET /ui/jobs/{job_id} renders revision editor and create action for sources with no revision.""" """GET /ui/jobs/{job_id} exposes cancel/resubmit controls for queued jobs."""
_, client = app_client _, client = app_client
job_id = seed_job( job_id = seed_job(
filename="no-revision.pdf", filename="no-revision.pdf",
status=JobStatus.TRANSCRIBED, status=JobStatus.QUEUED,
transcription_text="original text", transcription_text=None,
revision_text=None,
) )
response = client.get(f"/ui/jobs/{job_id}") response = client.get(f"/ui/jobs/{job_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Revision Editor" in response.text assert "Cancel" in response.text
assert "Create revision" in response.text assert "Resubmit" in response.text
assert "No revision exists for this source." in response.text assert "Delete Job" in response.text
def test_job_detail_page_shows_update_action_for_existing_revision(self, app_client, seed_job): def test_job_cancel_page_renders_confirmation(self, app_client, seed_job):
"""GET /ui/jobs/{job_id} renders revision editor with update action when revision exists."""
_, client = app_client _, client = app_client
job_id = seed_job( job_id = seed_job(filename="cancel-ready.pdf", status=JobStatus.PROCESSING)
filename="with-revision.pdf",
status=JobStatus.TRANSCRIBED,
transcription_text="original text",
revision_text="hello",
)
response = client.get(f"/ui/jobs/{job_id}") response = client.get(f"/ui/jobs/{job_id}/cancel")
assert response.status_code == 200 assert response.status_code == 200
assert "Revision Editor" in response.text assert "Cancel Processing Job" in response.text
assert "Update revision" in response.text assert "Cancel job" in response.text
assert "hello" in response.text
def test_job_resubmit_page_renders_confirmation(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(filename="resubmit-ready.pdf", status=JobStatus.FAILED, transcription_text=None)
response = client.get(f"/ui/jobs/{job_id}/resubmit")
assert response.status_code == 200
assert "Resubmit Job" in response.text
assert "Resubmit now" in response.text
def test_job_delete_page_shows_confirmation_when_not_processing(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(filename="delete-ready.pdf", status=JobStatus.TRANSCRIBED)
response = client.get(f"/ui/jobs/{job_id}/delete")
assert response.status_code == 200
assert "Delete job" in response.text
assert "This action permanently deletes the job." in response.text
assert "Delete job permanently" in response.text
def test_job_delete_page_shows_blocked_state_when_processing(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(filename="delete-blocked.pdf", status=JobStatus.PROCESSING)
response = client.get(f"/ui/jobs/{job_id}/delete")
assert response.status_code == 200
assert "Delete is blocked while the job is processing." in response.text
assert "Wait for processing to complete, then retry delete." in response.text
+10 -2
View File
@@ -11,8 +11,16 @@ class TestPageRegistration:
"""Mounted UI routes respond successfully when the full app is created.""" """Mounted UI routes respond successfully when the full app is created."""
_, client = app_client _, client = app_client
upload_response = client.get("/ui/upload") homepage_response = client.get("/ui/homepage")
upload_response = client.get("/ui/upload", follow_redirects=False)
documents_response = client.get("/ui/documents")
people_response = client.get("/ui/people")
sources_response = client.get("/ui/sources")
jobs_response = client.get("/ui/jobs") jobs_response = client.get("/ui/jobs")
assert upload_response.status_code == 200 assert homepage_response.status_code == 200
assert upload_response.status_code == 307
assert documents_response.status_code == 200
assert people_response.status_code == 200
assert sources_response.status_code == 200
assert jobs_response.status_code == 200 assert jobs_response.status_code == 200
+237
View File
@@ -0,0 +1,237 @@
"""Tests for the people page routes."""
import asyncio
from datetime import date
from uuid import uuid4
import pytest
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Person
@pytest.mark.integration
class TestPeoplePageRendering:
"""Verify people routes render expected CRUD read states."""
def test_people_page_renders_empty_state(self, app_client):
_, client = app_client
response = client.get("/ui/people")
assert response.status_code == 200
assert "People" in response.text
assert "Create new person" in response.text
assert "No person records found in repository." in response.text
def test_people_page_lists_seeded_people(self, app_client):
_, client = app_client
async def _seed_person() -> None:
async with session_scope() as session:
session.add(Person(full_name="Ada Lovelace", display_name="Ada"))
await session.commit()
asyncio.run(_seed_person())
response = client.get("/ui/people")
assert response.status_code == 200
assert "Ada Lovelace" in response.text
assert "Ada" in response.text
def test_person_create_page_renders_fields(self, app_client):
_, client = app_client
response = client.get("/ui/people/new")
assert response.status_code == 200
assert "Create Person Record" in response.text
assert "Full name is required." in response.text
assert "Birth date (YYYY-MM-DD)" in response.text
assert "Death date (YYYY-MM-DD)" in response.text
assert "Biography" in response.text
assert "Save person" in response.text
def test_person_detail_page_renders_metadata_and_empty_links(self, app_client):
_, client = app_client
async def _seed_person() -> str:
async with session_scope() as session:
person = Person(
full_name="Grace Hopper",
display_name="Grace",
maiden_name="Murray",
birth_date=date(1906, 12, 9),
birth_date_raw="1906",
birth_place="New York",
death_date=date(1992, 1, 1),
death_date_raw="1992",
death_place="Arlington",
biography="Computer pioneer",
portrait_path="/images/grace.jpg",
)
session.add(person)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200
assert "Grace Hopper" in response.text
assert "Full Name:" in response.text
assert "Display Name:" in response.text
assert "Maiden Name:" in response.text
assert "Birth Date:" in response.text
assert "1906-12-09" in response.text
assert "Death Date:" in response.text
assert "1992-01-01" 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 "No linked documents yet." in response.text
assert "Link this person from a Document workflow." in response.text
def test_person_detail_page_resolves_relative_portrait_path_to_uploads_mount(self, app_client):
_, client = app_client
async def _seed_person() -> str:
async with session_scope() as session:
person = Person(
full_name="Portrait Person",
portrait_path="portraits/person/seeded.png",
)
session.add(person)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200
assert "/uploads/portraits/person/seeded.png" in response.text
def test_person_detail_page_renders_linked_documents(self, app_client):
_, client = app_client
async def _seed_links() -> str:
async with session_scope() as session:
person = Person(full_name="Linked Person")
document = Document(name="Linked Document", document_type="letter")
session.add(person)
session.add(document)
await session.flush()
session.add(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_links())
response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200
assert "Linked Document" in response.text
assert "Role: author" in response.text
def test_person_detail_page_handles_invalid_id(self, app_client):
_, client = app_client
response = client.get("/ui/people/not-a-uuid")
assert response.status_code == 200
assert "Invalid person id" in response.text
def test_person_detail_page_handles_missing_person(self, app_client):
_, client = app_client
response = client.get(f"/ui/people/{uuid4()}")
assert response.status_code == 200
assert "Person not found" in response.text
def test_person_edit_page_renders_expected_fields(self, app_client):
_, client = app_client
async def _seed_person() -> str:
async with session_scope() as session:
person = Person(full_name="Editable Person", display_name="EP")
session.add(person)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}/edit")
assert response.status_code == 200
assert "Edit Person Record" in response.text
assert "Full name is required." in response.text
assert "Full name" in response.text
assert "Save changes" in response.text
def test_person_delete_page_shows_confirmation_when_unlinked(self, app_client):
_, client = app_client
async def _seed_person() -> str:
async with session_scope() as session:
person = Person(full_name="Safe Delete")
session.add(person)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}/delete")
assert response.status_code == 200
assert "Delete Person Record" in response.text
assert "This action permanently deletes the person record." in response.text
assert "Delete person permanently" in response.text
def test_person_delete_page_warns_links_will_be_removed_when_linked_documents_exist(self, app_client):
_, client = app_client
async def _seed_links() -> str:
async with session_scope() as session:
person = Person(full_name="Blocked Delete")
document = Document(name="Linked Document", document_type="record")
session.add(person)
session.add(document)
await session.flush()
session.add(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_links())
response = client.get(f"/ui/people/{person_id}/delete")
assert response.status_code == 200
assert "This will also remove 1 linked document relationship(s)." in response.text
assert "Delete person permanently" in response.text
+236
View File
@@ -0,0 +1,236 @@
"""Tests for the sources page routes."""
import asyncio
from pathlib import Path
import pytest
from sqlmodel import select
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import JobStatus
from transcription.db.models import Source
@pytest.mark.integration
class TestSourcesPageRendering:
"""Verify source list and detail routes render expected states."""
def test_sources_page_renders_empty_state(self, app_client):
_, client = app_client
response = client.get("/ui/sources")
assert response.status_code == 200
assert "Sources" in response.text
assert "No source file records found." in response.text
def test_sources_page_lists_seeded_sources(self, app_client):
_, client = app_client
async def _seed() -> None:
async with session_scope() as session:
document = Document(name="Source Document", document_type="letter")
session.add(document)
await session.flush()
session.add(
Source(
document_id=document.id,
page_number=1,
upload_name="page_one.png",
filename="stored_page_one.png",
file_path="/tmp/stored_page_one.png",
)
)
await session.commit()
asyncio.run(_seed())
response = client.get("/ui/sources")
assert response.status_code == 200
assert "page_one.png" in response.text
assert "stored_page_one.png" in response.text
def test_sources_page_filters_to_document_context(self, app_client):
_, client = app_client
async def _seed() -> str:
async with session_scope() as session:
target = Document(name="Target", document_type="letter")
other = Document(name="Other", document_type="record")
session.add(target)
session.add(other)
await session.flush()
session.add(
Source(
document_id=target.id,
page_number=1,
upload_name="target_page.png",
filename="target_stored.png",
file_path="/tmp/target_stored.png",
)
)
session.add(
Source(
document_id=other.id,
page_number=1,
upload_name="other_page.png",
filename="other_stored.png",
file_path="/tmp/other_stored.png",
)
)
await session.commit()
return str(target.id)
document_id = asyncio.run(_seed())
response = client.get(f"/ui/sources?document_id={document_id}")
assert response.status_code == 200
assert "Sources: Target" in response.text
assert "Back to Document" in response.text
assert "target_page.png" in response.text
assert "other_page.png" not in response.text
def test_sources_page_filters_to_job_context(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(filename="job-page.png", transcription_text="job text")
response = client.get(f"/ui/sources?job_id={job_id}")
assert response.status_code == 200
assert "Sources for Job" in response.text
assert "Back to Job" in response.text
assert "job-page.png" in response.text
assert "Job Source Status" in response.text
def test_sources_page_job_context_shows_job_source_status_and_error_detail(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(
filename="job-failed-page.png",
status=JobStatus.FAILED,
transcription_text=None,
error_detail="Provider timed out",
)
response = client.get(f"/ui/sources?job_id={job_id}")
assert response.status_code == 200
assert "job-failed-page.png" in response.text
assert "failed" in response.text.lower()
assert "Provider timed out" in response.text
def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job):
_, client = app_client
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "small_png.png"
job_id = seed_job(
filename="detail-source.png",
transcription_text="original transcription text",
revision_text="human revision text",
source_file=fixture_path,
)
async def _get_source_id() -> str:
async with session_scope() as session:
job = await session.get(Job, job_id)
assert job is not None
source = (
await session.exec(select(Source).where(Source.document_id == job.document_id))
).first()
assert source is not None
return str(source.id)
source_id = asyncio.run(_get_source_id())
response = client.get(f"/ui/sources/{source_id}")
assert response.status_code == 200
assert "Source Page 1: detail-source.png" in response.text
assert "Back to Sources" in response.text
assert "automated raw transcription" in response.text.lower()
assert "original transcription text" in response.text
assert "curated human transcription" in response.text.lower()
assert "human revision text" in response.text
assert "Page Number:" in response.text
assert "Stored Filename:" in response.text
assert "Delete Source" in response.text
def test_source_detail_page_displays_job_source_status_and_error_detail(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(
filename="failed-source.png",
status=JobStatus.FAILED,
transcription_text=None,
error_detail="Provider timed out",
)
async def _get_source_id() -> str:
async with session_scope() as session:
job = await session.get(Job, job_id)
assert job is not None
source = (
await session.exec(select(Source).where(Source.document_id == job.document_id))
).first()
assert source is not None
return str(source.id)
source_id = asyncio.run(_get_source_id())
response = client.get(f"/ui/sources/{source_id}")
assert response.status_code == 200
assert "JOB SOURCE OUTCOMES" in response.text
assert "Status:" in response.text
assert "failed" in response.text.lower()
assert "Error Detail:" in response.text
assert "Provider timed out" in response.text
def test_source_delete_page_blocks_when_source_is_job_linked(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(filename="linked-source.png", transcription_text="linked text")
async def _get_source_id() -> str:
async with session_scope() as session:
job = await session.get(Job, job_id)
assert job is not None
source = (
await session.exec(select(Source).where(Source.document_id == job.document_id))
).first()
assert source is not None
return str(source.id)
source_id = asyncio.run(_get_source_id())
response = client.get(f"/ui/sources/{source_id}/delete")
assert response.status_code == 200
assert "Delete Source Record" in response.text
assert "Delete is only available for unlinked sources." in response.text
def test_source_delete_page_allows_unlinked_source(self, app_client):
_, client = app_client
async def _seed_unlinked_source() -> str:
async with session_scope() as session:
document = Document(name="Unlinked Source Doc", document_type="memo")
session.add(document)
await session.flush()
source = Source(
document_id=document.id,
page_number=1,
upload_name="orphan-source.png",
filename="orphan-source.png",
file_path="/tmp/orphan-source.png",
)
session.add(source)
await session.commit()
return str(source.id)
source_id = asyncio.run(_seed_unlinked_source())
response = client.get(f"/ui/sources/{source_id}/delete")
assert response.status_code == 200
assert "Delete Source Record" in response.text
assert "Delete source permanently" in response.text
assert "Delete is only available for unlinked sources." not in response.text
+27 -11
View File
@@ -13,23 +13,39 @@ class TestPageRendering:
response = client.get("/", follow_redirects=False) response = client.get("/", follow_redirects=False)
assert response.status_code == 307 assert response.status_code == 307
assert response.headers["location"] == "/ui" assert response.headers["location"] == "/ui/homepage"
def test_ui_redirects_to_upload(self, app_client): def test_ui_redirects_to_homepage(self, app_client):
"""GET /ui redirects to the upload page.""" """GET /ui redirects to the homepage."""
_, client = app_client _, client = app_client
response = client.get("/ui", follow_redirects=False) response = client.get("/ui", follow_redirects=False)
assert response.status_code == 307 assert response.status_code == 307
assert response.headers["location"] == "/ui/upload" assert response.headers["location"] == "/ui/homepage"
def test_upload_page_renders_expected_controls(self, app_client): def test_homepage_page_renders(self, app_client):
"""GET /ui/upload returns the page shell and upload controls.""" """GET /ui/homepage renders the homepage page."""
_, client = app_client _, client = app_client
response = client.get("/ui/upload") response = client.get("/ui/homepage")
assert response.status_code == 200 assert response.status_code == 200
assert "Upload Document" in response.text assert "Home" in response.text
assert "Select document file" in response.text assert "Edit Home Page" in response.text
assert "Upload" in response.text assert '/ui/homepage' in response.text
assert "Jobs" in response.text
def test_homepage_edit_page_renders(self, app_client):
"""GET /ui/homepage/edit renders the edit page."""
_, client = app_client
response = client.get("/ui/homepage/edit")
assert response.status_code == 200
assert "Edit Home Page" in response.text
assert "Homepage markdown" in response.text
def test_upload_page_renders_expected_controls(self, app_client):
"""GET /ui/upload redirects to the job-create flow."""
_, client = app_client
response = client.get("/ui/upload", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "/ui/jobs/new"

Some files were not shown because too many files have changed in this diff Show More