generated from john/python-template
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7192a33dc | ||
|
|
5932c0d3a1 | ||
|
|
edb6967888 | ||
|
|
759d4c2434 | ||
|
|
323f12d911 | ||
|
|
f80834d589 | ||
|
|
752346025b | ||
|
|
4f6e1fd913 | ||
|
|
47aef0e26e | ||
|
|
c098013a68 | ||
|
|
49e2e48df1 | ||
|
|
0ab7ad50f2 | ||
|
|
9653060c2a | ||
|
|
ed6f9dfe25 | ||
|
|
5946867ff3 | ||
|
|
646a360aca | ||
|
|
2b3d33e50e | ||
|
|
dfe6f121ff | ||
|
|
51ac2d0b98 | ||
|
|
61cc8a200b | ||
|
|
c46d1bd0bc | ||
|
|
00ed176ac1 | ||
|
|
99a128e981 | ||
|
|
aa94f34de4 | ||
|
|
661e2b1bec | ||
|
|
d75083a666 | ||
|
|
d0a3ca0289 | ||
|
|
209c48987c | ||
|
|
4ed1f43eda |
@@ -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'
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
Vendored
+4
-8
@@ -8,14 +8,10 @@
|
||||
"module": "debugpy",
|
||||
"args": [
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"transcription.app:create_app",
|
||||
"--factory",
|
||||
"--host",
|
||||
// "127.0.0.1",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"8080"
|
||||
"transcription",
|
||||
"--host", "127.0.0.1",
|
||||
"--port", "9999",
|
||||
"--database.driver", "sqlite"
|
||||
],
|
||||
"justMyCode": true,
|
||||
"console": "integratedTerminal",
|
||||
|
||||
@@ -22,18 +22,80 @@ uv sync
|
||||
|
||||
### 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
|
||||
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
|
||||
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
|
||||
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_RETRY_BACKOFF_SECONDS=0
|
||||
WORKER_PROVIDER_TIMEOUT_SECONDS=20
|
||||
@@ -45,13 +107,17 @@ WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
|
||||
### 3) Run the app
|
||||
|
||||
```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
|
||||
|
||||
- GUI: [http://[IP_ADDRESS]:8000/ui](http://[IP_ADDRESS]:8000/ui)
|
||||
- Health check: [http://[IP_ADDRESS]:8000/healthz](http://[IP_ADDRESS]:8000/healthz)
|
||||
- GUI: [http://localhost:9999/ui](http://localhost:9999/ui)
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -59,7 +59,7 @@ Responsibilities:
|
||||
|
||||
### 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`)
|
||||
|
||||
### Infrastructure Layer
|
||||
@@ -121,14 +121,14 @@ Responsibilities:
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v1.md)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Overview](index_v2.md)
|
||||
- [System Design Intent](invariant/intent.md)
|
||||
- [Transcription Methodology](invariant/transcription_methodology.md)
|
||||
- System Architecture (this document)
|
||||
- [System Requirements](requirements_v1.md)
|
||||
- [Data model](schema_v1.md)
|
||||
- [Error Handling Policy](error_handling_v1.md)
|
||||
- [Implementation Plan](implementation_plan_v1.md)
|
||||
- [System Requirements](requirements_v2.md)
|
||||
- [Data model](schema_v2.md)
|
||||
- [Error Handling Policy](error_handling_v2.md)
|
||||
- [Implementation Plan](implementation_plan_v2.md)
|
||||
|
||||
|
||||
|
||||
|
||||
-102
@@ -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);
|
||||
```
|
||||
@@ -79,8 +79,8 @@ HTTP Status Mappings:
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v2.md)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Design Intent](invariant/intent.md)
|
||||
- [Transcription Methodology](invariant/transcription_methodology.md)
|
||||
- [System Architecture](architecture_v2.md)
|
||||
- [System Requirements](requirements_v2.md)
|
||||
- [Data model](schema_v2.md)
|
||||
|
||||
@@ -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`.
|
||||
- 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`.
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
- 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 project’s visual and interaction conventions.
|
||||
|
||||
## Done When
|
||||
|
||||
- A fresh database is created directly from the V2 SQLModel metadata.
|
||||
|
||||
+2
-2
@@ -38,8 +38,8 @@ Read [architecture_v2.md](architecture_v2.md) first for technical overview and s
|
||||
## Documentation Index
|
||||
|
||||
- System Overview (this document)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Design Intent](invariant/intent.md)
|
||||
- [Transcription Methodology](invariant/transcription_methodology.md)
|
||||
- [System Architecture](architecture_v2.md)
|
||||
- [System Requirements](requirements_v2.md)
|
||||
- [Data model](schema_v2.md)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
@@ -31,8 +31,8 @@ This document captures the **Version 2 baseline requirements** for the productio
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v2.md)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Design Intent](invariant/intent.md)
|
||||
- [Transcription Methodology](invariant/transcription_methodology.md)
|
||||
- [System Architecture](architecture_v2.md)
|
||||
- System Requirements (this document)
|
||||
- [Data model](schema_v2.md)
|
||||
|
||||
+2
-2
@@ -126,8 +126,8 @@ erDiagram
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v2.md)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Design Intent](invariant/intent.md)
|
||||
- [Transcription Methodology](invariant/transcription_methodology.md)
|
||||
- [System Architecture](architecture_v2.md)
|
||||
- [System Requirements](requirements_v2.md)
|
||||
- Data model (this document)
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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. |
|
||||
@@ -1,17 +1,24 @@
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .config import LOGGING_CONFIG
|
||||
from .config import get_settings
|
||||
from .app import create_app
|
||||
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:
|
||||
settings = get_settings()
|
||||
settings = parse_cli_settings()
|
||||
|
||||
uvicorn.run(
|
||||
"transcription.app:create_app",
|
||||
"transcription.__main__:create_cli_app",
|
||||
factory=True,
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
log_level=LOGGING_CONFIG.get("root", {}).get("level", "info").lower(),
|
||||
log_level=settings.log_level,
|
||||
reload=settings.reload,
|
||||
)
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ from fastapi import APIRouter
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def healthz() -> dict[str, str]:
|
||||
"""Return a simple health status payload."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/healthz")
|
||||
def healthz_route() -> dict[str, str]:
|
||||
"""Route wrapper for health status payload."""
|
||||
return healthz()
|
||||
|
||||
|
||||
def healthz() -> dict[str, str]:
|
||||
"""Return a simple health status payload."""
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -20,13 +20,11 @@ from .config import Settings
|
||||
from .config import configure_logging
|
||||
from .config import get_settings
|
||||
from .db import create_all
|
||||
from .db import dispose_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.jobs import JobService
|
||||
from .ui import register_pages
|
||||
from .ui.pages import register_pages
|
||||
from .worker import worker_consumer_lifespan
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -34,15 +32,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI):
|
||||
configure_logging()
|
||||
|
||||
settings = getattr(app.state, "settings", None) or get_settings()
|
||||
configure_logging(settings)
|
||||
app.state.settings = settings
|
||||
app.state.services = ServiceBundle()
|
||||
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||
|
||||
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.prompt_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -50,10 +47,7 @@ async def _lifespan(app: FastAPI):
|
||||
await _recover_stale_processing_jobs(app)
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
stack.push_async_callback(
|
||||
dispose_session_factory,
|
||||
database_url=get_database_url(settings),
|
||||
)
|
||||
stack.push_async_callback(dispose_database_runtime)
|
||||
stop_event, worker_notifier = await stack.enter_async_context(
|
||||
worker_consumer_lifespan(
|
||||
session_factory=app.state.runtime.session_factory,
|
||||
@@ -96,13 +90,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
|
||||
@app.get("/ui", include_in_schema=False)
|
||||
async def ui_redirect() -> RedirectResponse:
|
||||
return RedirectResponse(url="/ui/upload", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||
return RedirectResponse(url="/ui/documents", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||
|
||||
@app.get("/healthz")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
app.include_router(health_router)
|
||||
register_error_handlers(app)
|
||||
register_pages(app)
|
||||
app.include_router(health_router)
|
||||
return app
|
||||
|
||||
@@ -6,6 +6,7 @@ are resolved by the provider adapters, not here.
|
||||
"""
|
||||
|
||||
import logging.config
|
||||
from collections.abc import Sequence
|
||||
from enum import StrEnum
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
@@ -51,7 +52,7 @@ class Settings(BaseSettings):
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
cli_parse_args=True,
|
||||
env_nested_delimiter="__",
|
||||
cli_implicit_flags=True,
|
||||
cli_kebab_case=True,
|
||||
)
|
||||
@@ -74,7 +75,6 @@ class Settings(BaseSettings):
|
||||
|
||||
# --- persistence ---
|
||||
database: DatabaseSettings = Field(default_factory=SqliteSettings)
|
||||
database_url: str = "sqlite:///./transcription.db"
|
||||
bootstrap_schema_on_startup: bool = False
|
||||
sqlite_check_same_thread: bool = False
|
||||
|
||||
@@ -93,14 +93,21 @@ class Settings(BaseSettings):
|
||||
@property
|
||||
def should_bootstrap_schema(self) -> bool:
|
||||
"""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.environment in {"development", "test"}
|
||||
|
||||
|
||||
@cache
|
||||
def get_settings(**kwargs) -> Settings:
|
||||
return Settings(**kwargs)
|
||||
def get_settings(**kwargs: Any) -> Settings:
|
||||
"""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] = {
|
||||
|
||||
+170
-50
@@ -1,28 +1,53 @@
|
||||
"""SQLModel domain models for the transcription system.
|
||||
|
||||
Core V1 lifecycle:
|
||||
Document -> one-to-many -> Source
|
||||
Document -> one-to-many -> Job
|
||||
Source -> one-to-one? -> Revision (optional)
|
||||
"""
|
||||
"""SQLModel domain models for the V2 transcription system."""
|
||||
|
||||
from datetime import UTC
|
||||
from datetime import date
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import JSON
|
||||
from sqlalchemy import Column
|
||||
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 Relationship
|
||||
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):
|
||||
QUEUED = "queued"
|
||||
PROCESSING = "processing"
|
||||
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"
|
||||
|
||||
|
||||
@@ -31,32 +56,90 @@ class Document(SQLModel, table=True):
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
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")
|
||||
sources: list["Source"] = Relationship(back_populates="document")
|
||||
jobs: list["Job"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
sources: list["Source"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
document_people: list["DocumentPerson"] = Relationship(
|
||||
back_populates="document", sa_relationship_kwargs={"lazy": "selectin"}
|
||||
)
|
||||
|
||||
@property
|
||||
def authors(self):
|
||||
"""Return linked people whose role is AUTHOR."""
|
||||
return [
|
||||
link.person
|
||||
for link in self.document_people
|
||||
if link.role == DocumentPersonRole.AUTHOR and link.person is not None
|
||||
]
|
||||
|
||||
@property
|
||||
def author(self):
|
||||
"""Return the first linked author for convenience in read paths."""
|
||||
return self.authors[0] if self.authors else None
|
||||
|
||||
|
||||
class Source(SQLModel, table=True):
|
||||
"""A document source (image or PDF)."""
|
||||
class Person(SQLModel, table=True):
|
||||
"""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"}
|
||||
)
|
||||
|
||||
@property
|
||||
def authored_documents(self):
|
||||
"""Return documents where this person is linked as AUTHOR."""
|
||||
return [
|
||||
link.document
|
||||
for link in self.document_people
|
||||
if link.role == DocumentPersonRole.AUTHOR and link.document is not None
|
||||
]
|
||||
|
||||
|
||||
class DocumentPerson(SQLModel, table=True):
|
||||
"""Associates documents with people in a given role."""
|
||||
|
||||
__tablename__: str = "document_person"
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
document_id: UUID = Field(foreign_key="document.id")
|
||||
job_id: UUID = Field(foreign_key="job.id")
|
||||
upload_name: str
|
||||
"""The filename of the source that was uploaded for transcription."""
|
||||
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))
|
||||
person_id: UUID = Field(foreign_key="person.id")
|
||||
role: DocumentPersonRole = Field(default=DocumentPersonRole.AUTHOR)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
# Relationships
|
||||
document: Optional["Document"] = Relationship(back_populates="sources")
|
||||
job: Optional["Job"] = Relationship(back_populates="sources")
|
||||
revision: Optional["Revision"] = Relationship(
|
||||
back_populates="source",
|
||||
sa_relationship_kwargs={"uselist": False},
|
||||
__table_args__ = (UniqueConstraint("document_id", "person_id", "role", name="uq_document_person_role"),)
|
||||
|
||||
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"}
|
||||
)
|
||||
|
||||
|
||||
@@ -70,41 +153,78 @@ class Job(SQLModel, table=True):
|
||||
date_created: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
date_updated: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
provider: str | None = None
|
||||
"""Name of the transcription provider used to generate this transcript."""
|
||||
model: str | None = None
|
||||
"""Model identifier used to generate this transcript."""
|
||||
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")
|
||||
sources: list["Source"] = Relationship(back_populates="job")
|
||||
document: Optional["Document"] = Relationship(back_populates="jobs", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
job_sources: list["JobSource"] = Relationship(back_populates="job", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
"""Return the filename of the associated source, when available."""
|
||||
if not self.sources:
|
||||
if not self.job_sources:
|
||||
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):
|
||||
"""A revision of a transcription text."""
|
||||
class Source(SQLModel, table=True):
|
||||
"""A document source image or PDF page."""
|
||||
|
||||
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__: str = "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")
|
||||
"""ID for the associated source."""
|
||||
revision: int = Field(default=1, ge=1)
|
||||
"""Revision number of this transcription revision, starting at 1."""
|
||||
text: str
|
||||
"""The revised text."""
|
||||
date_created: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
status: JobSourceStatus = Field(default=JobSourceStatus.PENDING)
|
||||
raw_transcription: str | None = None
|
||||
ai_metadata: dict[str, Any] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
||||
raw_api_response: dict[str, Any] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
||||
error_detail: str | None = None
|
||||
executed_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
__table_args__ = (UniqueConstraint("source_id", name="uq_revision_source_id"),)
|
||||
|
||||
# Relationships
|
||||
source: Optional["Source"] = Relationship(back_populates="revision")
|
||||
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"})
|
||||
|
||||
@@ -2,9 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel import select
|
||||
@@ -18,14 +15,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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.
|
||||
from transcription.db import models as _models # noqa: F401
|
||||
|
||||
active_engine = engine or resolve_engine()
|
||||
async with active_engine.begin() as connection:
|
||||
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)
|
||||
|
||||
|
||||
@@ -38,38 +34,3 @@ async def get_next_queued_job(*, session: AsyncSession) -> Job | None:
|
||||
.limit(1)
|
||||
) # fmt: skip
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSessionTransaction
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..config import Settings
|
||||
from ..config import get_settings
|
||||
from .engine import dispose_engine
|
||||
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:
|
||||
return get_session_factory(database_url or get_database_url(get_settings()))
|
||||
def resolve_session_factory(
|
||||
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)]
|
||||
@@ -40,15 +47,20 @@ async def dispose_session_factory(database_url: str) -> None:
|
||||
@asynccontextmanager
|
||||
async def session_scope(
|
||||
*,
|
||||
settings: Settings | None = None,
|
||||
database_url: str | None = None,
|
||||
session_factory: SessionFactory | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> AsyncGenerator[AsyncSession]:
|
||||
if session is not None:
|
||||
yield session
|
||||
return
|
||||
|
||||
session_factory = resolve_session_factory(database_url)
|
||||
async with session_factory() as owned_session:
|
||||
active_session_factory = session_factory or resolve_session_factory(
|
||||
database_url,
|
||||
settings=settings,
|
||||
)
|
||||
async with active_session_factory() as owned_session:
|
||||
yield owned_session
|
||||
|
||||
|
||||
@@ -58,9 +70,11 @@ type SessionScopeDep = Annotated[AsyncSession, Depends(session_scope)]
|
||||
@asynccontextmanager
|
||||
async def transaction_scope(
|
||||
*,
|
||||
settings: Settings | None = None,
|
||||
database_url: str | None = None,
|
||||
session: AsyncSessionTransaction | None = None,
|
||||
) -> AsyncGenerator[AsyncSessionTransaction]:
|
||||
session_factory: SessionFactory | None = None,
|
||||
session: AsyncSession | AsyncSessionTransaction | None = None,
|
||||
) -> AsyncGenerator[AsyncSession | AsyncSessionTransaction]:
|
||||
match session:
|
||||
case AsyncSession() as async_session:
|
||||
if not async_session.in_transaction():
|
||||
@@ -71,9 +85,15 @@ async def transaction_scope(
|
||||
yield async_transaction
|
||||
return
|
||||
|
||||
session_factory = resolve_session_factory(database_url)
|
||||
async with session_factory().begin() as owned_session:
|
||||
active_session_factory = session_factory or resolve_session_factory(
|
||||
database_url,
|
||||
settings=settings,
|
||||
)
|
||||
async with active_session_factory.begin() as owned_session:
|
||||
yield owned_session
|
||||
|
||||
|
||||
type TransactionScopeDep = Annotated[AsyncSessionTransaction, Depends(transaction_scope)]
|
||||
type TransactionScopeDep = Annotated[
|
||||
AsyncSession | AsyncSessionTransaction,
|
||||
Depends(transaction_scope),
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
|
||||
from ..db.session import SessionFactory
|
||||
from .documents import DocumentService
|
||||
from .jobs import JobService
|
||||
from .transcription import TranscriptionService
|
||||
@@ -17,3 +18,12 @@ class ServiceBundle:
|
||||
documents: DocumentService = field(default_factory=DocumentService)
|
||||
jobs: JobService = field(default_factory=JobService)
|
||||
transcriptions: TranscriptionService = field(default_factory=TranscriptionService)
|
||||
|
||||
@classmethod
|
||||
def from_session_factory(cls, session_factory: SessionFactory) -> "ServiceBundle":
|
||||
"""Create a ServiceBundle from a session factory."""
|
||||
return cls(
|
||||
documents=DocumentService(session_factory=session_factory),
|
||||
jobs=JobService(session_factory=session_factory),
|
||||
transcriptions=TranscriptionService(session_factory=session_factory),
|
||||
)
|
||||
|
||||
@@ -31,7 +31,10 @@ class ServiceBase(ABC):
|
||||
@asynccontextmanager
|
||||
async def _session_scope(self, session: AsyncSession | None = None):
|
||||
"""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
|
||||
|
||||
async def _finalize(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
@@ -10,6 +12,8 @@ from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..db.models import Document
|
||||
from ..db.models import DocumentPerson
|
||||
from ..db.models import Person
|
||||
from ..errors import AppError
|
||||
from ..errors import ErrorCategory
|
||||
from .base import ServiceBase
|
||||
@@ -33,6 +37,14 @@ class DocumentAlreadyExistsError(DocumentError):
|
||||
"""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)
|
||||
class UploadJobResult:
|
||||
"""Summary of created upload records."""
|
||||
@@ -100,6 +112,7 @@ class DocumentService(ServiceBase):
|
||||
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
|
||||
"""Update an existing document in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
document.updated_at = datetime.now(UTC)
|
||||
merged = await _session.merge(document)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||
return merged
|
||||
@@ -107,7 +120,163 @@ class DocumentService(ServiceBase):
|
||||
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
|
||||
"""Delete a document from the database."""
|
||||
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)
|
||||
|
||||
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.",
|
||||
)
|
||||
|
||||
if existing.document_people:
|
||||
raise PersonDeleteBlockedError(
|
||||
"Person delete blocked by linked documents",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Remove linked DocumentPerson records first, then retry deletion.",
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# Query Operations
|
||||
@@ -128,3 +297,51 @@ class DocumentService(ServiceBase):
|
||||
async with self._session_scope(session) as _session:
|
||||
result = await _session.exec(select(Document))
|
||||
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()
|
||||
|
||||
@@ -7,12 +7,19 @@ from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..errors import AppError
|
||||
from ..errors import ErrorCategory
|
||||
from ..db.models import Job
|
||||
from ..db.models import JobSource
|
||||
from ..db.models import JobStatus
|
||||
from ..db.models import Source
|
||||
from .base import ServiceBase
|
||||
|
||||
|
||||
class JobDeleteBlockedError(AppError):
|
||||
"""Raised when a job delete operation is blocked by lifecycle policy."""
|
||||
|
||||
|
||||
class JobService(ServiceBase):
|
||||
"""Thin service class for managing jobs in the database."""
|
||||
|
||||
@@ -38,7 +45,7 @@ class JobService(ServiceBase):
|
||||
select(Job)
|
||||
.options(
|
||||
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)
|
||||
.execution_options(populate_existing=True)
|
||||
@@ -74,12 +81,12 @@ class JobService(ServiceBase):
|
||||
async with self._session_scope(session) as _session:
|
||||
query = select(Job).options(
|
||||
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:
|
||||
query = query.where(Job.status == status)
|
||||
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)
|
||||
return result.all()
|
||||
|
||||
@@ -94,7 +101,7 @@ class JobService(ServiceBase):
|
||||
async with self._session_scope(session) as _session:
|
||||
query = select(Job).options(
|
||||
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)
|
||||
return result.all()
|
||||
@@ -151,10 +158,11 @@ class JobService(ServiceBase):
|
||||
select(Job)
|
||||
.options(
|
||||
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)
|
||||
.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()
|
||||
|
||||
@@ -182,3 +190,34 @@ class JobService(ServiceBase):
|
||||
|
||||
await self._finalize(session=_session, caller_session=session, refresh=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)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from transcription.config import Settings
|
||||
@@ -13,18 +17,30 @@ from transcription.errors import ErrorCategory
|
||||
|
||||
from ..db.models import Document
|
||||
from ..db.models import Job
|
||||
from ..db.models import JobSource
|
||||
from ..db.models import JobSourceStatus
|
||||
from ..db.models import Source
|
||||
from .documents import UploadJobResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
|
||||
SUPPORTED_PORTRAIT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
|
||||
|
||||
|
||||
class UploadError(AppError):
|
||||
"""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, ...]
|
||||
|
||||
|
||||
async def create_upload_job(
|
||||
*,
|
||||
filename: str,
|
||||
@@ -63,6 +79,66 @@ 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[tuple[str, Path]] = []
|
||||
for filename, file_bytes in sorted_uploads:
|
||||
stored_uploads.append(
|
||||
(
|
||||
filename,
|
||||
store_file(
|
||||
filename=filename,
|
||||
file_bytes=file_bytes,
|
||||
settings=runtime_settings,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
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 _, stored_path in stored_uploads:
|
||||
_best_effort_delete(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(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
@@ -81,12 +157,21 @@ async def _create_upload_records(
|
||||
|
||||
source = Source(
|
||||
document_id=document.id,
|
||||
job_id=job.id,
|
||||
page_number=1,
|
||||
upload_name=Path(original_filename).name,
|
||||
filename=stored_path.name,
|
||||
file_path=str(stored_path),
|
||||
)
|
||||
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(document)
|
||||
@@ -94,6 +179,63 @@ async def _create_upload_records(
|
||||
return document, job
|
||||
|
||||
|
||||
async def _create_job_for_document_records(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
document_id: UUID,
|
||||
stored_uploads: Sequence[tuple[str, Path]],
|
||||
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, (original_filename, stored_path) in enumerate(stored_uploads):
|
||||
source = Source(
|
||||
document_id=document_id,
|
||||
page_number=next_page_number + page_offset,
|
||||
upload_name=Path(original_filename).name,
|
||||
filename=stored_path.name,
|
||||
file_path=str(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:
|
||||
try:
|
||||
if path.exists():
|
||||
@@ -105,13 +247,35 @@ def _best_effort_delete(path: Path) -> None:
|
||||
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
|
||||
"""Persist an uploaded file to the configured upload directory."""
|
||||
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)
|
||||
|
||||
upload_dir = runtime_settings.upload_dir
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def store_person_portrait(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
|
||||
"""Persist a portrait upload under uploads/portraits/person."""
|
||||
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("portraits") / "person",
|
||||
)
|
||||
|
||||
|
||||
def _store_file_bytes(
|
||||
*,
|
||||
filename: str,
|
||||
file_bytes: bytes,
|
||||
settings: Settings,
|
||||
relative_directory: Path | 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)
|
||||
stored_path = upload_dir / stored_name
|
||||
stored_path = target_dir / stored_name
|
||||
|
||||
try:
|
||||
stored_path.write_bytes(file_bytes)
|
||||
@@ -126,7 +290,7 @@ def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None =
|
||||
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:
|
||||
raise UploadError(
|
||||
"Upload payload is empty",
|
||||
@@ -143,14 +307,15 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
|
||||
)
|
||||
|
||||
suffix = Path(safe_name).suffix.lower()
|
||||
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS:
|
||||
if suffix not in supported_extensions:
|
||||
raise UploadError(
|
||||
f"Unsupported upload extension: {suffix}",
|
||||
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:
|
||||
safe_name = Path(filename).name
|
||||
return f"{uuid4()}_{safe_name}"
|
||||
suffix = Path(safe_name).suffix.lower()
|
||||
return f"{uuid4()}{suffix}"
|
||||
|
||||
@@ -19,7 +19,8 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from transcription.config import Settings
|
||||
from transcription.config import get_settings
|
||||
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.errors import AppError
|
||||
from transcription.errors import ErrorCategory
|
||||
@@ -50,8 +51,12 @@ class TranscriptionNotFoundError(TranscriptionError):
|
||||
"""Raised when a transcription-related resource is not found."""
|
||||
|
||||
|
||||
class SourceDeleteBlockedError(TranscriptionError):
|
||||
"""Raised when source deletion is blocked by dependency policy."""
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -59,51 +64,197 @@ class TranscriptionService(ServiceBase):
|
||||
super().__init__(session_factory=session_factory)
|
||||
self.provider = get_transcription_provider(settings=self.settings)
|
||||
|
||||
async def create_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> Revision:
|
||||
"""Create a new revision in the database."""
|
||||
async def create_source(self, source: Source, *, session: AsyncSession | None = None) -> Source:
|
||||
"""Create a new source page record in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
_session.add(revision)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(revision,))
|
||||
return revision
|
||||
_session.add(source)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(source,))
|
||||
return source
|
||||
|
||||
async def read_revision(self, revision_id: UUID, *, session: AsyncSession | None = None) -> Revision:
|
||||
"""Read an existing revision from the database."""
|
||||
async def read_source(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source:
|
||||
"""Read an existing source page record."""
|
||||
async with self._session_scope(session) as _session:
|
||||
revision = await _session.get(
|
||||
Revision,
|
||||
revision_id,
|
||||
options=(selectinload(Revision.source),), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
if revision is None:
|
||||
source = await _session.get(Source, source_id)
|
||||
if source is None:
|
||||
raise TranscriptionNotFoundError(
|
||||
f"Revision with id {revision_id} not found",
|
||||
f"Source with id {source_id} 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:
|
||||
"""Update an existing revision in the database."""
|
||||
async def read_source_detail(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source:
|
||||
"""Read a source page record with job-source context for UI detail rendering."""
|
||||
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,))
|
||||
return merged
|
||||
|
||||
async def delete_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> None:
|
||||
"""Delete a revision from the database."""
|
||||
async def delete_source(self, source: Source, *, session: AsyncSession | None = None) -> None:
|
||||
"""Delete a source page record."""
|
||||
async with self._session_scope(session) as _session:
|
||||
await _session.delete(revision)
|
||||
await _session.delete(source)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
# Temporary compatibility methods for callers still using transcript naming.
|
||||
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 read_transcript(self, transcript_id: UUID, *, session: AsyncSession | None = None) -> Revision:
|
||||
"""Backward-compatible alias for read_revision."""
|
||||
return await self.read_revision(transcript_id, session=session)
|
||||
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 delete_transcript(self, transcript: Revision, *, session: AsyncSession | None = None) -> None:
|
||||
"""Backward-compatible alias for delete_revision."""
|
||||
await self.delete_revision(transcript, session=session)
|
||||
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)
|
||||
|
||||
await _session.delete(source)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
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(
|
||||
self,
|
||||
@@ -151,13 +302,37 @@ class TranscriptionService(ServiceBase):
|
||||
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.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 = 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:
|
||||
existing_job_source = await _session.exec(
|
||||
select(JobSource).where(JobSource.job_id == job.id).where(JobSource.source_id == source_row.id)
|
||||
)
|
||||
job_source = existing_job_source.first()
|
||||
if job_source is None:
|
||||
job_source = JobSource(
|
||||
job_id=job.id,
|
||||
source_id=source_row.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,))
|
||||
return job
|
||||
|
||||
@@ -167,8 +342,8 @@ class TranscriptionService(ServiceBase):
|
||||
source_id: UUID,
|
||||
text: str,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Revision:
|
||||
"""Create or replace the single optional revision for a source."""
|
||||
) -> Source:
|
||||
"""Persist a human revision on a source page."""
|
||||
async with self._session_scope(session) as _session:
|
||||
source = await _session.get(Source, source_id)
|
||||
if source is None:
|
||||
@@ -178,45 +353,35 @@ class TranscriptionService(ServiceBase):
|
||||
suggestion="Verify the source id and retry.",
|
||||
)
|
||||
|
||||
query = select(Revision).where(Revision.source_id == source_id)
|
||||
existing = (await _session.exec(query)).one_or_none()
|
||||
|
||||
if existing is None:
|
||||
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
|
||||
source.revised_text = text
|
||||
source.date_revised = datetime.now(UTC)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(source,))
|
||||
return source
|
||||
|
||||
async def read_revision_by_source(
|
||||
self,
|
||||
source_id: UUID,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Revision | None:
|
||||
"""Read the single optional revision for a source."""
|
||||
) -> Source | None:
|
||||
"""Read the source record for a given page, including any revision text."""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = select(Revision).where(Revision.source_id == source_id)
|
||||
result = await _session.exec(query)
|
||||
return result.one_or_none()
|
||||
return await _session.get(Source, source_id)
|
||||
|
||||
async def list_revisions_by_job(
|
||||
self,
|
||||
job_id: UUID,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Sequence[Revision]:
|
||||
"""List revisions connected to all sources for a job."""
|
||||
) -> Sequence[Source]:
|
||||
"""List source pages for a job that carry revision text."""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = (
|
||||
select(Revision)
|
||||
.join(Source, Source.id == Revision.source_id)
|
||||
.where(Source.job_id == job_id)
|
||||
.order_by(Revision.date_created) # pyright: ignore[reportArgumentType]
|
||||
select(Source)
|
||||
.join(JobSource, JobSource.source_id == Source.id)
|
||||
.where(JobSource.job_id == job_id)
|
||||
.where(Source.revised_text.is_not(None))
|
||||
.order_by(Source.date_revised) # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
result = await _session.exec(query)
|
||||
return result.all()
|
||||
|
||||
@@ -70,7 +70,17 @@ async def process_queued_job(
|
||||
|
||||
source_job = await services.jobs.read_job(job_id=job.id, session=session)
|
||||
source = _resolve_primary_source(source_job)
|
||||
assert source is not None, f"Job {job.id} has no associated source record."
|
||||
if source is None:
|
||||
candidate_sources = await services.transcriptions.list_sources(document_id=job.document_id, session=session)
|
||||
source = next(iter(sorted(candidate_sources, key=lambda item: item.page_number)), None)
|
||||
|
||||
if source is None:
|
||||
error = AppError(
|
||||
f"Job {job.id} has no associated source record.",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Attach at least one source to the job and retry.",
|
||||
)
|
||||
return await _finalize_failed(job=job, services=services, error=error, session=session)
|
||||
started_at = asyncio.get_running_loop().time()
|
||||
|
||||
try:
|
||||
@@ -151,6 +161,7 @@ async def process_next_queued_job(
|
||||
) -> bool:
|
||||
"""Process the next queued job if one exists."""
|
||||
job = await services.jobs.read_next_queued_job(session=session)
|
||||
|
||||
if job is None:
|
||||
return False
|
||||
|
||||
@@ -291,9 +302,9 @@ async def _finalize_failed(
|
||||
|
||||
|
||||
def _resolve_primary_source(job: Job) -> Source | None:
|
||||
if not job.sources:
|
||||
if not job.job_sources:
|
||||
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 _validate_transcription_quality(*, result: TranscriptionResult, settings: Settings) -> None:
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""UI page registration exports."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from nicegui import app as nicegui_app
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
||||
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
||||
|
||||
_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:
|
||||
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
|
||||
return
|
||||
|
||||
nicegui_app.colors(**_THEME_COLORS)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def register_pages(app: FastAPI) -> None:
|
||||
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
||||
_register_global_styles(app)
|
||||
register_upload_page()
|
||||
register_jobs_page()
|
||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=True)
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
"""Reusable UI component exports."""
|
||||
|
||||
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.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",
|
||||
]
|
||||
|
||||
@@ -4,56 +4,68 @@ from __future__ import annotations
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.ui.resources import read_css
|
||||
|
||||
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||
("Upload", "/upload", "upload_file"),
|
||||
("Documents", "/documents", "description"),
|
||||
("People", "/people", "group"),
|
||||
("Sources", "/sources", "folder"),
|
||||
("Jobs", "/jobs", "work_history"),
|
||||
)
|
||||
|
||||
from transcription.ui.theme import VIBESCRIBE_LOGO_SVG
|
||||
|
||||
def _is_active_path(*, current_path: str, item_path: str) -> bool:
|
||||
if item_path == "/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
|
||||
|
||||
|
||||
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:
|
||||
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,
|
||||
icon=icon,
|
||||
on_click=lambda _=None, route=path: ui.navigate.to(route),
|
||||
)
|
||||
button.props(_button_props(icon=icon, is_active=is_active)).classes(_button_classes(is_active=is_active))
|
||||
).props("flat no-caps").classes(classes)
|
||||
|
||||
|
||||
def _normalize_path(current_path: str | None) -> str:
|
||||
normalized = (current_path or "").strip()
|
||||
if not normalized:
|
||||
return "/upload"
|
||||
return "/jobs"
|
||||
return normalized.rstrip("/") or "/"
|
||||
|
||||
|
||||
def render_navigation_header(*, current_path: str | None = None) -> None:
|
||||
"""Render a shared app header with links for top-level pages."""
|
||||
def render_app_shell(*, current_path: str | None = None) -> None:
|
||||
"""Render the shared application shell header."""
|
||||
ui.add_css(read_css("components/app_shell.css"))
|
||||
normalized_path = _normalize_path(current_path)
|
||||
|
||||
with (
|
||||
ui.header(elevated=True).props("bordered").classes("bg-dark text-white q-px-sm q-py-xs"),
|
||||
ui.row().classes("w-full items-center justify-end q-gutter-xs"),
|
||||
ui.element("div").classes("w-full grid grid-cols-2 gap-2 sm:flex sm:justify-start sm:gap-2"),
|
||||
):
|
||||
for label, path, icon in NAV_ITEMS:
|
||||
_render_nav_button(label=label, path=path, icon=icon, current_path=normalized_path)
|
||||
with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"):
|
||||
with ui.row().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:
|
||||
_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)
|
||||
|
||||
@@ -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]")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Documents list and detail page registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
|
||||
from .cards import archival_card
|
||||
from .data_display import archival_badge
|
||||
from .data_display import metadata_row
|
||||
from .primitives import render_empty_state
|
||||
|
||||
|
||||
def render_archival_metadata(document: Document, author_link: DocumentPerson | None = None) -> None:
|
||||
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")
|
||||
|
||||
|
||||
def render_doc_people_details(document: Document) -> None:
|
||||
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)
|
||||
|
||||
|
||||
def render_doc_job_details(document: Document) -> None:
|
||||
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")
|
||||
@@ -0,0 +1,111 @@
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from nicegui import ui
|
||||
from nicegui.binding import bindable_dataclass
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ...db.models import Document
|
||||
from ...db.models import DocumentPersonRole
|
||||
from ...services.people import get_name_options
|
||||
from .cards import archival_card
|
||||
|
||||
PROPS = "outlined bg-white"
|
||||
CREATE_NEW_PERSON_OPTION = "__create_new_person__"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bindable_dataclass
|
||||
class DocumentEditForm:
|
||||
id: UUID | None = None
|
||||
name: str | None = None
|
||||
document_type: str | None = None
|
||||
document_date: str | None = None
|
||||
document_date_raw: str | None = None
|
||||
location_created: str | None = None
|
||||
archive_identifier: str | None = None
|
||||
notes: str | None = None
|
||||
author_id: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_table_model(cls, model: Document):
|
||||
existing_author = next(
|
||||
(item for item in model.document_people if item.role == DocumentPersonRole.AUTHOR),
|
||||
None,
|
||||
)
|
||||
return cls(
|
||||
id=model.id,
|
||||
name=model.name,
|
||||
document_type=model.document_type,
|
||||
document_date=model.document_date.isoformat() if model.document_date else None,
|
||||
document_date_raw=model.document_date_raw,
|
||||
location_created=model.location_created,
|
||||
archive_identifier=model.archive_identifier,
|
||||
notes=model.notes,
|
||||
author_id=str(existing_author.person_id) if existing_author is not None else "",
|
||||
)
|
||||
|
||||
def save(self, session: AsyncSession) -> None:
|
||||
"""Save the form data to the database."""
|
||||
doc = session.get(Document, self.id)
|
||||
if not doc:
|
||||
logger.error("Document with ID %s not found in the database.", self.id)
|
||||
return
|
||||
|
||||
|
||||
async def render_document_edit_form(document: Document, session: AsyncSession) -> DocumentEditForm:
|
||||
edit_form = DocumentEditForm.from_table_model(document)
|
||||
|
||||
with archival_card(extra_classes="gap-3"):
|
||||
(ui.input("Document name").classes("w-full").props("autofocus").props(PROPS).bind_value(edit_form, "name"))
|
||||
(
|
||||
ui.input("Document type")
|
||||
.classes("w-full")
|
||||
.props("autofocus")
|
||||
.props(PROPS)
|
||||
.bind_value(edit_form, "document_type")
|
||||
)
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
|
||||
(
|
||||
ui.input("Exact date (YYYY-MM-DD)")
|
||||
.props(PROPS)
|
||||
.props('type="date"')
|
||||
.bind_value(edit_form, "document_date")
|
||||
)
|
||||
(ui.input("Approximate date").bind_value(edit_form, "document_date_raw").props(PROPS))
|
||||
ui.input("Document location").classes("w-full").props(PROPS).bind_value(edit_form, "location_created")
|
||||
ui.input("Archive identifier").classes("w-full").props(PROPS).bind_value(edit_form, "archive_identifier")
|
||||
(
|
||||
ui.textarea("Notes")
|
||||
.classes("w-full")
|
||||
.props(PROPS)
|
||||
.props("autogrow")
|
||||
.bind_value(edit_form, "notes")
|
||||
.props("rows=4")
|
||||
)
|
||||
|
||||
people = await get_name_options(session=session)
|
||||
author_options = {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} | {
|
||||
str(person_id): name for person_id, name in people.items()
|
||||
}
|
||||
|
||||
def on_author_change(event) -> None:
|
||||
selected = str(event.value or "").strip()
|
||||
if selected == CREATE_NEW_PERSON_OPTION:
|
||||
ui.navigate.to("/people/new")
|
||||
|
||||
(
|
||||
ui.select(
|
||||
author_options,
|
||||
label="Author (Person)",
|
||||
value=edit_form.author_id or "",
|
||||
on_change=on_author_change,
|
||||
)
|
||||
.classes("w-full")
|
||||
.props(PROPS)
|
||||
.bind_value(edit_form, "author_id")
|
||||
)
|
||||
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
|
||||
|
||||
return edit_form
|
||||
@@ -24,10 +24,10 @@ def render_document_panzoom(*, source: Source) -> None:
|
||||
document_url = _document_url(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"):
|
||||
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;"
|
||||
)
|
||||
|
||||
@@ -90,7 +90,7 @@ def _register_panzoom_assets() -> None:
|
||||
height: 100%;
|
||||
border: 0;
|
||||
pointer-events: none;
|
||||
background: white;
|
||||
background: var(--theme-surface-raised);
|
||||
}
|
||||
</style>
|
||||
""",
|
||||
|
||||
@@ -26,7 +26,7 @@ def show_error(exc: Exception, *, title: str, operation: str) -> None:
|
||||
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(error.message)
|
||||
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["descending"] = default_descending
|
||||
|
||||
# Quasar props enforce behavior; visual styling is centralized in theme.css.
|
||||
table = (
|
||||
ui.table(
|
||||
rows=rows,
|
||||
@@ -64,9 +65,14 @@ def build_table(
|
||||
row_key="id",
|
||||
pagination=pagination,
|
||||
)
|
||||
.classes(classes)
|
||||
.props('table-style="table-layout: fixed; width: 100%;"')
|
||||
.classes(f"w-full ui-table {classes}")
|
||||
.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))
|
||||
if on_row_click_id is not None:
|
||||
_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 transcription.ui.components.cards import archival_card
|
||||
from transcription.ui.components.primitives import render_empty_state
|
||||
from .common import build_table
|
||||
|
||||
|
||||
@@ -40,7 +42,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": str(row.id),
|
||||
"status": row.status,
|
||||
"status": row.status.upper(),
|
||||
"filename": row.filename,
|
||||
"retry_count": row.retry_count,
|
||||
"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:
|
||||
"""Render jobs table and open a detail page when clicking a row."""
|
||||
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
|
||||
|
||||
build_table(
|
||||
rows=_serialize_rows(rows),
|
||||
columns=[
|
||||
{"name": "id", "label": "Job ID", "field": "id", "sortable": True},
|
||||
{"name": "status", "label": "Status", "field": "status", "sortable": True},
|
||||
{"name": "filename", "label": "Filename", "field": "filename", "sortable": True},
|
||||
{"name": "id", "label": "Job ID", "field": "id", "sortable": True, "classes": "font-mono"},
|
||||
{"name": "status", "label": "Status", "field": "status", "sortable": True, "classes": "font-semibold ui-link-primary"},
|
||||
{"name": "filename", "label": "Source Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
|
||||
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
|
||||
{"name": "date_created", "label": "Created", "field": "date_created", "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,59 @@
|
||||
"""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
|
||||
|
||||
|
||||
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),
|
||||
}
|
||||
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": "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}"),
|
||||
)
|
||||
@@ -10,62 +10,67 @@ from typing import Any
|
||||
from nicegui import ui
|
||||
|
||||
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:
|
||||
"""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}"
|
||||
provider = job.provider or "unknown"
|
||||
model = job.model or "unknown"
|
||||
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"):
|
||||
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="Updated", value=_format_created_at(job.date_updated))
|
||||
|
||||
if job.text:
|
||||
with ui.card().classes("w-full q-pa-sm"):
|
||||
ui.markdown(job.text)
|
||||
latest_transcription = _latest_job_transcription(job)
|
||||
|
||||
if job.error_detail:
|
||||
with ui.card().classes("w-full bg-red-1 text-red-10 q-pa-sm"):
|
||||
if latest_transcription:
|
||||
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(job.error_detail).classes("text-body2")
|
||||
ui.label(latest_error_detail).classes("text-body2")
|
||||
|
||||
return card
|
||||
|
||||
|
||||
def render_revision_row(
|
||||
*,
|
||||
revision: Revision,
|
||||
revision: Source | None,
|
||||
initially_expanded: bool = False,
|
||||
classes: str = "w-full",
|
||||
on_delete: RevisionAction | None = None,
|
||||
) -> Any:
|
||||
"""Render a collapsible row for the single optional source revision."""
|
||||
header = "Revision | User-authored"
|
||||
caption = _format_created_at(revision.date_created)
|
||||
if revision is None:
|
||||
return None
|
||||
|
||||
expansion = ui.expansion(value=initially_expanded, group="group").classes(
|
||||
f"{classes} rounded-borders bg-blue-grey-10"
|
||||
)
|
||||
header = "Source revision | User-authored"
|
||||
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.add_slot("header"), ui.row().classes("w-full items-start justify-between q-gutter-md"):
|
||||
with ui.column().classes("q-gutter-none"):
|
||||
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:
|
||||
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"):
|
||||
ui.button("Cancel", on_click=lambda: delete_dialog.submit(False)).props("flat")
|
||||
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(
|
||||
'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"):
|
||||
ui.markdown(revision.text)
|
||||
ui.markdown(revision.revised_text)
|
||||
|
||||
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:
|
||||
"""Return a compact UTC-like timestamp for row captions."""
|
||||
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:
|
||||
with ui.row().classes("w-md items-start justify-between q-gutter-x-md"):
|
||||
ui.label(label).classes("text-caption text-grey-5 text-uppercase")
|
||||
ui.label(value).classes("text-body2 text-right text-grey-1 break-all")
|
||||
ui.label(label).classes("text-caption vibe-text-muted text-uppercase")
|
||||
ui.label(value).classes("text-body2 text-right break-all")
|
||||
|
||||
@@ -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"')
|
||||
@@ -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")
|
||||
@@ -0,0 +1,17 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from transcription.db.session import SessionFactory
|
||||
from transcription.db.session import resolve_session_factory
|
||||
|
||||
from ..services import ServiceBundle
|
||||
|
||||
type SessionFactoryDep = Annotated[SessionFactory, Depends(resolve_session_factory)]
|
||||
|
||||
|
||||
def _get_service_bundle(session_factory: SessionFactoryDep) -> ServiceBundle:
|
||||
return ServiceBundle.from_session_factory(session_factory)
|
||||
|
||||
|
||||
type ServicesDep = Annotated[ServiceBundle, Depends(_get_service_bundle)]
|
||||
@@ -0,0 +1,23 @@
|
||||
from fastapi import FastAPI
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.ui.pages.documents import register_pages as register_documents_pages
|
||||
|
||||
from ..theme import register_global_styles
|
||||
from .jobs_page import register_page as register_jobs_page
|
||||
from .people_page import register_page as register_people_page
|
||||
from .sources_page import register_page as register_sources_page
|
||||
from .upload_page import register_page as register_upload_page
|
||||
|
||||
__all__ = ["register_pages"]
|
||||
|
||||
|
||||
def register_pages(app: FastAPI) -> None:
|
||||
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
||||
register_global_styles(app)
|
||||
register_upload_page()
|
||||
register_documents_pages()
|
||||
register_people_page()
|
||||
register_sources_page()
|
||||
register_jobs_page()
|
||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
from nicegui import ui
|
||||
|
||||
from ...dependency import ServicesDep
|
||||
from ...dependency import SessionFactoryDep
|
||||
from .delete_document import render_delete_document_page
|
||||
from .document_detail import render_document_detail_page
|
||||
from .document_overview import render_document_overview_page
|
||||
from .edit_document import render_document_edit_page
|
||||
from .new_document import render_new_document_page
|
||||
|
||||
__all__ = ["register_pages"]
|
||||
|
||||
|
||||
def register_pages() -> None:
|
||||
"""Register documents list and detail routes."""
|
||||
|
||||
@ui.page("/documents")
|
||||
async def documents_page(services: ServicesDep) -> None:
|
||||
await render_document_overview_page(services=services)
|
||||
|
||||
@ui.page("/documents/new")
|
||||
async def document_create_page(request: Request, services: ServicesDep) -> None:
|
||||
await render_new_document_page(request, services=services)
|
||||
|
||||
@ui.page("/documents/{document_id}")
|
||||
async def document_detail_page(document_id: str, services: ServicesDep) -> None:
|
||||
await render_document_detail_page(document_id, services=services)
|
||||
|
||||
@ui.page("/documents/{document_id}/edit")
|
||||
async def document_edit_page(document_id: str, services: ServicesDep, session_factory: SessionFactoryDep) -> None:
|
||||
await render_document_edit_page(document_id, services=services, session_factory=session_factory)
|
||||
|
||||
@ui.page("/documents/{document_id}/delete")
|
||||
async def document_delete_page(document_id: str, services: ServicesDep) -> None:
|
||||
await render_delete_document_page(document_id, services=services)
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.services.documents import DocumentDeleteBlockedError
|
||||
from transcription.services.documents import DocumentError
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.cards import archival_card
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
from transcription.ui.components.primitives import destructive_button
|
||||
from transcription.ui.theme import page_header
|
||||
|
||||
from ...dependency import ServicesDep
|
||||
|
||||
|
||||
async def render_delete_document_page(document_id: str, services: ServicesDep) -> None:
|
||||
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 services.documents.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 services.documents.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"
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Documents list and detail page registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.db.models import DocumentPersonRole
|
||||
from transcription.services.documents import DocumentError
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
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.viewers import dark_room_viewer
|
||||
from transcription.ui.dependency import ServicesDep
|
||||
from transcription.ui.theme import page_header
|
||||
|
||||
from ...components import document_details as details
|
||||
|
||||
|
||||
async def render_document_detail_page(document_id: str, services: ServicesDep) -> None:
|
||||
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 services.documents.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")
|
||||
|
||||
details.render_archival_metadata(document=document, author_link=author_link)
|
||||
|
||||
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
|
||||
details.render_doc_people_details(document=document)
|
||||
details.render_doc_job_details(document=document)
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
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.theme import page_header
|
||||
|
||||
from ...dependency import ServicesDep
|
||||
|
||||
|
||||
async def render_document_overview_page(services: ServicesDep) -> None:
|
||||
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 services.documents.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)
|
||||
@@ -0,0 +1,141 @@
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
from transcription.db.models import DocumentPersonRole
|
||||
from transcription.services.documents import DocumentError
|
||||
|
||||
from ....db.session import SessionFactory
|
||||
from ...components.app_shell import render_navigation_header
|
||||
from ...components.document_form import CREATE_NEW_PERSON_OPTION
|
||||
from ...components.document_form import DocumentEditForm
|
||||
from ...components.document_form import render_document_edit_form
|
||||
from ...components.error_presenter import show_error
|
||||
from ...dependency import ServicesDep
|
||||
from ...theme import page_header
|
||||
|
||||
|
||||
def _build_updated_document(edit_form: DocumentEditForm, document: Document) -> tuple[Document | None, str | None]:
|
||||
candidate_name = (edit_form.name or "").strip()
|
||||
candidate_type = (edit_form.document_type or "").strip()
|
||||
if not candidate_name:
|
||||
return None, "Document name is required."
|
||||
if not candidate_type:
|
||||
return None, "Document type is required."
|
||||
|
||||
parsed_date: date | None = None
|
||||
candidate_date_text = (edit_form.document_date or "").strip()
|
||||
if candidate_date_text:
|
||||
try:
|
||||
parsed_date = date.fromisoformat(candidate_date_text)
|
||||
except ValueError:
|
||||
return None, "Exact date must use YYYY-MM-DD."
|
||||
|
||||
return (
|
||||
Document(
|
||||
id=document.id,
|
||||
name=candidate_name,
|
||||
document_type=candidate_type,
|
||||
document_date=parsed_date,
|
||||
document_date_raw=(edit_form.document_date_raw or "").strip() or None,
|
||||
location_created=(edit_form.location_created or "").strip() or None,
|
||||
notes=(edit_form.notes or "").strip() or None,
|
||||
archive_identifier=(edit_form.archive_identifier or "").strip() or None,
|
||||
created_at=document.created_at,
|
||||
updated_at=document.updated_at,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
async def _sync_author_links(
|
||||
services: ServicesDep,
|
||||
document: Document,
|
||||
selected_author: str,
|
||||
) -> None:
|
||||
existing_author_links = [
|
||||
link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR
|
||||
]
|
||||
if not selected_author:
|
||||
for link in existing_author_links:
|
||||
await services.documents.delete_document_person(link)
|
||||
return
|
||||
|
||||
selected_author_id = UUID(selected_author)
|
||||
if any(link.person_id == selected_author_id for link in existing_author_links):
|
||||
return
|
||||
|
||||
for link in existing_author_links:
|
||||
await services.documents.delete_document_person(link)
|
||||
await services.documents.create_document_person(
|
||||
DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=selected_author_id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def render_document_edit_page(
|
||||
document_id: str,
|
||||
services: ServicesDep,
|
||||
session_factory: SessionFactory,
|
||||
) -> None:
|
||||
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 services.documents.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.")
|
||||
|
||||
edit_form = await render_document_edit_form(document=document, session=session_factory())
|
||||
|
||||
async def submit_edit() -> None:
|
||||
candidate, validation_error = _build_updated_document(edit_form, document)
|
||||
if validation_error:
|
||||
ui.notify(validation_error, type="warning")
|
||||
return
|
||||
if candidate is None:
|
||||
ui.notify("Unable to build updated document.", type="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
await services.documents.update_document(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Save failed", operation="documents.edit.save")
|
||||
return
|
||||
|
||||
selected_author = (edit_form.author_id or "").strip()
|
||||
if selected_author == CREATE_NEW_PERSON_OPTION:
|
||||
ui.navigate.to("/people/new")
|
||||
return
|
||||
try:
|
||||
await _sync_author_links(services=services, document=document, selected_author=selected_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"
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
from transcription.db.models import DocumentPersonRole
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.cards import archival_card
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
from transcription.ui.theme import page_header
|
||||
|
||||
from ...dependency import ServicesDep
|
||||
|
||||
CREATE_NEW_PERSON_OPTION = "__create_new_person__"
|
||||
|
||||
|
||||
async def render_new_document_page(request: Request, services: ServicesDep) -> None:
|
||||
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 services.documents.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 services.documents.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 services.documents.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")
|
||||
@@ -2,24 +2,32 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.db.models import Job
|
||||
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 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.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.jobs import render_jobs_table
|
||||
from transcription.ui.theme import page_header
|
||||
from transcription.worker import resolve_worker_notifier
|
||||
|
||||
from ...db.session import SessionFactoryDep
|
||||
from ..components.document_panzoom import render_document_panzoom
|
||||
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
|
||||
@@ -27,133 +35,296 @@ def register_page() -> None: # noqa: PLR0915
|
||||
|
||||
@ui.page("/jobs")
|
||||
async def jobs_page(session_factory: SessionFactoryDep) -> None:
|
||||
|
||||
jobs_service = JobService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/jobs")
|
||||
|
||||
@ui.refreshable
|
||||
async def render_table() -> None:
|
||||
jobs = [
|
||||
JobTableRow(
|
||||
id=job.id,
|
||||
status=job.status.value,
|
||||
filename=job.filename,
|
||||
retry_count=job.retry_count,
|
||||
date_created=job.date_created.isoformat(),
|
||||
date_updated=job.date_updated.isoformat(),
|
||||
)
|
||||
for job in await jobs_service.list_jobs()
|
||||
]
|
||||
render_jobs_table(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.button("Refresh", on_click=render_table.refresh, icon="refresh")
|
||||
await render_table()
|
||||
@ui.refreshable
|
||||
async def render_table() -> None:
|
||||
jobs = [
|
||||
JobTableRow(
|
||||
id=job.id,
|
||||
status=job.status.value,
|
||||
filename=job.filename,
|
||||
retry_count=job.retry_count,
|
||||
date_created=job.date_created.isoformat(),
|
||||
date_updated=job.date_updated.isoformat(),
|
||||
)
|
||||
for job in await jobs_service.list_jobs()
|
||||
]
|
||||
render_jobs_table(jobs)
|
||||
|
||||
await render_table()
|
||||
|
||||
@ui.page("/jobs/new")
|
||||
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
|
||||
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}")
|
||||
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915
|
||||
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
|
||||
jobs_service = JobService(session_factory=session_factory)
|
||||
transcription_service = TranscriptionService(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-negative")
|
||||
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-negative")
|
||||
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
|
||||
return
|
||||
|
||||
source = _resolve_primary_source(job)
|
||||
|
||||
with ui.splitter(value=30).classes("w-full h-[calc(100vh-64px)]") as splitter:
|
||||
with splitter.before, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
|
||||
if source is not None:
|
||||
render_document_panzoom(source=source)
|
||||
else:
|
||||
ui.label("No source preview is available for this job.").classes("text-body2 text-grey-3")
|
||||
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 "")
|
||||
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())
|
||||
destructive_button(
|
||||
"Delete Job",
|
||||
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/delete"),
|
||||
icon="delete",
|
||||
extra_classes="text-xs",
|
||||
)
|
||||
|
||||
ui.label("Revision Editor").classes("text-subtitle1 text-weight-medium")
|
||||
editor = ui.textarea(label="Revision text", value=default_revision_text).props("autogrow outlined")
|
||||
editor.classes("w-full")
|
||||
with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"):
|
||||
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())
|
||||
|
||||
async def save_revision() -> None:
|
||||
candidate = (editor.value or "").strip()
|
||||
if not candidate:
|
||||
ui.notify("Revision text is required.", type="warning")
|
||||
return
|
||||
|
||||
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")
|
||||
await render_revision_panel.refresh()
|
||||
|
||||
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(
|
||||
"Create revision" if current_revision is None else "Update revision",
|
||||
on_click=save_revision,
|
||||
icon="save",
|
||||
).props('unelevated color="primary"')
|
||||
"View Linked Document",
|
||||
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
|
||||
icon="description",
|
||||
).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.label("No revision exists for this source.").classes("text-body2 text-grey-3")
|
||||
return
|
||||
@ui.page("/jobs/{job_id}/delete")
|
||||
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
|
||||
render_revision_row(
|
||||
revision=current_revision,
|
||||
initially_expanded=True,
|
||||
on_delete=lambda _revision, rid=current_revision.id: delete_revision_by_id(rid),
|
||||
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"
|
||||
)
|
||||
|
||||
await render_revision_panel()
|
||||
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")
|
||||
|
||||
def _resolve_primary_source(job: Job) -> Source | None:
|
||||
if not job.sources:
|
||||
return None
|
||||
return job.sources[0]
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"""People list and detail page registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from urllib.parse import quote
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.config import get_settings
|
||||
from transcription.db.models import Person
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.services.documents import DocumentError
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.services.documents import PersonDeleteBlockedError
|
||||
from transcription.services.store import UploadError
|
||||
from transcription.services.store import store_person_portrait
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.cards import archival_card
|
||||
from transcription.ui.components.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
|
||||
from transcription.ui.components.table.people import render_people_table
|
||||
from transcription.ui.components.viewers import dark_room_viewer
|
||||
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) -> None:
|
||||
async def on_portrait_selected(event) -> None:
|
||||
payload = await event.file.read()
|
||||
try:
|
||||
stored_path = store_person_portrait(
|
||||
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")
|
||||
ui.label("Portraits are stored under uploads/portraits/person.").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:
|
||||
|
||||
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:
|
||||
|
||||
people_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
|
||||
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))
|
||||
|
||||
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(
|
||||
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:
|
||||
|
||||
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:
|
||||
|
||||
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))
|
||||
|
||||
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:
|
||||
|
||||
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("Delete is blocked because linked documents exist.").classes(
|
||||
"text-xs text-red-800 font-bold mt-2"
|
||||
)
|
||||
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs ui-text-muted")
|
||||
ui.label("Remove document links 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 Person",
|
||||
on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
|
||||
icon="arrow_back",
|
||||
).classes("ui-btn-primary text-xs")
|
||||
ui.button(
|
||||
"Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description"
|
||||
).props("flat text-xs")
|
||||
return
|
||||
|
||||
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 PersonDeleteBlockedError as exc:
|
||||
ui.notify(exc.message, type="warning")
|
||||
ui.navigate.to(f"/people/{person.id}/delete")
|
||||
return
|
||||
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"
|
||||
)
|
||||
@@ -0,0 +1,259 @@
|
||||
"""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 Source
|
||||
from transcription.services.documents import DocumentError
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.services.jobs import JobService
|
||||
from transcription.services.transcription import TranscriptionNotFoundError
|
||||
from transcription.services.transcription import 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 section_header_row
|
||||
from transcription.ui.components.table.sources import SourceTableRow
|
||||
from transcription.ui.components.table.sources import render_sources_table
|
||||
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:
|
||||
|
||||
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] = []
|
||||
|
||||
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 = 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)
|
||||
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 = 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,
|
||||
)
|
||||
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:
|
||||
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}"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
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="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("/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
|
||||
@@ -3,29 +3,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from starlette import status
|
||||
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.upload import render_upload_widget
|
||||
from transcription.worker import resolve_worker_notifier
|
||||
|
||||
|
||||
def register_page() -> None:
|
||||
"""Register the upload page route."""
|
||||
|
||||
@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")
|
||||
|
||||
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)
|
||||
return RedirectResponse(url="/ui/jobs/new", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||
|
||||
@@ -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")
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
+10
-5
@@ -15,7 +15,6 @@ from transcription.config import Settings
|
||||
from transcription.config import get_settings
|
||||
from transcription.db.engine import get_database_url
|
||||
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 get_session_factory
|
||||
from transcription.db.session import session_scope
|
||||
@@ -31,6 +30,7 @@ def session():
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
SQLModel.metadata.drop_all(engine)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as sync_session:
|
||||
yield sync_session
|
||||
@@ -41,8 +41,15 @@ async def default_settings():
|
||||
"""Provide default settings for tests."""
|
||||
settings = get_settings(database_url="sqlite:///:memory:")
|
||||
db_url = get_database_url(settings)
|
||||
await create_all(engine=get_engine(database_url=db_url))
|
||||
return settings
|
||||
engine = get_engine(database_url=db_url)
|
||||
|
||||
# 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
|
||||
@@ -52,8 +59,6 @@ async def async_session(default_settings: Settings):
|
||||
async with session_scope(database_url=db_url) as async_session:
|
||||
yield async_session
|
||||
|
||||
await dispose_session_factory(db_url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_session_factory(default_settings: Settings):
|
||||
|
||||
@@ -8,8 +8,9 @@ from transcription.config import Settings
|
||||
from transcription.db.models import Job
|
||||
from transcription.db.models import JobStatus
|
||||
from transcription.providers.base import TranscriptionResult
|
||||
from transcription.services import ServiceBundle
|
||||
from transcription.services.store import create_upload_job
|
||||
from transcription.worker import process_next_queued_job
|
||||
from transcription.services.workflows import advance_job
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -58,14 +59,18 @@ class TestPipelineSuccessFlow:
|
||||
_fake_transcribe_document_image,
|
||||
)
|
||||
|
||||
processed = await process_next_queued_job(session=async_session)
|
||||
services = ServiceBundle()
|
||||
queued_job = await services.jobs.read_next_queued_job(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 async_session.get(Job, upload_result.job_id)
|
||||
|
||||
assert processed is True
|
||||
assert job is not None
|
||||
assert job.status == JobStatus.TRANSCRIBED
|
||||
assert job.text == "Pipeline transcript"
|
||||
assert job.error_detail is None
|
||||
assert any(job_source.raw_transcription == "Pipeline transcript" for job_source in job.job_sources)
|
||||
assert all(job_source.error_detail is None for job_source in job.job_sources)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -98,14 +103,19 @@ class TestPipelineFailureFlow:
|
||||
_fake_transcribe_document_image,
|
||||
)
|
||||
|
||||
processed = await process_next_queued_job(session=async_session)
|
||||
services = ServiceBundle()
|
||||
queued_job = await services.jobs.read_next_queued_job(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 async_session.get(Job, upload_result.job_id)
|
||||
|
||||
assert processed is True
|
||||
assert job is not None
|
||||
assert job.status == JobStatus.FAILED
|
||||
assert job.text is None
|
||||
assert job.error_detail is not None
|
||||
assert "pipeline provider failure" in job.error_detail
|
||||
assert "[internal_unexpected_error]" in job.error_detail
|
||||
assert "error_id=" in job.error_detail
|
||||
assert all(job_source.raw_transcription is None for job_source in job.job_sources)
|
||||
assert any(job_source.error_detail is not None for job_source in job.job_sources)
|
||||
error_detail = next(job_source.error_detail for job_source in job.job_sources if job_source.error_detail is not None)
|
||||
assert "pipeline provider failure" in error_detail
|
||||
assert "[internal_unexpected_error]" in error_detail
|
||||
assert "error_id=" in error_detail
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
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 PersonDeleteBlockedError
|
||||
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):
|
||||
service = DocumentService(session_factory=default_session_factory)
|
||||
|
||||
document = await service.create_document(
|
||||
Document(
|
||||
id=uuid4(),
|
||||
name="free-delete",
|
||||
document_type="memo",
|
||||
)
|
||||
)
|
||||
|
||||
await service.delete_document(document)
|
||||
|
||||
with pytest.raises(DocumentError):
|
||||
await service.read_document_detail(document.id)
|
||||
|
||||
|
||||
@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_blocks_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,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(PersonDeleteBlockedError):
|
||||
await service.delete_person(person)
|
||||
|
||||
|
||||
@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)
|
||||
@@ -1,12 +1,18 @@
|
||||
from uuid import uuid4
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from transcription.db.models import Document
|
||||
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 Source
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.services.jobs import JobDeleteBlockedError
|
||||
from transcription.services.jobs import JobService
|
||||
|
||||
|
||||
@@ -66,13 +72,20 @@ class TestJobService:
|
||||
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="letter.jpg",
|
||||
filename="stored-letter.jpg",
|
||||
file_path="/uploads/stored-letter.jpg",
|
||||
)
|
||||
session.add(source)
|
||||
await session.flush()
|
||||
session.add(
|
||||
Source(
|
||||
document_id=document.id,
|
||||
JobSource(
|
||||
job_id=job.id,
|
||||
upload_name="letter.jpg",
|
||||
filename="stored-letter.jpg",
|
||||
file_path="/uploads/stored-letter.jpg",
|
||||
source_id=source.id,
|
||||
status=JobSourceStatus.PENDING,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
@@ -90,11 +103,124 @@ class TestJobService:
|
||||
document = Document(id=uuid4(), name="ordered-doc")
|
||||
await document_service.create_document(document=document)
|
||||
|
||||
first = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||
second = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||
created_at = datetime.now(UTC)
|
||||
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=second)
|
||||
|
||||
next_job = await job_service.read_next_queued_job()
|
||||
assert next_job is not None
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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_job_for_document
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
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}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for revision behavior in TranscriptionService."""
|
||||
"""Tests for source revision behavior in TranscriptionService."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -6,16 +6,20 @@ import pytest
|
||||
|
||||
from transcription.db.models import Document
|
||||
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 Source
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.services.jobs import JobService
|
||||
from transcription.services.transcription import SourceDeleteBlockedError
|
||||
from transcription.services.transcription import TranscriptionNotFoundError
|
||||
from transcription.services.transcription import TranscriptionService
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestTranscriptionServiceRevisionUpsert:
|
||||
"""Verify optional single-revision create/update semantics."""
|
||||
"""Verify page-level source revision semantics."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
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)
|
||||
|
||||
source = Source(
|
||||
document_id=document.id,
|
||||
job_id=job.id,
|
||||
page_number=1,
|
||||
upload_name="source.jpg",
|
||||
filename="source.jpg",
|
||||
file_path="uploads/source.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)
|
||||
|
||||
revision = await transcriptions.upsert_revision_for_source(source_id=source.id, text="User revision")
|
||||
fetched = await transcriptions.read_revision_by_source(source.id)
|
||||
|
||||
assert revision.source_id == source.id
|
||||
assert revision.text == "User revision"
|
||||
assert revision.id == source.id
|
||||
assert revision.revised_text == "User revision"
|
||||
assert fetched is not None
|
||||
assert fetched.id == revision.id
|
||||
assert fetched.text == "User revision"
|
||||
assert fetched.id == source.id
|
||||
assert fetched.revised_text == "User revision"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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")
|
||||
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)
|
||||
|
||||
source = Source(
|
||||
document_id=document.id,
|
||||
job_id=job.id,
|
||||
page_number=1,
|
||||
upload_name="source.jpg",
|
||||
filename="source.jpg",
|
||||
file_path="uploads/source.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)
|
||||
|
||||
@@ -79,7 +87,70 @@ class TestTranscriptionServiceRevisionUpsert:
|
||||
revisions = await transcriptions.list_revisions_by_job(job.id)
|
||||
|
||||
assert first.id == second.id
|
||||
assert second.text == "Revision v2"
|
||||
assert second.revised_text == "Revision v2"
|
||||
assert len(revisions) == 1
|
||||
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):
|
||||
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-success")
|
||||
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="delete.jpg",
|
||||
filename="delete.jpg",
|
||||
file_path="uploads/delete.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)
|
||||
|
||||
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)
|
||||
|
||||
@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)
|
||||
|
||||
@@ -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
@@ -27,7 +27,7 @@ class TestAppLifespan:
|
||||
"""Startup initializes logging, schema, directories, and worker resources."""
|
||||
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):
|
||||
calls.append("schema")
|
||||
@@ -80,7 +80,7 @@ class TestAppLifespan:
|
||||
"""Shutdown signals and stops worker resources cleanly."""
|
||||
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):
|
||||
calls.append("schema")
|
||||
|
||||
+33
-3
@@ -7,6 +7,7 @@ from pydantic import ValidationError
|
||||
|
||||
from transcription.config import Provider
|
||||
from transcription.config import Settings
|
||||
from transcription.config import parse_cli_settings
|
||||
|
||||
|
||||
def _make_settings(**overrides) -> Settings:
|
||||
@@ -31,6 +32,30 @@ class TestSettingsLoading:
|
||||
with pytest.raises(ValidationError):
|
||||
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:
|
||||
"""Verify provider enum defaults and validation."""
|
||||
@@ -46,13 +71,18 @@ class TestProviderSettings:
|
||||
with pytest.raises(ValidationError):
|
||||
_make_settings(provider="not-a-provider")
|
||||
|
||||
def test_optional_fields_default_to_none(self):
|
||||
"""provider_model, openrouter_http_referer, and openrouter_app_title are None when unset."""
|
||||
def test_optional_provider_header_fields_default_to_none(self):
|
||||
"""openrouter_http_referer and openrouter_app_title are None when unset."""
|
||||
settings = _make_settings()
|
||||
assert settings.provider_model is None
|
||||
assert settings.openrouter_http_referer 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:
|
||||
"""Verify filesystem path field types."""
|
||||
|
||||
+5
-2
@@ -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
|
||||
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()))
|
||||
|
||||
assert "document" in table_names
|
||||
assert "person" in table_names
|
||||
assert "document_person" in table_names
|
||||
assert "job" 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:
|
||||
await dispose_database_runtime()
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""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_uses_cli_factory_import_string(monkeypatch):
|
||||
"""Startup uses an importable factory so Uvicorn owns app creation."""
|
||||
settings = SimpleNamespace(host="127.0.0.1", port=8123, log_level="debug", reload=False)
|
||||
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",
|
||||
"factory": True,
|
||||
"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
|
||||
+124
-43
@@ -1,4 +1,4 @@
|
||||
"""Tests for transcription.models — Document, Source, Job, Revision persistence and relationships."""
|
||||
"""Tests for the V2 SQLModel persistence layer and relationships."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
@@ -6,14 +6,22 @@ import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
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 JobStatus
|
||||
from transcription.db.models import Revision
|
||||
from transcription.db.models import Person
|
||||
from transcription.db.models import Source
|
||||
|
||||
|
||||
def _make_document(**overrides) -> Document:
|
||||
defaults = {"name": "letter bundle"}
|
||||
defaults = {
|
||||
"name": "letter bundle",
|
||||
"document_type": "letter",
|
||||
"notes": "Family correspondence",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return Document(**defaults)
|
||||
|
||||
@@ -26,6 +34,16 @@ def _persist_document(session) -> 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:
|
||||
job = Job(document_id=document.id)
|
||||
session.add(job)
|
||||
@@ -34,13 +52,14 @@ def _persist_job(session, document: Document) -> 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 = {
|
||||
"document_id": document.id,
|
||||
"job_id": job.id,
|
||||
"page_number": page_number,
|
||||
"upload_name": "letter.jpg",
|
||||
"filename": "stored-letter.jpg",
|
||||
"file_path": "/uploads/stored-letter.jpg",
|
||||
"raw_transcription": "Original machine text",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
source = Source(**defaults)
|
||||
@@ -50,6 +69,20 @@ def _persist_source(session, document: Document, job: Job, **overrides) -> Sourc
|
||||
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:
|
||||
def test_can_be_persisted(self, session):
|
||||
document = _persist_document(session)
|
||||
@@ -60,6 +93,8 @@ class TestDocumentModel:
|
||||
def test_defaults_are_populated(self, session):
|
||||
document = _persist_document(session)
|
||||
assert isinstance(document.id, UUID)
|
||||
assert document.created_at is not None
|
||||
assert document.updated_at is not None
|
||||
|
||||
|
||||
class TestJobModel:
|
||||
@@ -78,7 +113,7 @@ class TestJobModel:
|
||||
assert job.date_created 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)
|
||||
job = _persist_job(session, document)
|
||||
|
||||
@@ -87,79 +122,125 @@ class TestJobModel:
|
||||
session.commit()
|
||||
session.refresh(job)
|
||||
|
||||
job.status = JobStatus.TRANSCRIBED
|
||||
job.status = JobStatus.COMPLETED
|
||||
session.add(job)
|
||||
session.commit()
|
||||
session.refresh(job)
|
||||
|
||||
assert job.status == JobStatus.TRANSCRIBED
|
||||
assert job.status == JobStatus.COMPLETED
|
||||
|
||||
|
||||
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)
|
||||
job = _persist_job(session, document)
|
||||
source = _persist_source(session, document, job)
|
||||
source = _persist_source(session, document)
|
||||
|
||||
fetched = session.get(Source, source.id)
|
||||
assert fetched is not None
|
||||
assert fetched.document_id == document.id
|
||||
assert fetched.job_id == job.id
|
||||
assert fetched.page_number == 1
|
||||
assert fetched.date_uploaded is not None
|
||||
|
||||
|
||||
class TestRevisionModel:
|
||||
def test_revision_persists_for_source(self, session):
|
||||
def test_revised_text_is_supported(self, session):
|
||||
document = _persist_document(session)
|
||||
job = _persist_job(session, document)
|
||||
source = _persist_source(session, document, job)
|
||||
source = _persist_source(session, document, revised_text="Edited output")
|
||||
|
||||
revision = Revision(source_id=source.id, text="Edited revision text")
|
||||
session.add(revision)
|
||||
session.commit()
|
||||
session.refresh(revision)
|
||||
|
||||
fetched = session.get(Revision, revision.id)
|
||||
fetched = session.get(Source, source.id)
|
||||
assert fetched is not None
|
||||
assert fetched.text == "Edited revision text"
|
||||
assert fetched.date_created is not None
|
||||
assert fetched.revised_text == "Edited output"
|
||||
|
||||
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)
|
||||
job = _persist_job(session, document)
|
||||
source = _persist_source(session, document, job)
|
||||
person = _persist_person(session)
|
||||
|
||||
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.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)
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
|
||||
|
||||
class TestRelationships:
|
||||
def test_document_exposes_jobs_and_sources(self, session):
|
||||
class TestJobSourceModel:
|
||||
def test_job_source_persists_json_payloads(self, session):
|
||||
document = _persist_document(session)
|
||||
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)
|
||||
assert len(document.jobs) == 1
|
||||
assert len(document.sources) == 1
|
||||
assert len(document.document_people) == 1
|
||||
|
||||
def test_source_exposes_optional_single_revision(self, session):
|
||||
def test_document_exposes_author_via_role_filtered_relation(self, session):
|
||||
document = _persist_document(session)
|
||||
job = _persist_job(session, document)
|
||||
source = _persist_source(session, document, job)
|
||||
author = _persist_person(session, full_name="Author Person")
|
||||
recipient = _persist_person(session, full_name="Recipient Person")
|
||||
|
||||
assert source.revision is None
|
||||
|
||||
revision = Revision(source_id=source.id, text="Edited")
|
||||
session.add(revision)
|
||||
session.add(DocumentPerson(document_id=document.id, person_id=author.id, role=DocumentPersonRole.AUTHOR))
|
||||
session.add(DocumentPerson(document_id=document.id, person_id=recipient.id, role=DocumentPersonRole.RECIPIENT))
|
||||
session.commit()
|
||||
|
||||
session.refresh(source)
|
||||
assert source.revision is not None
|
||||
assert source.revision.text == "Edited"
|
||||
session.refresh(document)
|
||||
assert [person.full_name for person in document.authors] == ["Author Person"]
|
||||
assert document.author is not None
|
||||
assert document.author.full_name == "Author Person"
|
||||
|
||||
def test_person_exposes_authored_documents_via_role_filtered_relation(self, session):
|
||||
authored_document = _make_document(name="Authored Doc")
|
||||
recipient_only_document = _make_document(name="Recipient Doc")
|
||||
session.add(authored_document)
|
||||
session.add(recipient_only_document)
|
||||
session.commit()
|
||||
session.refresh(authored_document)
|
||||
session.refresh(recipient_only_document)
|
||||
person = _persist_person(session, full_name="Dual Role Person")
|
||||
|
||||
session.add(
|
||||
DocumentPerson(
|
||||
document_id=authored_document.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
DocumentPerson(
|
||||
document_id=recipient_only_document.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.RECIPIENT,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
session.refresh(person)
|
||||
assert [document.name for document in person.authored_documents] == ["Authored Doc"]
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Tests for global UI theme registration."""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from transcription.ui.pages 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
|
||||
+20
-8
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
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 session_scope
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
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 Revision
|
||||
from transcription.db.models import Person
|
||||
from transcription.db.models import Source
|
||||
|
||||
RevisionSeed = str
|
||||
@@ -55,10 +60,12 @@ def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
|
||||
|
||||
async def _clear() -> None:
|
||||
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(Job))
|
||||
await session.exec(delete(Document))
|
||||
await session.exec(delete(Person))
|
||||
await session.commit()
|
||||
|
||||
asyncio.run(_clear())
|
||||
@@ -94,8 +101,6 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
||||
document_id=document.id,
|
||||
status=status,
|
||||
retry_count=0,
|
||||
text=transcription_text,
|
||||
error_detail=error_detail,
|
||||
provider="openrouter",
|
||||
model="google/gemini-2.5-flash",
|
||||
prompt_name="transcribe_document.md",
|
||||
@@ -105,7 +110,6 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
||||
|
||||
source = Source(
|
||||
document_id=document.id,
|
||||
job_id=job.id,
|
||||
upload_name=filename,
|
||||
filename=filename,
|
||||
file_path=str(stored_path),
|
||||
@@ -113,14 +117,22 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
||||
session.add(source)
|
||||
await session.flush()
|
||||
|
||||
if revision_text is not None:
|
||||
if transcription_text is not None or error_detail is not None:
|
||||
session.add(
|
||||
Revision(
|
||||
JobSource(
|
||||
job_id=job.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()
|
||||
return job.id
|
||||
|
||||
|
||||
@@ -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
|
||||
+67
-33
@@ -1,10 +1,12 @@
|
||||
"""Tests for the jobs page route."""
|
||||
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from transcription.db import session_scope
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import JobStatus
|
||||
|
||||
|
||||
@@ -18,7 +20,39 @@ class TestPageRendering:
|
||||
response = client.get("/ui/jobs")
|
||||
|
||||
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):
|
||||
"""GET /ui/jobs lists seeded jobs from the in-memory database."""
|
||||
@@ -31,29 +65,28 @@ class TestPageRendering:
|
||||
assert "sample.pdf" in response.text
|
||||
assert "transcribed" in response.text
|
||||
|
||||
def test_job_detail_page_renders_seeded_job(self, app_client, seed_job):
|
||||
"""GET /ui/jobs/{job_id} renders detail content for a real seeded job."""
|
||||
def test_job_detail_page_renders_document_links(self, app_client, seed_job):
|
||||
"""GET /ui/jobs/{job_id} renders document-scoped navigation links."""
|
||||
_, client = app_client
|
||||
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "single_page_pdf.pdf"
|
||||
job_id = seed_job(
|
||||
filename="detail.pdf",
|
||||
status=JobStatus.TRANSCRIBED,
|
||||
transcription_text="original text",
|
||||
revision_text="hello",
|
||||
source_file=fixture_path,
|
||||
)
|
||||
|
||||
response = client.get(f"/ui/jobs/{job_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Original Transcription" in response.text
|
||||
assert "detail.pdf" in response.text
|
||||
assert "Revision" in response.text
|
||||
assert "Revision" in response.text
|
||||
assert "hello" in response.text
|
||||
assert "original text" in response.text
|
||||
assert "Document preview" in response.text
|
||||
assert "/uploads/detail.pdf" in response.text
|
||||
assert "Job" in response.text
|
||||
assert "Provider:" in response.text
|
||||
assert "Model:" in response.text
|
||||
assert "Prompt:" in response.text
|
||||
assert "Retry Count:" in response.text
|
||||
assert "Last Updated:" in response.text
|
||||
assert "document links" in response.text.lower()
|
||||
assert "Sources" in response.text
|
||||
assert "Jobs" in response.text
|
||||
assert "Delete job" not in response.text
|
||||
|
||||
def test_job_detail_page_rejects_invalid_id(self, app_client):
|
||||
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
|
||||
@@ -72,36 +105,37 @@ class TestPageRendering:
|
||||
assert response.status_code == 200
|
||||
assert "Job not found" in response.text
|
||||
|
||||
def test_job_detail_page_shows_revision_editor_when_none_exists(self, app_client, seed_job):
|
||||
"""GET /ui/jobs/{job_id} renders revision editor and create action for sources with no revision."""
|
||||
def test_job_detail_page_hides_delete_action(self, app_client, seed_job):
|
||||
"""GET /ui/jobs/{job_id} does not expose job deletion controls in this revision."""
|
||||
_, client = app_client
|
||||
job_id = seed_job(
|
||||
filename="no-revision.pdf",
|
||||
status=JobStatus.TRANSCRIBED,
|
||||
transcription_text="original text",
|
||||
revision_text=None,
|
||||
)
|
||||
|
||||
response = client.get(f"/ui/jobs/{job_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Revision Editor" in response.text
|
||||
assert "Create revision" in response.text
|
||||
assert "No revision exists for this source." in response.text
|
||||
assert "Delete job" not in response.text
|
||||
|
||||
def test_job_detail_page_shows_update_action_for_existing_revision(self, app_client, seed_job):
|
||||
"""GET /ui/jobs/{job_id} renders revision editor with update action when revision exists."""
|
||||
def test_job_delete_page_shows_confirmation_when_not_processing(self, app_client, seed_job):
|
||||
_, client = app_client
|
||||
job_id = seed_job(
|
||||
filename="with-revision.pdf",
|
||||
status=JobStatus.TRANSCRIBED,
|
||||
transcription_text="original text",
|
||||
revision_text="hello",
|
||||
)
|
||||
job_id = seed_job(filename="delete-ready.pdf", status=JobStatus.TRANSCRIBED)
|
||||
|
||||
response = client.get(f"/ui/jobs/{job_id}")
|
||||
response = client.get(f"/ui/jobs/{job_id}/delete")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Revision Editor" in response.text
|
||||
assert "Update revision" in response.text
|
||||
assert "hello" in response.text
|
||||
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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user