generated from john/python-template
Compare commits
76
Commits
doc_update
...
63373bf24d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63373bf24d | ||
|
|
936af9b1d3 | ||
|
|
a78b58ff40 | ||
|
|
aed827babe | ||
|
|
178347e086 | ||
|
|
c9f5dca064 | ||
|
|
6bd4cbb0a7 | ||
|
|
28811d79ce | ||
|
|
171132919d | ||
|
|
89cf69f8a2 | ||
|
|
1e8d8572d4 | ||
|
|
888a8c380a | ||
|
|
b8be27f0c9 | ||
|
|
8d5aec4301 | ||
|
|
0ace10269f | ||
|
|
ccf2c78ff4 | ||
|
|
4b3baf5a3e | ||
|
|
9b4d6f0340 | ||
|
|
5753eb0135 | ||
|
|
e6549277c6 | ||
|
|
b59d3da23e | ||
|
|
4bf6c9e2f3 | ||
|
|
4dac9349c1 | ||
|
|
5a741de0a9 | ||
|
|
58faa00d7b | ||
|
|
89cac3c378 | ||
|
|
4b5e7ac23e | ||
|
|
21a7e83563 | ||
|
|
fce7107863 | ||
|
|
5090e238ff | ||
|
|
75f263c2b6 | ||
|
|
be152a028e | ||
|
|
9219adaf0c | ||
|
|
fd3ca60008 | ||
|
|
72bc96ab3a | ||
|
|
4eeb552273 | ||
|
|
d9f5fbb1a4 | ||
|
|
271633d1d5 | ||
|
|
6c6589d8ff | ||
|
|
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 | ||
|
|
ce8fcce6b0 | ||
|
|
6b5b0500b3 | ||
|
|
bbf7fe28c2 | ||
|
|
c4d25c1be8 | ||
|
|
1fa5eb1127 | ||
|
|
ec6617a1c4 | ||
|
|
9eb0f40c08 | ||
|
|
f769d29da1 | ||
|
|
8afc462a6d | ||
|
|
3d6daec561 | ||
|
|
1cc2f319d5 |
+53
-7
@@ -1,8 +1,54 @@
|
||||
PROVIDER=openrouter
|
||||
OPENROUTER_API_KEY=sk-or-...
|
||||
# PROVIDER_MODEL= # optional: OpenRouter adapter supplies default
|
||||
# --- NiceGUI Server ---
|
||||
# HOST=`0.0.0.0` (default)
|
||||
# PORT=8000 (default)
|
||||
# LOG_LEVEL: [`critical`, `error`, `warning`, `info` (default), `debug`, `trace`]
|
||||
# RELOAD=false (default)
|
||||
|
||||
# --- AI provider ---
|
||||
# PROVIDER=[`openrouter`(default), `google_genai`]
|
||||
PROVIDER=openrouter
|
||||
# OPENROUTER_API_KEY - Required when `PROVIDER=openrouter`
|
||||
OPENROUTER_API_KEY=your-api-key-goes-here
|
||||
# GEMINI_API_KEY - Required when `PROVIDER=google_genai`
|
||||
# PROVIDER_MODEL= specify model. If left blank OpenRouter will supply default.
|
||||
PROVIDER_MODEL=google/gemini-2.5-flash
|
||||
# OPENROUTER_HTTP_REFERER=https://example.com
|
||||
# OPENROUTER_APP_TITLE=Historical Transcription MVP
|
||||
# DATABASE_URL=sqlite:///./transcription.db
|
||||
# UPLOAD_DIR=./uploads
|
||||
# PROMPT_DIR=./prompts
|
||||
# OPENROUTER_APP_TITLE="Google: Gemini 2.5 Flash (openrouter)"
|
||||
|
||||
# --- runtime environment ---
|
||||
# ENVIRONMENT: [`development`(default), `test`, `production`]
|
||||
|
||||
# --- persistence ---
|
||||
# Use nested settings with double underscore because env_nested_delimiter="__".
|
||||
# SQLite example:
|
||||
# DATABASE__DRIVER=sqlite
|
||||
# DATABASE__PATH=app.db
|
||||
#
|
||||
# SQLite with custom relative path:
|
||||
# DATABASE__DRIVER=sqlite
|
||||
DATABASE__PATH=./data/transcription.db
|
||||
#
|
||||
# Postgres example:
|
||||
# DATABASE__DRIVER=postgres
|
||||
# DATABASE__HOST=localhost
|
||||
# DATABASE__PORT=5432
|
||||
# DATABASE__DATABASE=transcription
|
||||
# DATABASE__USER=postgres
|
||||
# DATABASE__PASSWORD=change-me
|
||||
#
|
||||
# Optional persistence flags:
|
||||
# BOOTSTRAP_SCHEMA_ON_STARTUP=false
|
||||
# SQLITE_CHECK_SAME_THREAD=false
|
||||
|
||||
# --- filesystem paths ---
|
||||
UPLOAD_DIR="./data"
|
||||
PROMPT_DIR="./prompts"
|
||||
|
||||
# --- worker reliability ---
|
||||
WORKER_MAX_RETRIES=0
|
||||
WORKER_RETRY_BACKOFF_SECONDS=0
|
||||
# WORKER_PROVIDER_TIMEOUT_SECONDS=[0-20]
|
||||
WORKER_PROVIDER_TIMEOUT_SECONDS=20
|
||||
WORKER_MIN_TRANSCRIPTION_CHARS=0
|
||||
WORKER_MIN_TRANSCRIPTION_LINES=0
|
||||
WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
|
||||
|
||||
@@ -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 all application CSS in `ui/static/theme.css`; do not add page- or component-specific stylesheets or embed style blocks in Python components.
|
||||
- Load `theme.css` once from the composition root with `ui.add_css(..., shared=True)`.
|
||||
- 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 resource path with `functools.cache` or an equivalent unbounded `lru_cache`. Cache the immutable stylesheet text to avoid repeated resource I/O; keep NiceGUI registration at the composition root.
|
||||
- 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.
|
||||
|
||||
@@ -17,3 +17,7 @@ wheels/
|
||||
|
||||
# Document images
|
||||
uploads/*
|
||||
data/*
|
||||
|
||||
# Local destructive-test backups
|
||||
.test-backups/
|
||||
|
||||
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",
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"chat.sessionSync.enabled": true
|
||||
}
|
||||
@@ -22,18 +22,83 @@ 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
|
||||
DEFAULT_PROMPT_NAME=transcribe_document.md
|
||||
# TRANSCRIPTION_TEMPERATURE=0.2 # range: 0.0-2.0
|
||||
# TRANSCRIPTION_TOP_P=0.9 # range: 0.0-1.0
|
||||
```
|
||||
|
||||
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 +110,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
|
||||
|
||||
@@ -72,7 +141,65 @@ uv run uvicorn transcription.app:create_app --factory --reload
|
||||
|
||||
## Prompt artifacts
|
||||
|
||||
Prompt files are stored in `prompts/` and loaded from `PROMPT_DIR` (default: `./prompts`).
|
||||
Prompt files are stored directly in `PROMPT_DIR` (default: `./prompts`). `DEFAULT_PROMPT_NAME` must be a filename,
|
||||
not a path. Each job snapshots the validated prompt text, SHA-256 hash, and sampling values for reproducibility.
|
||||
|
||||
The canonical MVP prompt is:
|
||||
- `prompts/transcribe_document.md`
|
||||
|
||||
## Destructive test procedure (with data backup)
|
||||
|
||||
AI execution policy: before the first unit-test run in a test/fix cycle, create one backup of `./data`. Reuse that same backup for every subsequent test run in the cycle. After tests succeed, always pause and ask whether to restore now.
|
||||
|
||||
Use the cross-platform Python wrapper below whenever an AI agent runs tests against this repository.
|
||||
|
||||
1. Create one backup of `./data` and mark it as the active test-cycle backup.
|
||||
2. Run your test command.
|
||||
3. On failure, fix the errors and run the wrapper again; it reuses the active backup and never backs up post-test data.
|
||||
4. On success, always prompt whether to restore now (do not auto-restore unless explicitly approved).
|
||||
5. Close the cycle only by restoring the active backup or explicitly accepting the current data.
|
||||
|
||||
Preflight behavior:
|
||||
|
||||
- Backup preflight is warning-only when `data/transcription.db` appears in use.
|
||||
- Restore preflight is blocking: the script prompts you to close conflicting applications, then type `retry` to re-check or `cancel` to skip restore.
|
||||
|
||||
### Run with confirmation-gated restore (default)
|
||||
|
||||
```bash
|
||||
uv run python tools/run_destructive_tests.py -- pytest tests/services/test_job_service.py tests/ui/test_jobs_page.py
|
||||
```
|
||||
|
||||
After tests pass, the script asks whether to restore backup immediately.
|
||||
|
||||
This is the required default mode for AI-assisted test runs because it gives time to verify and accept code changes before any restoration happens.
|
||||
|
||||
### Run with automatic restore (non-interactive)
|
||||
|
||||
```bash
|
||||
uv run python tools/run_destructive_tests.py --auto-restore -- pytest
|
||||
```
|
||||
|
||||
### Run without terminal prompt (decide restore later)
|
||||
|
||||
```bash
|
||||
uv run python tools/run_destructive_tests.py --skip-restore-prompt -- pytest
|
||||
```
|
||||
|
||||
This keeps both the current post-test state and the backup, so restore can be decided explicitly later.
|
||||
|
||||
Repeated wrapper invocations reuse the backup recorded in `.test-backups/.active-backup`. If that backup is missing, the wrapper stops rather than creating a replacement from potentially destructive post-test data.
|
||||
|
||||
### Restore later from a saved backup
|
||||
|
||||
```bash
|
||||
uv run python tools/run_destructive_tests.py --restore-from data-backup-YYYYMMDD-HHMMSS
|
||||
```
|
||||
|
||||
To keep the current data and close the active cycle without restoring:
|
||||
|
||||
```bash
|
||||
uv run python tools/run_destructive_tests.py --accept-current-data
|
||||
```
|
||||
|
||||
Backups are stored in `.test-backups/` and ignored by git.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
# System Architecture (Version 2)
|
||||
|
||||
This document describes the V2 production architecture of the personal historical-document transcription system.
|
||||
|
||||
## Architecture Objectives
|
||||
|
||||
* Preserve source material as immutable transcribed text alongside page-level spatial AI metadata.
|
||||
* Support batching multi-image and folder uploads cleanly into sequential pages (`page_number`).
|
||||
* Leverage asynchronous worker pools (`asyncio`) for parallel single-image API execution bounded by rate limiters (`asyncio.Semaphore`).
|
||||
* Migrate persistence to PostgreSQL using native `UUID`, `TIMESTAMPTZ`, and `JSONB` document storage.
|
||||
* Standardize all data validation, API parsing, and database models on **Pydantic V2**.
|
||||
* Support rich historical attribution (multi-author and multi-recipient relationships).
|
||||
|
||||
## Runtime Topology
|
||||
|
||||
The V2 runtime operates as an asynchronous Python application:
|
||||
|
||||
* FastAPI + NiceGUI web application process.
|
||||
* In-process `asyncio` background task orchestrator for parallel API execution.
|
||||
* Relational persistence via PostgreSQL (using `asyncpg` or `psycopg3`).
|
||||
* Pydantic V2 validation layer wrapping API payloads and PostgreSQL `JSONB` schemas.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U[Browser User] --> A[FastAPI + NiceGUI App]
|
||||
A --> W[Asyncio Worker Engine]
|
||||
A --> DB[(PostgreSQL Database)]
|
||||
W --> P[Vision Provider APIs\nOpenAI / Claude]
|
||||
W --> DB
|
||||
```
|
||||
|
||||
## Lifecycle Ownership
|
||||
|
||||
Application lifespan owns runtime setup/teardown:
|
||||
|
||||
* Initialize environment logging and Pydantic configuration.
|
||||
* Manage asynchronous PostgreSQL connection pools (`asyncpg` / `psycopg3`).
|
||||
* Execute database migrations and index initialization.
|
||||
* Recover stale processing jobs on startup.
|
||||
* Manage graceful shutdown of active `asyncio` worker pools.
|
||||
|
||||
## Layered Module Structure
|
||||
|
||||
### Interface Layer
|
||||
|
||||
* `src/transcription/ui/**` (NiceGUI pages, multi-page renderers, person cards)
|
||||
* `src/transcription/api/**` (FastAPI routes and JSON error handlers)
|
||||
|
||||
### Application & Async Worker Layer
|
||||
|
||||
* `src/transcription/services/workflows.py`
|
||||
* `src/transcription/worker.py`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Batch orchestration and status transitions (`queued` -> `processing` -> `completed` | `partial_success` | `failed`).
|
||||
* Parallel single-image API execution using `asyncio.gather` bounded by `asyncio.Semaphore`.
|
||||
* Pydantic schema parsing (`PageAIMetadata`) and validation prior to database storage.
|
||||
|
||||
### Domain & Service Layer
|
||||
|
||||
* `src/transcription/models/*.py` (Pydantic V2 schemas and entity definitions)
|
||||
* `src/transcription/services/*.py` (Transactional operations for `Document`, `Person`, `Source`, `Job`, and `JobSource`)
|
||||
|
||||
### Infrastructure Layer
|
||||
|
||||
* `src/transcription/db/**` (PostgreSQL connection pooling and raw parameterized SQL execution)
|
||||
* `src/transcription/providers/**` (OpenAI & Anthropic Vision SDK adapters)
|
||||
|
||||
## Processing Workflow
|
||||
|
||||
1. User uploads a folder or batch of images for a `Document`.
|
||||
2. System creates `Document`, `Job(status='queued')`, and ordered `Source` pages (`page_number = 1..N`).
|
||||
3. Worker claims job, sets `Job.status = 'processing'`, and spawns parallel `asyncio` tasks bounded by semaphore.
|
||||
4. Each task calls Vision API for a **single** `Source` image.
|
||||
5. On task completion:
|
||||
* Writes a `JobSource` record containing `status='transcribed'`, `raw_transcription`, `ai_metadata` (bounding boxes/confidence), and `raw_api_response`.
|
||||
* Caches active text to `Source.raw_transcription`.
|
||||
|
||||
|
||||
6. On page failure:
|
||||
* Writes `JobSource` record with `status='failed'` and `error_detail`.
|
||||
|
||||
|
||||
7. Once all page tasks resolve:
|
||||
* Marks `Job.status` as `completed` (100% success), `partial_success` (at least 1 success, 1 failure), or `failed` (all failed).
|
||||
|
||||
|
||||
|
||||
## Domain Ownership & Invariants
|
||||
|
||||
* **Immutable AI Outputs:** `source.raw_transcription` and `job_source.raw_transcription` store original, point-in-time machine output and are immutable.
|
||||
* **Inlined Revisions:** Human corrections occur on `source.revised_text`. UI renders `COALESCE(revised_text, raw_transcription)`.
|
||||
* **Sequential Integrity:** Multi-page documents are strictly ordered by `source.page_number ASC`.
|
||||
* **Page Execution Isolation:** A failure on one page image does not invalidate successful transcriptions on sister pages in the same batch job.
|
||||
|
||||
## Data Model Summary
|
||||
|
||||
* `Document` has many `Source` pages, many `Job` runs, and many `Person` records via `DocumentPerson` junction (`author` or `recipient`).
|
||||
* `Source` belongs to one `Document` and can be processed across many `JobSource` executions.
|
||||
* `Job` has many `JobSource` execution records.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
* Unit tests for Pydantic V2 schemas, custom validators, and JSONB serialization.
|
||||
* Integration tests for async PostgreSQL connection handling and parameterized queries.
|
||||
* Async workflow tests using mock AI providers to verify `partial_success` and retry logic.
|
||||
* UI integration tests for multi-page rendering and person management.
|
||||
|
||||
---
|
||||
|
||||
## Technology References
|
||||
|
||||
- [FastAPI documentation](https://fastapi.tiangolo.com/)
|
||||
- [NiceGUI documentation](https://nicegui.io/documentation)
|
||||
- [PostgreSQL documentation](https://www.postgresql.org/docs/)
|
||||
- [Python asyncio](https://docs.python.org/3/library/asyncio.html#module-asyncio)
|
||||
- [Pydantic Validation](https://pydantic.dev/docs/validation/latest/get-started/)
|
||||
- [Pydantic AI](https://pydantic.dev/docs/ai/overview/)
|
||||
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v1.md)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
-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);
|
||||
```
|
||||
@@ -1,88 +0,0 @@
|
||||
# Error Handling Policy (Version 2)
|
||||
|
||||
This document defines the canonical error-handling policy for the V2 document transcription system.
|
||||
|
||||
## Error Handling Objectives
|
||||
|
||||
* Make failures visible in clear, actionable language at both the document and individual page levels.
|
||||
* Support **isolated failure handling** in multi-image batches so single page errors do not crash an entire batch job.
|
||||
* Preserve diagnostic detail (Pydantic validation errors, raw provider responses) in PostgreSQL `JSONB` for fast troubleshooting.
|
||||
* Ensure consistent error envelope structure across API, UI, and async worker boundaries.
|
||||
|
||||
## Scope And Authority
|
||||
|
||||
Governs error behavior across NiceGUI pages, FastAPI routes, service orchestration, `asyncio` background tasks, PostgreSQL interactions, and AI provider adapters.
|
||||
|
||||
## Error Taxonomy
|
||||
|
||||
| Category | Definition | Retriable |
|
||||
| --- | --- | --- |
|
||||
| `validation_error` | Pydantic payload or parameter schema validation failure | no |
|
||||
| `user_input_error` | Unacceptable user file (unsupported image type, corrupt file) | no |
|
||||
| `not_found_error` | Requested resource (`Document`, `Source`, `Person`, `Job`) missing | no |
|
||||
| `conflict_error` | Operation violates state constraints (e.g., duplicate `document_person` role) | no |
|
||||
| `external_provider_error` | AI Provider API failure (rate limit, vision execution error) | yes |
|
||||
| `infrastructure_transient_error` | Temporary DB connection reset or HTTP timeout | yes |
|
||||
| `infrastructure_persistent_error` | Database down, missing API credentials, misconfiguration | no |
|
||||
| `internal_unexpected_error` | Uncaught Python exception or logic defect | no |
|
||||
|
||||
## Async Batch & Page-Level Error Behavior
|
||||
|
||||
In multi-image `asyncio` batch processing:
|
||||
|
||||
1. **Page Isolation:** Exceptions caught during individual page calls are caught within the `asyncio` task wrapper.
|
||||
2. **Page Record Logging:** Page failure detail is written directly to `job_source.error_detail` and `job_source.status = 'failed'`.
|
||||
3. **Batch Aggregate State:**
|
||||
* If **all** page tasks succeed -> `job.status = 'completed'`.
|
||||
* If **some** page tasks fail -> `job.status = 'partial_success'`.
|
||||
* If **all** page tasks fail -> `job.status = 'failed'`.
|
||||
|
||||
|
||||
4. **Retry Strategy:** The UI exposes a "Retry Failed Pages" option for `partial_success` jobs, which spawns a new targeted `Job` containing *only* the `Source` IDs marked as `failed`.
|
||||
|
||||
## API Error Response Contract
|
||||
|
||||
API error responses return a structured JSON envelope:
|
||||
```json
|
||||
{
|
||||
"error_id": "err_uuid_12345",
|
||||
"category": "validation_error",
|
||||
"message": "The uploaded payload failed schema validation.",
|
||||
"suggestion": "Check file format and metadata fields, then try again.",
|
||||
"details": {
|
||||
"pydantic_errors": [...]
|
||||
},
|
||||
"timestamp": "2026-07-31T07:55:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
HTTP Status Mappings:
|
||||
|
||||
* `validation_error`, `user_input_error` -> `400`
|
||||
* `not_found_error` -> `404`
|
||||
* `conflict_error` -> `409`
|
||||
* `external_provider_error` -> `502` / `503`
|
||||
* `infrastructure_transient_error` -> `503`
|
||||
* `infrastructure_persistent_error`, `internal_unexpected_error` -> `500`
|
||||
|
||||
---
|
||||
|
||||
## Technology References
|
||||
|
||||
- [FastAPI documentation](https://fastapi.tiangolo.com/)
|
||||
- [NiceGUI documentation](https://nicegui.io/documentation)
|
||||
- [PostgreSQL documentation](https://www.postgresql.org/docs/)
|
||||
- [Python asyncio](https://docs.python.org/3/library/asyncio.html#module-asyncio)
|
||||
- [Pydantic Validation](https://pydantic.dev/docs/validation/latest/get-started/)
|
||||
- [Pydantic AI](https://pydantic.dev/docs/ai/overview/)
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v2.md)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Architecture](architecture_v2.md)
|
||||
- [System Requirements](requirements_v2.md)
|
||||
- [Data model](schema_v2.md)
|
||||
- Error Handling Policy (this document)
|
||||
- [Implementation Plan](implementation_plan_v2.md)
|
||||
@@ -1,248 +0,0 @@
|
||||
# Implementation Plan (Version 2)
|
||||
|
||||
This plan defines the path from the V1 baseline to **Version 2 complete**, aligned to the updated multi-image and multi-person relational domain model:
|
||||
|
||||
* `Document` acts as a logical parent container for physical artifacts, supporting multi-author and multi-recipient relationships via `DocumentPerson`.
|
||||
* `Source` represents an individual image page within a document, maintaining sequential order (`page_number`), cached active machine output (`raw_transcription`), and inline single user revisions (`revised_text`).
|
||||
* `Job` acts as an overarching batch orchestrator for multi-page async processing tasks.
|
||||
* `JobSource` records individual point-in-time API executions per image page, storing Pydantic-validated `ai_metadata` and raw REST envelopes (`raw_api_response`).
|
||||
* **Pydantic V2** acts as the single source of truth for runtime validation, API payload parsing, and PostgreSQL JSONB serialization.
|
||||
|
||||
The objective is to complete the V2 scope with production readiness while keeping non-V2 enhancements out of active delivery.
|
||||
|
||||
---
|
||||
|
||||
## V2 Completion Definition
|
||||
|
||||
V2 is complete when all of the following are true:
|
||||
|
||||
1. **Functional complete**
|
||||
* Multi-image and whole-folder uploads assign sequential page numbers to `Source` records under a single `Document`.
|
||||
* Batch jobs process pages concurrently using an `asyncio` worker pool with semaphore rate limiting.
|
||||
* Partial job failures resolve cleanly to `partial_success`, allowing single-page retries without re-running successful pages.
|
||||
* Multi-author and multi-recipient tagging is supported on `Document`.
|
||||
|
||||
|
||||
2. **Data-model complete**
|
||||
* SQLite is fully replaced with PostgreSQL (using `asyncpg` or `psycopg3`).
|
||||
* Pydantic V2 models validate all API payloads, database row mappings, and `JSONB` structures.
|
||||
|
||||
|
||||
3. **Operational complete**
|
||||
* Concurrency controls, worker pool metrics, and database connections operate safely under batch load.
|
||||
|
||||
|
||||
4. **Documentation complete**
|
||||
* `schema_v2.md`, `DDL_v2.sql`, Pydantic model contracts are updated and consistent.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Data Contract Stabilization & Pydantic Baseline
|
||||
|
||||
**Goal:** Lock the PostgreSQL schema, DDL, and Pydantic V2 models before refactoring service logic.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Finalize DDL for PostgreSQL native types (`UUID`, `TIMESTAMPTZ`, `JSONB`) and junction tables (`document_person`, `job_source`).
|
||||
2. Build core Pydantic V2 schemas (`Person`, `Document`, `Source`, `Job`, `JobSource`, `PageAIMetadata`).
|
||||
3. Confirm and document data invariants:
|
||||
* `source.raw_transcription` and `job_source.raw_transcription` are immutable machine outputs.
|
||||
* `source.revised_text` holds user edits. UI renders `COALESCE(revised_text, raw_transcription)`.
|
||||
* Page sequence is strictly ordered by `source.page_number ASC`.
|
||||
|
||||
|
||||
4. Freeze V2 job status values (`queued`, `processing`, `completed`, `partial_success`, `failed`) and page execution status values (`pending`, `transcribed`, `failed`).
|
||||
|
||||
### Deliverables
|
||||
|
||||
* Canonical `docs/schema_v2.md` and `docs/DDL_v2.sql`.
|
||||
* Centralized Pydantic validation suite in `models/schemas_v2.py`.
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
* All database tables, relationships, and JSONB structures have corresponding Pydantic V2 models passing unit validation tests.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Persistence Layer Transition (SQLite to PostgreSQL)
|
||||
|
||||
**Goal:** Replace the SQLite storage layer with an asynchronous PostgreSQL driver (`asyncpg` or `psycopg3`).
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Configure PostgreSQL database connection pooling and environment configuration.
|
||||
2. Refactor `services/store.py` / repository layers to execute parameterized async SQL queries (`$1`, `$2`).
|
||||
3. Implement JSONB serialization and deserialization helpers using Pydantic's `.model_dump_json()` and `.model_validate()`.
|
||||
4. Implement database bootstrap routines for PostgreSQL table creation and index initialization.
|
||||
|
||||
### Deliverables
|
||||
|
||||
* PostgreSQL-native database connection and query service modules.
|
||||
* Integration test suite confirming connection pooling and JSONB CRUD operations.
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
* All database reads/writes run asynchronously against PostgreSQL with zero remaining SQLite driver dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Service Layer & `asyncio` Engine Refactor
|
||||
|
||||
**Goal:** Implement batch orchestration and parallel single-image API execution.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Refactor upload service to process folder/multi-image input:
|
||||
* Group files into a single `Document`.
|
||||
* Create ordered `Source` rows (`page_number = 1..N`).
|
||||
|
||||
|
||||
2. Refactor `services/workflows.py` with `asyncio` worker pools:
|
||||
* Use `asyncio.Semaphore` to enforce API provider rate limits.
|
||||
* Issue parallel single-image requests to Vision APIs (OpenAI/Claude).
|
||||
* Parse API responses directly into Pydantic models (`PageAIMetadata`).
|
||||
|
||||
|
||||
3. Update execution tracking:
|
||||
* Create a `JobSource` row per page call to record `raw_transcription`, `ai_metadata`, and `raw_api_response`.
|
||||
* Update active `source.raw_transcription` upon task completion.
|
||||
* Calculate aggregate batch status (`completed`, `partial_success`, `failed`) on the parent `Job`.
|
||||
|
||||
|
||||
4. Refactor `services/person.py` and `services/documents.py` to handle multi-person roles via `document_person`.
|
||||
|
||||
### Deliverables
|
||||
|
||||
* Asynchronous batch execution engine in `services/workflows.py`.
|
||||
* Service routines for multi-person tagging and page-level retries.
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
* Executing a folder upload of 10+ images processes concurrently, populates page-level `JobSource` entries, and handles partial worker errors without crashing the batch.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — UI & API Contract Alignment
|
||||
|
||||
**Goal:** Update API endpoints and frontend/UI views to render multi-page documents and person roles.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Update document and job API endpoints to accept batch file arrays and multi-person ID payloads.
|
||||
2. Update UI document views:
|
||||
* Render multi-page document transcriptions sequentially by `page_number`.
|
||||
* Display author and recipient chips/cards linked from `document_person`.
|
||||
|
||||
|
||||
3. Update job detail UI to show page-level execution statuses (`transcribed` vs. `failed`) and provide a "Retry Failed Pages" action for `partial_success` jobs.
|
||||
4. Align inline page editing controls to update `source.revised_text` and `source.date_revised`.
|
||||
|
||||
### Deliverables
|
||||
|
||||
* Refactored API routes and UI components supporting multi-page rendering and person management.
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
* UI successfully displays multi-page document text, allows per-page human revisions, and shows author/recipient metadata.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Test Suite Realignment & Concurrency Testing
|
||||
|
||||
**Goal:** Ensure end-to-end system stability under concurrent async execution and load.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Write unit tests for Pydantic models, custom validators, and JSONB conversions.
|
||||
2. Write integration tests for async database operations:
|
||||
* CRUD for `Document`, `Person`, `DocumentPerson`, `Source`, `Job`, and `JobSource`.
|
||||
|
||||
|
||||
3. Write mock-backed async workflow tests:
|
||||
* Verify `asyncio.Semaphore` bounds concurrent tasks properly.
|
||||
* Validate state transition logic for `completed`, `partial_success`, and `failed` jobs.
|
||||
* Confirm retry routines process only targeted `JobSource` records marked as `failed`.
|
||||
|
||||
|
||||
4. Re-enable CI quality gates (linting, type checking with Pyright/mypy, pytest).
|
||||
|
||||
### Deliverables
|
||||
|
||||
* Passing asynchronous test suite covering core workflows, edge cases, and failure recoveries.
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
* CI pipeline is green with comprehensive coverage across database operations, Pydantic models, and worker queues.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Reliability, Operations, and Release Readiness
|
||||
|
||||
**Goal:** Prepare V2 for production deployment and operator management.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Verify structured logging includes `job_id`, `document_id`, `source_id`, and `person_id`.
|
||||
2. Tune PostgreSQL connection pool limits and `asyncio` concurrency thresholds for production infrastructure.
|
||||
3. Update operational documentation:
|
||||
* Review and update `docs/schema_v2.md` as needed.
|
||||
* Create `docs/runbook_v2.md` detailing PostgreSQL maintenance, JSONB index management, and worker queue monitoring.
|
||||
* Create `docs/release_checklist_v2.md` for launch sign-off.
|
||||
|
||||
|
||||
|
||||
### Deliverables
|
||||
|
||||
* Updated project documentation and operational runbooks.
|
||||
* V2 release sign-off checklist.
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
* All documentation reflects V2 architecture; launch checklist is fully verified.
|
||||
|
||||
---
|
||||
|
||||
## Requirement Traceability Focus
|
||||
|
||||
Maintain evidence against these V2 requirement groups:
|
||||
|
||||
* **Batch & Multi-Image Pipeline:** Folder ingestion, page ordering, async worker execution.
|
||||
* **Database & Persistence:** PostgreSQL, native UUIDs, JSONB execution storage, `asyncpg` pooling.
|
||||
* **Validation & Schemas:** Pydantic V2 models for DB rows, API requests, and AI vision responses.
|
||||
* **Attribution & Metadata:** Multi-author and multi-recipient tagging, biographical entity management.
|
||||
* **Error Recovery:** Partial success states, page-level status flags, isolated retry execution.
|
||||
|
||||
---
|
||||
|
||||
## Scope Discipline Rule (V2 Focus)
|
||||
|
||||
* Only tasks required for V2 scope (PostgreSQL, Pydantic V2, folder/async processing, multi-person roles) enter this plan.
|
||||
* V3 candidate features (such as side-by-side multi-provider model output comparison) remain strictly in the future backlog.
|
||||
* Any schema adjustments during implementation require immediate updates to `DDL_v2.sql`, Pydantic models, and `schema_v2.md`.
|
||||
|
||||
---
|
||||
|
||||
## Technology References
|
||||
|
||||
- [FastAPI documentation](https://fastapi.tiangolo.com/)
|
||||
- [NiceGUI documentation](https://nicegui.io/documentation)
|
||||
- [PostgreSQL documentation](https://www.postgresql.org/docs/)
|
||||
- [Python asyncio](https://docs.python.org/3/library/asyncio.html#module-asyncio)
|
||||
- [Pydantic Validation](https://pydantic.dev/docs/validation/latest/get-started/)
|
||||
- [Pydantic AI](https://pydantic.dev/docs/ai/overview/)
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v2.md)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Architecture](architecture_v2.md)
|
||||
- [System Requirements](requirements_v2.md)
|
||||
- [Data model](schema_v2.md)
|
||||
- [Error Handling Policy](error_handling_v2.md)
|
||||
- Implementation Plan (this document)
|
||||
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Document Transcription System Overview (Version 2)
|
||||
|
||||
This project is a personal-scale application for transcribing, indexing, and preserving historical family documents, letters, postcards, and journals.
|
||||
|
||||
## Start Here
|
||||
|
||||
Read [architecture_v2.md](architecture_v2.md) first for technical overview and system design.
|
||||
|
||||
## Core V2 Capabilities
|
||||
|
||||
* **Folder & Multi-Image Ingestion:** Upload whole folders or image batches that map sequentially (`page_number`) under a single `Document`.
|
||||
* **Parallel Async AI Vision Engine:** Concurrently process single-page image transcriptions using Python `asyncio` bounded by rate limiters.
|
||||
* **Robust PostgreSQL Storage:** Relational storage for entities with native `UUID`, `TIMESTAMPTZ`, and `JSONB` for deep AI spatial metadata and raw envelopes.
|
||||
* **Pydantic V2 Validation:** End-to-end type safety, DB row mapping, and JSONB payload validation.
|
||||
* **Historical Person Management:** Track authors and recipients across documents with rich biographical entities (`Person`).
|
||||
* **Page-Level Execution Auditing & Revisions:** Store immutable point-in-time machine output per run while enabling inline human corrections (`revised_text`).
|
||||
* **Partial Failure Recovery:** Bounded batch execution that isolates single-page API errors (`partial_success`) for simple retries.
|
||||
|
||||
## Technical Stack
|
||||
|
||||
* **Application Web Framework:** FastAPI + NiceGUI
|
||||
* **Persistence Engine:** PostgreSQL 13+
|
||||
* **Data Validation & Schemas:** Pydantic V2
|
||||
* **Concurrency & Workers:** Python `asyncio` worker pool with `asyncio.Semaphore`
|
||||
* **Vision Providers:** OpenAI (GPT-4o) and Anthropic (Claude 3.5 Sonnet) via native SDKs
|
||||
|
||||
---
|
||||
|
||||
## Technology References
|
||||
|
||||
- [FastAPI documentation](https://fastapi.tiangolo.com/)
|
||||
- [NiceGUI documentation](https://nicegui.io/documentation)
|
||||
- [PostgreSQL documentation](https://www.postgresql.org/docs/)
|
||||
- [Python asyncio](https://docs.python.org/3/library/asyncio.html#module-asyncio)
|
||||
- [Pydantic Validation](https://pydantic.dev/docs/validation/latest/get-started/)
|
||||
- [Pydantic AI](https://pydantic.dev/docs/ai/overview/)
|
||||
|
||||
## Documentation Index
|
||||
|
||||
- System Overview (this document)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Architecture](architecture_v2.md)
|
||||
- [System Requirements](requirements_v2.md)
|
||||
- [Data model](schema_v2.md)
|
||||
- [Error Handling Policy](error_handling_v2.md)
|
||||
- [Implementation Plan](implementation_plan_v2.md)
|
||||
@@ -0,0 +1,141 @@
|
||||
# Digital Evidence and AI Processing Provenance (Invariant)
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document defines non-negotiable evidence and provenance rules for the transcription application.
|
||||
|
||||
The application exists to preserve historical source material and produce useful transcriptions without losing the ability to inspect, reinterpret, or reprocess the evidence later. Provider integrations, model names, schemas, and user interfaces may change; the principles below must remain true.
|
||||
|
||||
## 2. Evidence Model
|
||||
|
||||
The application distinguishes five kinds of information:
|
||||
|
||||
1. **Source evidence**: the original uploaded media and the facts needed to identify and verify it.
|
||||
2. **Execution specification**: the frozen instructions, parameters, source identity, and software context for one processing attempt.
|
||||
3. **Transport evidence**: the response received at the application/provider boundary, including safe protocol metadata.
|
||||
4. **Normalized data**: selected fields extracted for search, display, accounting, and workflow behavior.
|
||||
5. **Derived artifacts**: outputs produced from source evidence, such as transcription text, OCR geometry, confidence data, layout analysis, or entity extraction.
|
||||
|
||||
Normalized data and derived artifacts never replace source or transport evidence.
|
||||
|
||||
## 3. Core Invariants
|
||||
|
||||
### 3.1 Original Source Preservation
|
||||
|
||||
1. The original uploaded bytes are the primary evidence and must be preserved without transformation.
|
||||
2. Each source must have a cryptographic content digest, byte size, and stable identity.
|
||||
3. Processing may use transformed derivatives, but those derivatives must not overwrite the original.
|
||||
4. A derivative used for processing must record its relationship to the original, its transformation, and its own digest.
|
||||
5. Moving or renaming a stored file must not change its evidence identity.
|
||||
|
||||
### 3.2 Append-Only Processing History
|
||||
|
||||
1. Every processing attempt must have a distinct execution record, whether it succeeds, partially succeeds, times out, or fails.
|
||||
2. A later attempt must not overwrite the evidence from an earlier attempt.
|
||||
3. A convenient “latest transcription” value may be maintained as a cache or projection, but it is not the authoritative execution history.
|
||||
4. Human revisions must remain distinguishable from all machine-generated outputs.
|
||||
5. Reprocessing a source must create new evidence rather than rewriting historical evidence.
|
||||
|
||||
### 3.3 Frozen Execution Specification
|
||||
|
||||
Each execution must preserve enough information to understand what the application asked the processor to do:
|
||||
|
||||
1. Requested provider, model, and provider-routing constraints.
|
||||
2. Full effective system and user instructions.
|
||||
3. Prompt asset name and content digest when a prompt asset is used.
|
||||
4. Every explicitly supplied generation or processing parameter.
|
||||
5. Whether an optional parameter was explicitly set or omitted.
|
||||
6. Source and derivative digests, media type, dimensions or page geometry when known, and page identity.
|
||||
7. A secret-safe representation of the request structure.
|
||||
8. Application, provider-adapter, and client-library versions sufficient to interpret the execution.
|
||||
|
||||
The execution specification must not contain credentials, authorization headers, secret query values, or unnecessary duplicate source binaries.
|
||||
|
||||
### 3.4 Evidence-Layer Terminology
|
||||
|
||||
The following terms are not interchangeable:
|
||||
|
||||
- **Transport response**: the status, safe headers, and exact response body received by the application at its HTTP boundary.
|
||||
- **Router-normalized response**: a response transformed by an intermediary into its common schema.
|
||||
- **SDK-parsed response**: an object created when a client library validates or filters a response.
|
||||
- **Normalized metadata**: application-selected fields derived from a response.
|
||||
- **Native provider response**: the upstream provider's own response before any intermediary transformation.
|
||||
|
||||
The application and its documentation must identify which layer is stored. A response must not be described as “raw,” “complete,” or “native” without naming the boundary at which that claim is true.
|
||||
|
||||
### 3.5 Transport Evidence
|
||||
|
||||
1. Preserve the exact successful response body received at the application's transport boundary before SDK model parsing can discard unknown fields.
|
||||
2. Preserve the response status and an allowlisted set of non-secret headers needed for correlation, content interpretation, rate-limit diagnosis, or audit.
|
||||
3. Preserve provider/router request and generation identifiers when available.
|
||||
4. Preserve safe response evidence for unsuccessful calls when a response was received.
|
||||
5. Record explicitly when no response was received, such as a local timeout or connection failure.
|
||||
6. Retain parsed and normalized forms only as additional representations of the preserved response.
|
||||
|
||||
Wire-level packet capture, TLS session data, credentials, and unrestricted headers are neither required nor permitted.
|
||||
|
||||
These requirements apply to executions performed after transport capture is implemented. For earlier executions, the absence of transport evidence must be represented explicitly. An SDK snapshot or normalized record must never be relabeled or backfilled as transport evidence.
|
||||
|
||||
### 3.6 Derived Artifact Provenance
|
||||
|
||||
1. Every derived artifact must identify its source evidence and producing execution.
|
||||
2. Each artifact must declare its semantic type, media/serialization format, schema name and version, producer, producer version, and creation time.
|
||||
3. Artifact content must be stored directly or referenced by a stable path or object identifier and protected by a cryptographic digest.
|
||||
4. Coordinates must declare their coordinate system, units, origin, page/image dimensions, and transformation history.
|
||||
5. Confidence values must identify the producer and scope to which they apply; values from different producers must not be treated as directly comparable without validation.
|
||||
6. Provider-specific payloads may be retained, but durable application behavior must not depend on undocumented provider fields.
|
||||
|
||||
This model must accommodate future OCR text, word or line polygons, layout regions, confidence data, alternate transcriptions, and structured extraction without adding a dedicated column for every possible feature.
|
||||
|
||||
### 3.7 Integrity and Auditability
|
||||
|
||||
1. Stored evidence must be exportable with enough identifiers and metadata to verify relationships and digests outside the application.
|
||||
2. Evidence mutation, deletion, and retention behavior must be explicit and testable.
|
||||
3. Schema upgrades must preserve existing evidence and its original meaning.
|
||||
4. Backfills must be identified as backfills; they must not imply that previously uncaptured evidence existed.
|
||||
5. Integrity verification must distinguish a missing file, digest mismatch, unavailable external artifact, and malformed metadata.
|
||||
|
||||
### 3.8 Security and Privacy
|
||||
|
||||
1. API keys, authorization headers, cookies, and credentials must never be persisted as provenance.
|
||||
2. Persist only headers and metadata fields that appear on an explicit allowlist of known-safe fields. Discard all other fields before storage; never persist an unrestricted capture and attempt to redact it afterward.
|
||||
3. Request manifests should reference source content by identity instead of duplicating base64 source data.
|
||||
4. Diagnostic displays and exports must avoid exposing secrets or machine-local details that are not necessary for evidence interpretation.
|
||||
|
||||
## 4. Reproducibility Limits
|
||||
|
||||
Provenance supports explanation, comparison, and best-effort reproduction; it does not guarantee identical output.
|
||||
|
||||
Identical requests may produce different results because of model updates, provider routing, nondeterministic computation, undocumented defaults, safety systems, or retired endpoints. The application must preserve whether a parameter was omitted rather than pretending to know the provider default used at that time.
|
||||
|
||||
Likewise, preserving a general vision-model response does not create OCR coordinates that were never returned. Future coordinate extraction remains possible because the original source evidence is preserved and can be processed again by a suitable system.
|
||||
|
||||
## 5. Model Evaluation Policy
|
||||
|
||||
Model selection must be based on a representative sample of the actual archive rather than vendor claims alone.
|
||||
|
||||
Evaluation should:
|
||||
|
||||
1. Use manually reviewed reference transcriptions following the project's [Transcription Methodology](transcription_methodology.md).
|
||||
2. Represent printed, typed, handwritten, degraded, tabular, multilingual, and spatially complex material present in the archive.
|
||||
3. Measure character and word error rates where appropriate.
|
||||
4. Separately record silent corrections, invented text, omitted text, uncertainty handling, layout fidelity, cost, and latency.
|
||||
5. Preserve the exact model, endpoint or route, parameters, prompt, source digest, and scoring method for every comparison.
|
||||
6. Treat model rankings as corpus- and version-specific, not permanent declarations of a universal “best” model.
|
||||
|
||||
Benchmark material containing family records remains private application data unless explicitly approved for publication.
|
||||
|
||||
## 6. Ownership and Change Policy
|
||||
|
||||
1. Versioned architecture, schema, scope, and implementation documents define how a release satisfies this invariant.
|
||||
2. Provider adapters own the capture of provider-boundary evidence.
|
||||
3. Services own validation, persistence, retention, and export behavior.
|
||||
4. UI pages may inspect evidence through service contracts but do not define evidence semantics.
|
||||
5. If implementation conflicts with this invariant, either correct the implementation or explicitly revise this document before accepting the behavior.
|
||||
6. Revisions to this document require deliberate review because they change the long-term preservation contract.
|
||||
|
||||
## 7. Related Invariants
|
||||
|
||||
- [Historical Document Transcription Design Intent](intent.md)
|
||||
- [Transcription Methodology & Style Guide](transcription_methodology.md)
|
||||
- [UI Style Guide](ui_style_guide.md)
|
||||
@@ -0,0 +1,101 @@
|
||||
# Error Handling (Invariant)
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document defines the non-negotiable failure-handling principles for the transcription application.
|
||||
|
||||
Error categories, API envelopes, status codes, framework integrations, and persistence fields may change between versions. Failures must nevertheless remain visible, safe, diagnosable, and consistent across every application boundary.
|
||||
|
||||
## 2. Core Invariants
|
||||
|
||||
### 2.1 Failures Are Visible
|
||||
|
||||
1. An operation must not report success when all or part of the requested work failed.
|
||||
2. Invalid input, unavailable dependencies, persistence failures, provider failures, and unexpected defects must be surfaced through the application's established error path.
|
||||
3. Code must not silently discard an exception, provider response, invalid value, or failed state transition.
|
||||
4. When work can partially succeed, the successful and failed portions must be identified separately.
|
||||
|
||||
### 2.2 Messages Are Actionable
|
||||
|
||||
1. Operator-facing errors must explain what failed in concise language.
|
||||
2. When a safe corrective action is known, the error must state it.
|
||||
3. Expected validation or conflict failures must not be presented as unexplained internal defects.
|
||||
4. Internal diagnostics must not replace a usable operator-facing message.
|
||||
|
||||
### 2.3 Errors Have Stable Identity and Classification
|
||||
|
||||
1. Every surfaced failure must have a stable correlation identifier or equivalent trace identity.
|
||||
2. Failures must be classified into a documented, machine-readable category.
|
||||
3. Boundary-specific representations must preserve the original category and correlation identity.
|
||||
4. Unknown exceptions must be converted at an explicit boundary, retain their causal chain for diagnostics, and be classified as unexpected rather than disguised as an expected failure.
|
||||
|
||||
### 2.4 Boundary Translation Is Consistent
|
||||
|
||||
1. UI, API, service, worker, persistence, and provider boundaries must use one shared error model or deterministic translations between documented models.
|
||||
2. A boundary may simplify presentation, but it must not change the meaning, retryability, or identity of a failure.
|
||||
3. Domain and service code must not depend on UI notifications or HTTP response types.
|
||||
4. UI and API layers must not infer error categories by parsing message text.
|
||||
|
||||
### 2.5 State Changes Are Safe
|
||||
|
||||
1. A failed atomic operation must leave persisted state unchanged.
|
||||
2. Batch operations may preserve successful independent items only when partial success is an explicit part of the workflow contract.
|
||||
3. A failed item must retain enough state to identify what was attempted and whether retry is safe.
|
||||
4. Error handling must not overwrite earlier successful results or historical execution evidence.
|
||||
|
||||
### 2.6 Retry Is Explicit and Bounded
|
||||
|
||||
1. Validation, authorization, policy, conflict, and other deterministic failures must not be retried automatically without a relevant input or state change.
|
||||
2. Automatic retry is permitted only for failures classified as transient and only when the operation is idempotent or otherwise protected from duplicate effects.
|
||||
3. Retry count, delay, and terminal behavior must be bounded and observable.
|
||||
4. Exhausted retries must end in a visible terminal failure rather than an indefinitely pending state.
|
||||
|
||||
### 2.7 Diagnostics Are Preserved Safely
|
||||
|
||||
1. Logs and persisted diagnostic evidence must retain enough context to correlate the failure with the affected operation and record.
|
||||
2. Provider and infrastructure failures must preserve safe diagnostic evidence at the boundary where it is available.
|
||||
3. Credentials, authorization headers, cookies, secret values, and unnecessary personal data must not appear in errors, logs, notifications, or exports.
|
||||
4. Diagnostic metadata capture must use explicit safe-field allowlists where unrestricted content could contain secrets.
|
||||
5. User-facing messages must not expose stack traces, local filesystem details, database credentials, or raw internal exceptions.
|
||||
|
||||
AI execution failures also follow the evidence rules in [Digital Evidence and AI Processing Provenance](ai_evidence_and_provenance.md).
|
||||
|
||||
### 2.8 Cancellation and Timeout Are Distinct Outcomes
|
||||
|
||||
1. User cancellation, application shutdown, local timeout, remote timeout, and provider rejection must remain distinguishable.
|
||||
2. Cancellation must not be converted into success or a generic unexpected error.
|
||||
3. Timeout handling must identify whether a provider response was received when that fact is known.
|
||||
4. Cleanup after cancellation or timeout must preserve consistency and must not conceal a completed side effect.
|
||||
|
||||
### 2.9 Logging Must Support Audit Without Becoming the Record
|
||||
|
||||
1. Structured logs must include correlation identity, operation, category, and relevant non-secret record identifiers.
|
||||
2. Expected operator errors may be logged less severely than unexpected defects, but they must remain observable.
|
||||
3. Logs are operational diagnostics and do not replace required database state or archival evidence.
|
||||
4. Duplicate logging of the same failure at every layer should be avoided; ownership of the authoritative log event must be clear.
|
||||
|
||||
## 3. Verification Policy
|
||||
|
||||
Each version must verify:
|
||||
|
||||
1. Every documented error category reaches the intended UI and API representation.
|
||||
2. Failed atomic writes roll back completely.
|
||||
3. Partial-success workflows preserve successful independent results and identify failed items.
|
||||
4. Retry behavior is bounded and restricted to eligible failures.
|
||||
5. Unexpected exceptions retain correlation and causal information without exposing sensitive details.
|
||||
6. Logs, persisted evidence, UI messages, and exports contain no credentials.
|
||||
7. Cancellation, timeout, provider response failure, and no-response failure remain distinguishable.
|
||||
|
||||
## 4. Versioned Ownership
|
||||
|
||||
1. Version-specific error taxonomies, envelopes, HTTP mappings, model fields, and framework behavior belong in the applicable version documentation.
|
||||
2. Each versioned error-handling document must state how it satisfies this invariant.
|
||||
3. A version may add stricter safeguards but must not weaken these principles without first revising this invariant deliberately.
|
||||
4. Implementation and tests must be updated together when a versioned error contract changes.
|
||||
|
||||
## 5. Related Invariants
|
||||
|
||||
- [Historical Document Transcription Design Intent](intent.md)
|
||||
- [Digital Evidence and AI Processing Provenance](ai_evidence_and_provenance.md)
|
||||
- [UI Style Guide](ui_style_guide.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,110 @@
|
||||
# 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
|
||||
7. ui-card-error
|
||||
|
||||
### 5.3 Interactive Elements
|
||||
1. ui-btn-primary
|
||||
2. ui-btn-secondary
|
||||
3. ui-link-primary
|
||||
4. ui-text-accent
|
||||
5. ui-chip-primary
|
||||
6. ui-badge-secondary
|
||||
7. ui-status and ui-status--<status>
|
||||
|
||||
### 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 `vibe-` presentation classes are prohibited. Use `ui-` semantic classes from `theme.css`.
|
||||
|
||||
## 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.
|
||||
5. Embedded `<style>` blocks or NiceGUI `.style(...)` calls in Python UI code.
|
||||
6. Additional page- or component-specific stylesheets; `theme.css` is the single CSS source.
|
||||
|
||||
## 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.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Document Transcription System Requirements (Version 2)
|
||||
|
||||
This document captures the **Version 2 baseline requirements** for the production implementation.
|
||||
|
||||
## Requirements Model
|
||||
|
||||
| ID | Category | Requirement | Verify Method |
|
||||
| --- | --- | --- | --- |
|
||||
| REQ-0 | System | Provide end-to-end multi-page document transcription with persistent, inspectable async job states. | demonstration |
|
||||
| REQ-1 | Functional | Allow users to upload folders or multi-image batches as sequential `Source` pages under a `Document`. | test |
|
||||
| REQ-2 | Functional | Process multi-page jobs asynchronously using an `asyncio` worker pool bounded by rate limits. | test |
|
||||
| REQ-3 | Functional | Persist page-level execution outputs (`raw_transcription`, `ai_metadata`, `raw_api_response`) on `JobSource`. | test |
|
||||
| REQ-4 | Functional | Support job states (`queued`, `processing`, `completed`, `partial_success`, `failed`) and page states (`pending`, `transcribed`, `failed`). | inspection |
|
||||
| REQ-5 | Functional | Allow users to manage historical `Person` records and link multiple authors/recipients to a `Document` via `DocumentPerson`. | test |
|
||||
| REQ-6 | Functional | Maintain immutable original machine output on `Source.raw_transcription` while permitting inline human edits on `Source.revised_text`. | test |
|
||||
| REQ-7 | Data Constraint | Store all persistent domain data in PostgreSQL using native `UUID`, `TIMESTAMPTZ`, and `JSONB` columns. | inspection |
|
||||
| REQ-8 | Data Constraint | Validate all API requests, database rows, and JSONB structures using Pydantic V2 schemas. | test |
|
||||
| REQ-9 | Interface | Render multi-page transcriptions sequentially by `page_number` in the web UI with author/recipient metadata. | demonstration |
|
||||
| REQ-10 | Operations | Allow operators to retry only failed pages for jobs in a `partial_success` state. | test |
|
||||
|
||||
## Element Satisfaction Mapping
|
||||
|
||||
* **UI (NiceGUI):** Satisfies REQ-1, REQ-5, REQ-6, REQ-9, REQ-10.
|
||||
* **API (FastAPI):** Satisfies REQ-1, REQ-4, REQ-5, REQ-8.
|
||||
* **WORKER (asyncio):** Satisfies REQ-2, REQ-3, REQ-4, REQ-10.
|
||||
* **PERSISTENCE (PostgreSQL):** Satisfies REQ-3, REQ-6, REQ-7.
|
||||
* **MODELS (Pydantic V2):** Satisfies REQ-8.
|
||||
|
||||
---
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v2.md)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Architecture](architecture_v2.md)
|
||||
- System Requirements (this document)
|
||||
- [Data model](schema_v2.md)
|
||||
- [Error Handling Policy](error_handling_v2.md)
|
||||
- [Implementation Plan](implementation_plan_v2.md)
|
||||
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
# Database Schema (Version 2)
|
||||
|
||||
This document describes the PostgreSQL relational schema for the transcription platform. It incorporates multi-image batch orchestration via `asyncio`, page-level execution tracking, many-to-many author/recipient attribution, and JSONB document storage for AI vision outputs.
|
||||
|
||||
All primary and foreign keys are PostgreSQL native UUIDs (`gen_random_uuid()`).
|
||||
|
||||
## Entity Relationship Diagram
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
PERSON {
|
||||
UUID id PK
|
||||
TEXT full_name
|
||||
TEXT display_name
|
||||
TEXT maiden_name
|
||||
DATE birth_date
|
||||
TEXT birth_date_raw
|
||||
TEXT birth_place
|
||||
DATE death_date
|
||||
TEXT death_date_raw
|
||||
TEXT death_place
|
||||
TEXT biography
|
||||
TEXT portrait_path
|
||||
JSONB metadata
|
||||
TIMESTAMPTZ created_at
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
DOCUMENT {
|
||||
UUID id PK
|
||||
TEXT name
|
||||
TEXT document_type
|
||||
DATE document_date
|
||||
TEXT document_date_raw
|
||||
TEXT location_created
|
||||
TEXT notes
|
||||
TEXT archive_identifier
|
||||
TIMESTAMPTZ created_at
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
DOCUMENT_PERSON {
|
||||
UUID id PK
|
||||
UUID document_id FK
|
||||
UUID person_id FK
|
||||
VARCHAR role "author | recipient"
|
||||
TIMESTAMPTZ created_at
|
||||
}
|
||||
|
||||
JOB {
|
||||
UUID id PK
|
||||
UUID document_id FK
|
||||
VARCHAR status "queued | processing | completed | partial_success | failed"
|
||||
INTEGER retry_count
|
||||
TEXT provider
|
||||
TEXT model
|
||||
TEXT prompt_name
|
||||
TIMESTAMPTZ date_created
|
||||
TIMESTAMPTZ date_updated
|
||||
}
|
||||
|
||||
SOURCE {
|
||||
UUID id PK
|
||||
UUID document_id FK
|
||||
INTEGER page_number
|
||||
TEXT upload_name
|
||||
TEXT filename
|
||||
TEXT file_path
|
||||
TEXT raw_transcription
|
||||
TEXT revised_text
|
||||
TIMESTAMPTZ date_uploaded
|
||||
TIMESTAMPTZ date_revised
|
||||
}
|
||||
|
||||
JOB_SOURCE {
|
||||
UUID id PK
|
||||
UUID job_id FK
|
||||
UUID source_id FK
|
||||
VARCHAR status "pending | transcribed | failed"
|
||||
TEXT raw_transcription
|
||||
JSONB ai_metadata
|
||||
JSONB raw_api_response
|
||||
TEXT error_detail
|
||||
TIMESTAMPTZ executed_at
|
||||
}
|
||||
|
||||
DOCUMENT ||--o{ DOCUMENT_PERSON : "has_people"
|
||||
PERSON ||--o{ DOCUMENT_PERSON : "participates_in"
|
||||
DOCUMENT ||--o{ JOB : "has_jobs"
|
||||
DOCUMENT ||--o{ SOURCE : "contains_pages"
|
||||
JOB ||--o{ JOB_SOURCE : "executes"
|
||||
SOURCE ||--o{ JOB_SOURCE : "processed_in"
|
||||
```
|
||||
|
||||
## Domain Invariants & Rules
|
||||
|
||||
### Page-Level Execution & AI Outputs
|
||||
|
||||
* Execution Granularity: Every single image execution by an AI model produces a dedicated record in job_source.
|
||||
* Point-in-Time Auditability: job_source.raw_api_response stores the unparsed REST response envelope for that specific image page call. job_source.ai_metadata stores spatial bounding boxes, token usage, and layout details for that specific image page call.
|
||||
* Active Output Caching: Upon successful completion of an image call, source.raw_transcription is updated with the latest output string from job_source.raw_transcription for fast UI rendering.
|
||||
|
||||
### Page Ordering & Revisions
|
||||
|
||||
* Sequential Integrity: source.page_number dictates page ordering within a document. Reads assembling full documents must query ORDER BY source.document_id, source.page_number ASC.
|
||||
* Inlined Human Corrections: User edits occur at the page level inside source.revised_text. source.raw_transcription remains immutable. If source.revised_text is non-null, application frontends must render source.revised_text.
|
||||
|
||||
### Async Job Lifecycle & Failure Isolation
|
||||
|
||||
* Batch Orchestrator: A job represents an overarching execution run across one or more source images belonging to a document.
|
||||
* Isolated Failures: API requests run concurrently (e.g., using asyncio). A failure on page 3 does not invalidate successful transcriptions on page 1 or 2.
|
||||
* Job States:
|
||||
- queued: Created, awaiting worker execution.
|
||||
- processing: Concurrent HTTP tasks actively running.
|
||||
- completed: 100% of linked job_source tasks succeeded (transcribed).
|
||||
- partial_success: At least one job_source succeeded and at least one failed.
|
||||
- failed: All linked job_source tasks failed or a job-level runtime error occurred.
|
||||
|
||||
### Attribution & Person Roles
|
||||
|
||||
* Multi-Person Roles: Documents support zero, one, or many authors and recipients linked via document_person.
|
||||
* Role Uniqueness: (document_id, person_id, role) must be unique to prevent duplicate role tagging.
|
||||
|
||||
---
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v2.md)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Architecture](architecture_v2.md)
|
||||
- [System Requirements](requirements_v2.md)
|
||||
- Data model (this document)
|
||||
- [Error Handling Policy](error_handling_v2.md)
|
||||
- [Implementation Plan](implementation_plan_v2.md)
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
```mermaid
|
||||
block-beta
|
||||
columns 3
|
||||
|
||||
%% UI Component Column
|
||||
block:UI["UI COMPONENTS / WIREFRAME"]:1
|
||||
columns 1
|
||||
|
||||
block:HeaderUI["Header & Nav"]:1
|
||||
columns 1
|
||||
h_title["[Text] Document Name & Type"]
|
||||
h_date["[Text] Date & Origin Location"]
|
||||
end
|
||||
|
||||
block:EditorUI["Page Transcription Editor"]:1
|
||||
columns 1
|
||||
ed_img["[Image Viewer] Source Image"]
|
||||
ed_page["[Badge] Page Number"]
|
||||
ed_raw["[Read-Only] AI Raw Output"]
|
||||
ed_rev["[Textarea] Human Revised Text"]
|
||||
end
|
||||
|
||||
block:PeopleUI["Attribution Sidebar"]:1
|
||||
columns 1
|
||||
p_author["[List] Authors (Full Name)"]
|
||||
p_recip["[List] Recipients (Full Name)"]
|
||||
p_bio["[Card] Person Biography & Dates"]
|
||||
end
|
||||
|
||||
block:JobUI["AI Processing Drawer"]:1
|
||||
columns 1
|
||||
j_status["[Badge] Job Status"]
|
||||
j_model["[Text] Provider & Model"]
|
||||
j_tokens["[JSON View] AI Token Usage"]
|
||||
end
|
||||
end
|
||||
|
||||
%% Directional Mapping / Connectors
|
||||
block:FLOW["MAPPING / FLOW"]:1
|
||||
columns 1
|
||||
f1["Reads / Updates -->"]
|
||||
f2["Renders Active Page -->"]
|
||||
f3["Joins via Role -->"]
|
||||
f4["Executes & Logs -->"]
|
||||
end
|
||||
|
||||
%% Postgres Schema Column
|
||||
block:DB["POSTGRES SQL SCHEMA"]:1
|
||||
columns 1
|
||||
|
||||
block:DocTbl["Table: document"]:1
|
||||
columns 1
|
||||
d_id["id : UUID (PK)"]
|
||||
d_name["name : TEXT"]
|
||||
d_type["document_type : TEXT"]
|
||||
d_date["document_date : DATE"]
|
||||
end
|
||||
|
||||
block:SrcTbl["Table: source"]:1
|
||||
columns 1
|
||||
s_id["id : UUID (PK)"]
|
||||
s_page["page_number : INT"]
|
||||
s_path["file_path : TEXT"]
|
||||
s_raw["raw_transcription : TEXT"]
|
||||
s_rev["revised_text : TEXT"]
|
||||
end
|
||||
|
||||
block:PersonTbl["Table: person & document_person"]:1
|
||||
columns 1
|
||||
p_id["id : UUID (PK)"]
|
||||
p_name["full_name : TEXT"]
|
||||
p_role["role : 'author' | 'recipient'"]
|
||||
end
|
||||
|
||||
block:JobTbl["Table: job & job_source"]:1
|
||||
columns 1
|
||||
j_id["id : UUID (PK)"]
|
||||
j_stat["status : VARCHAR"]
|
||||
j_prov["provider / model : TEXT"]
|
||||
j_meta["ai_metadata : JSONB"]
|
||||
end
|
||||
end
|
||||
|
||||
%% Connections
|
||||
HeaderUI --> DocTbl
|
||||
ed_img --> s_path
|
||||
ed_page --> s_page
|
||||
ed_raw --> s_raw
|
||||
ed_rev --> s_rev
|
||||
PeopleUI --> PersonTbl
|
||||
JobUI --> JobTbl
|
||||
```
|
||||
@@ -1,53 +0,0 @@
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph UI["UI Components / Wireframe"]
|
||||
direction TB
|
||||
subgraph HeaderUI["Header & Nav"]
|
||||
h_title["[Text] Document Name & Type"]
|
||||
h_date["[Text] Date & Origin Location"]
|
||||
end
|
||||
subgraph EditorUI["Page Transcription Editor"]
|
||||
ed_img["[Image Viewer] Source Image"]
|
||||
ed_page["[Badge] Page Number"]
|
||||
ed_raw["[Read-Only] AI Raw Output"]
|
||||
ed_rev["[Textarea] Human Revised Text"]
|
||||
end
|
||||
subgraph PeopleUI["Attribution Sidebar"]
|
||||
p_author["[List] Authors / Recipients"]
|
||||
end
|
||||
subgraph JobUI["AI Processing Drawer"]
|
||||
j_status["[Badge] Job Status"]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph DB["Postgres SQL Schema"]
|
||||
direction TB
|
||||
subgraph DocTbl["Table: document"]
|
||||
d_name["name : TEXT"]
|
||||
d_type["document_type : TEXT"]
|
||||
end
|
||||
subgraph SrcTbl["Table: source"]
|
||||
s_path["file_path : TEXT"]
|
||||
s_page["page_number : INT"]
|
||||
s_raw["raw_transcription : TEXT"]
|
||||
s_rev["revised_text : TEXT"]
|
||||
end
|
||||
subgraph PersonTbl["Table: person & document_person"]
|
||||
p_name["full_name : TEXT"]
|
||||
p_role["role : author | recipient"]
|
||||
end
|
||||
subgraph JobTbl["Table: job & job_source"]
|
||||
j_stat["status : VARCHAR"]
|
||||
j_meta["ai_metadata : JSONB"]
|
||||
end
|
||||
end
|
||||
|
||||
%% Mappings
|
||||
HeaderUI --> DocTbl
|
||||
ed_img --> s_path
|
||||
ed_page --> s_page
|
||||
ed_raw --> s_raw
|
||||
ed_rev --> s_rev
|
||||
PeopleUI --> PersonTbl
|
||||
JobUI --> JobTbl
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
# UI Behavioral Contracts
|
||||
|
||||
## Purpose
|
||||
|
||||
This directory defines the current user-facing behavior of the NiceGUI application. It records what each page is for, which routes and actions it exposes, what information it presents, and how success, empty, validation, and failure states behave.
|
||||
|
||||
These documents are written for maintainers and AI contributors. They are behavioral contracts, not historical implementation notes and not substitutes for the database schema.
|
||||
|
||||
## Current Page Contracts
|
||||
|
||||
- [Home](pages/home.md)
|
||||
- [Documents](pages/documents.md)
|
||||
- [People](pages/people.md)
|
||||
- [Jobs](pages/jobs.md)
|
||||
- [Sources](pages/sources.md)
|
||||
|
||||
NiceGUI registers the routes shown in each contract without the `/ui` prefix. The application mounts NiceGUI under `/ui`, so `/documents` in page code is served to a browser as `/ui/documents`.
|
||||
|
||||
## Authority Hierarchy
|
||||
|
||||
When documents disagree, use this order:
|
||||
|
||||
1. User-facing page intent and accepted behavior: the page contracts in this directory.
|
||||
2. Visual and interaction styling: [UI Style Guide](../invariant/ui_style_guide.md).
|
||||
3. UI dependency and ownership boundaries: [UI contributor instructions](../../.github/instructions/ui.instructions.md).
|
||||
4. Durable failure behavior: [Error Handling invariant](../invariant/error_handling.md).
|
||||
5. Durable AI evidence behavior: [Digital Evidence and AI Processing Provenance](../invariant/ai_evidence_and_provenance.md).
|
||||
6. Data definitions and relationships: current models plus the [V4 schema](../ver4/schema_v4.md).
|
||||
7. Planned behavior changes: the applicable V4.x scope and implementation documents.
|
||||
8. Implementation truth: current code and tests.
|
||||
|
||||
If code intentionally changes accepted page behavior, update the corresponding page contract in the same change. If code accidentally differs, correct the implementation rather than rewriting intent to match a defect.
|
||||
|
||||
## Contract Contents
|
||||
|
||||
Each page contract contains:
|
||||
|
||||
1. Purpose and user goals.
|
||||
2. Registered routes and navigation context.
|
||||
3. List, detail, and form behavior.
|
||||
4. Editable and system-managed information.
|
||||
5. Validation, empty, loading, and failure states.
|
||||
6. A concise acceptance checklist.
|
||||
7. Current implementation and test anchors.
|
||||
8. Known limitations and deferred work.
|
||||
|
||||
## Maintenance Rules
|
||||
|
||||
- Describe current accepted behavior in present tense.
|
||||
- Do not mix an obsolete “first release” design with current behavior.
|
||||
- Keep future changes in versioned scope documents and link to them from a Deferred Work section.
|
||||
- Do not reproduce the complete database field inventory here; include only fields that affect page behavior.
|
||||
- Keep service, file, and test anchors current.
|
||||
- Do not create separate current-state, target-state, and traceability copies of the same contract.
|
||||
- Keep cross-page visual rules in the UI Style Guide instead of repeating them on each page.
|
||||
- Keep database joins such as `DocumentPerson` and `JobSource` in schema/architecture documentation unless they directly affect a page interaction.
|
||||
|
||||
## Current Baseline
|
||||
|
||||
These contracts describe the V4 baseline with completed V4.1 behavior and V4.2 evidence/provenance behavior.
|
||||
Draft V4.3 Settings changes are not described as current behavior.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Documents Page Contract
|
||||
|
||||
## Purpose
|
||||
|
||||
Documents manages the archival record for each historical artifact independently of its source files and transcription jobs. A Document can be created first, linked to people in one or more roles, and used later as the parent for Sources and Jobs.
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `/documents` | Searchable archival Document list. |
|
||||
| `/documents/new` | Create a Document. |
|
||||
| `/documents/{document_id}` | View one Document and its related records. |
|
||||
| `/documents/{document_id}/edit` | Edit metadata and people-by-role links. |
|
||||
| `/documents/{document_id}/delete` | Confirm or block deletion. |
|
||||
| `/documents/{document_id}/jobs` | Show Jobs belonging to the Document. |
|
||||
| `/documents/{document_id}/sources` | Redirect to the Document-filtered Sources list. |
|
||||
|
||||
## List Behavior
|
||||
|
||||
- The title is **Archival Documents**.
|
||||
- **Create new document** opens the create route.
|
||||
- The table defaults to Document Title order and supports search and column sorting.
|
||||
- Columns are Document Title, Type, Author, Document Date, and Archive Ref.
|
||||
- Document Title is left-aligned; the remaining columns are centered.
|
||||
- Author lists all linked people in the `author` role.
|
||||
- Date display prefers exact date, then approximate date, then `Unknown`.
|
||||
- Selecting a row opens Document Detail.
|
||||
- No records displays `No documents found in repository.`
|
||||
|
||||
## Create and Edit Behavior
|
||||
|
||||
Required:
|
||||
|
||||
- Document name.
|
||||
- Document type selected from the Document Type registry.
|
||||
|
||||
Optional:
|
||||
|
||||
- Exact date.
|
||||
- Approximate date.
|
||||
- Document location.
|
||||
- Archive identifier.
|
||||
- Notes.
|
||||
- Multiple people for every configured Person Role.
|
||||
|
||||
Rules:
|
||||
|
||||
- Exact date must parse as `YYYY-MM-DD`; browser presentation may follow locale.
|
||||
- Existing people appear with disambiguating labels.
|
||||
- **Create new person** opens Person creation.
|
||||
- `person_id` may preselect that Person in the author role on Document creation.
|
||||
- An invalid requested Person produces a warning rather than a broken form.
|
||||
- `return_to=jobs_new` returns a successful create to Job creation with the new Document selected.
|
||||
- Edit includes active and inactive Document Types so historical values remain maintainable.
|
||||
- Save success returns to Document Detail.
|
||||
|
||||
## Detail Behavior
|
||||
|
||||
- The heading shows name, type, and internal ID.
|
||||
- The first Source, when present, appears in the dark-room viewer.
|
||||
- Archival Metadata shows authors, compact Document date, location, and archive identifier. Notes appear in a separate archival-notes block within the same card.
|
||||
- System Logistics shows created and updated timestamps.
|
||||
- Related People are grouped by role and link to Person Detail.
|
||||
- **Sources & Pipeline Jobs** shows counts and actions for filtered Sources, Document Jobs, and adding a Job.
|
||||
- **Edit Document** and **Delete** are available from the header.
|
||||
- Invalid IDs and missing Documents produce explicit states without rendering a partial page.
|
||||
|
||||
## Document Jobs Behavior
|
||||
|
||||
- The page lists the Document's Jobs newest first with status and Job ID.
|
||||
- **Open Job** navigates to Job Detail.
|
||||
- **Create Job** opens Job creation with the Document selected.
|
||||
- No jobs displays an explicit empty state.
|
||||
|
||||
## Delete Behavior
|
||||
|
||||
- Deletion is blocked while any Source or Job belongs to the Document.
|
||||
- The blocked state names the dependency categories and provides navigation back and to Jobs.
|
||||
- An unlinked Document requires an explicit permanent-delete action.
|
||||
- Success returns to the Documents list.
|
||||
|
||||
## Acceptance Checklist
|
||||
|
||||
- List columns, alignment, search, sorting, date fallback, and row navigation match this contract.
|
||||
- Create/edit enforce name, registered type, and valid exact-date input.
|
||||
- Multiple people can be selected independently for each configured role.
|
||||
- Person-first Document creation preselects the requested Person as author.
|
||||
- Detail links people, Sources, and Jobs to the correct records.
|
||||
- Delete never removes a Document with Source or Job dependencies.
|
||||
- Service failures use the shared error presenter and never report false success.
|
||||
|
||||
## Implementation Anchors
|
||||
|
||||
- `src/transcription/ui/pages/documents_page.py`
|
||||
- `src/transcription/ui/components/table/documents.py`
|
||||
- `src/transcription/services/documents.py`
|
||||
- `src/transcription/services/people.py`
|
||||
- `tests/ui/test_documents_page.py`
|
||||
- `tests/services/test_document_service.py`
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Document creation persists the Document before adding relationship links; a later link failure is surfaced but is not currently one atomic write.
|
||||
- Source ordering controls are deferred to the [draft V4.3 scope](../../ver4.3/scope_boundary_v4_3.md).
|
||||
@@ -0,0 +1,63 @@
|
||||
# Home Page Contract
|
||||
|
||||
## Purpose
|
||||
|
||||
Home provides a user-maintained landing page for the local archive. It combines one current image with Markdown text and lets the operator edit both without changing application source or prompt assets.
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Browser path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `/homepage` | `/ui/homepage` | View current homepage image and Markdown. |
|
||||
| `/homepage/edit` | `/ui/homepage/edit` | Upload an image and edit Markdown. |
|
||||
|
||||
The application root and `/ui` redirect to `/ui/homepage`.
|
||||
|
||||
## View Behavior
|
||||
|
||||
- The visible page heading is **Home**; the browser tab title is **VibeScribe Home**.
|
||||
- The latest homepage image appears in the shared dark-room viewer.
|
||||
- Saved Markdown is rendered in the **Home Text** card.
|
||||
- Missing text displays `No homepage text saved yet.`
|
||||
- Missing image displays the viewer's empty state.
|
||||
- **Edit Home Page** opens the edit route.
|
||||
|
||||
## Edit Behavior
|
||||
|
||||
- The image upload accepts JPEG, PNG, GIF, WebP, BMP, and TIFF files.
|
||||
- A successful upload immediately stores the file, updates the preview to that image, and displays a positive notification.
|
||||
- The Markdown textarea is initialized from the currently stored homepage text.
|
||||
- **Save** writes the textarea content, displays `Homepage saved`, and returns to Home.
|
||||
- **Cancel** returns to Home without saving textarea changes. An image already uploaded during the edit session remains stored.
|
||||
|
||||
## Storage Contract
|
||||
|
||||
- Homepage content is mutable application data under `data/homepage`.
|
||||
- Markdown is stored in `homepage.md`.
|
||||
- Uploaded images keep a sanitized basename.
|
||||
- The view selects the supported image with the most recent modification time.
|
||||
- Homepage files are not transcription prompts and are not database records.
|
||||
|
||||
## Acceptance Checklist
|
||||
|
||||
- `/`, `/ui`, and the application brand reach Home.
|
||||
- Home renders with or without stored Markdown and image content.
|
||||
- Edit loads existing Markdown.
|
||||
- A supported image upload updates the preview and becomes the latest homepage image.
|
||||
- Save persists Markdown and returns to Home.
|
||||
- Cancel does not save changed Markdown.
|
||||
|
||||
## Implementation Anchors
|
||||
|
||||
- `src/transcription/ui/pages/home_page.py`
|
||||
- `src/transcription/ui/homepage_store.py`
|
||||
- `src/transcription/ui/components/app_shell.py`
|
||||
- `tests/ui/test_upload_page.py`
|
||||
- `tests/ui/test_navigation_and_mounts.py`
|
||||
- `tests/ui/test_pages_registration.py`
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Homepage storage is fixed under the repository/application `data` directory rather than a configured application-data root.
|
||||
- Uploading an image is immediate and is not rolled back by Cancel.
|
||||
- The editor does not currently delete or select among previously uploaded images.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Jobs Page Contract
|
||||
|
||||
## Purpose
|
||||
|
||||
Jobs manages transcription processing runs. A Job belongs to one Document, links one or more Source pages, records processing provenance, and exposes lifecycle actions without making lifecycle fields directly editable.
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `/jobs` | Searchable processing Job list. |
|
||||
| `/jobs/new` | Create and queue a Job. |
|
||||
| `/jobs/{job_id}` | View status, execution logistics, and related records. |
|
||||
| `/jobs/{job_id}/cancel` | Confirm cancellation. |
|
||||
| `/jobs/{job_id}/resubmit` | Confirm resubmission of failed Sources. |
|
||||
| `/jobs/{job_id}/delete` | Confirm or block deletion. |
|
||||
|
||||
## List Behavior
|
||||
|
||||
- The title is **Transcription Pipeline Jobs**.
|
||||
- **Create job** opens Job creation and **Refresh** reloads the table.
|
||||
- Columns are Job ID, Status, Source Filename, Retries, Created, and Updated.
|
||||
- Search covers Job ID, filename, and status.
|
||||
- Status is displayed as a semantic status chip.
|
||||
- Selecting a row opens Job Detail.
|
||||
- No records displays `No job records found in repository.`
|
||||
|
||||
## Create Behavior
|
||||
|
||||
- A Target Document and at least one source file are required.
|
||||
- `document_id` may preselect a Target Document.
|
||||
- If no Documents exist, the page explains the prerequisite and links to Document creation with a return path.
|
||||
- Provider and Model are optional request overrides.
|
||||
- Upload accepts JPEG, PNG, TIFF, and PDF files and supports multiple/folder selection.
|
||||
- The visible upload queue is sorted alphabetically by original filename.
|
||||
- Files can be removed individually or cleared before submission.
|
||||
- Helper text explains numeric filename prefixes for page ordering.
|
||||
- Submission creates the Job, Source records, and JobSource links, notifies the worker, and opens Job Detail.
|
||||
|
||||
## Detail and Lifecycle Behavior
|
||||
|
||||
- The heading shows Job ID and a status badge.
|
||||
- Execution Logistics shows provider, model, prompt, retry count, and last update.
|
||||
- Document Links open the parent Document and Job-filtered Sources.
|
||||
- Queued and processing Jobs show an auto-refresh notice and reload every four seconds.
|
||||
- Polling stops when the Job becomes terminal or a refresh fails.
|
||||
- Queued and processing Jobs expose **Cancel**.
|
||||
- Jobs other than `transcribed` expose **Resubmit** under the current UI rule. The service blocks resubmission while processing is active or when no failed Sources exist.
|
||||
- All Jobs expose **Delete Job**, subject to explicit evidence-deletion guardrails.
|
||||
- Invalid and missing IDs produce explicit states.
|
||||
|
||||
## Cancel Behavior
|
||||
|
||||
- The confirmation explains that processing stops and remaining non-transcribed Sources become failed.
|
||||
- The service decides whether the current state permits cancellation.
|
||||
- Success updates the Job, notifies the worker, and returns to Job Detail.
|
||||
|
||||
## Resubmit Behavior
|
||||
|
||||
- The page shows current status and failed Source count.
|
||||
- The page explains that resubmission queues failed linked Sources while preserving immutable prior attempt evidence.
|
||||
- The service blocks submission while processing is active or when no failed Sources exist.
|
||||
- `JobSource` remains the latest compatibility projection, while every provider call appends an `ExecutionAttempt`.
|
||||
- The latest successful `Source.raw_transcription` projection remains available while a retry is pending or fails.
|
||||
- Success reports the number of resubmitted Sources and returns to Job Detail.
|
||||
|
||||
## Delete Behavior
|
||||
|
||||
- Deletion is blocked while status is `processing`.
|
||||
- Allowed deletion explicitly warns that related `JobSource` projections,
|
||||
immutable execution attempts, captured transport responses, and attempt-owned
|
||||
artifacts are permanently removed.
|
||||
- Source records and source files remain available for separate deletion.
|
||||
- Success returns to the Jobs list.
|
||||
|
||||
## Acceptance Checklist
|
||||
|
||||
- Job creation cannot proceed without a valid Document and at least one Source.
|
||||
- Upload ordering and removal controls match the displayed queue.
|
||||
- Detail shows current status and provenance summary with correct related links.
|
||||
- Active Jobs refresh without overlapping permanent polling after terminal state.
|
||||
- Cancel, resubmit, and delete honor service guardrails and show actionable failures.
|
||||
- Lifecycle fields cannot be edited directly.
|
||||
|
||||
## Implementation Anchors
|
||||
|
||||
- `src/transcription/ui/pages/jobs_page.py`
|
||||
- `src/transcription/ui/components/table/jobs.py`
|
||||
- `src/transcription/services/jobs.py`
|
||||
- `src/transcription/services/store.py`
|
||||
- `src/transcription/services/workflows.py`
|
||||
- `tests/ui/test_jobs_page.py`
|
||||
- `tests/services/test_job_service.py`
|
||||
- `tests/services/test_store.py`
|
||||
@@ -0,0 +1,90 @@
|
||||
# People Page Contract
|
||||
|
||||
## Purpose
|
||||
|
||||
People manages reusable historical-person records. A Person may appear in many Documents under different relationship roles and may optionally carry a portrait and FamilySearch identifier.
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `/people` | Searchable People list. |
|
||||
| `/people/new` | Create a Person. |
|
||||
| `/people/{person_id}` | View one Person and linked Documents. |
|
||||
| `/people/{person_id}/edit` | Edit the Person. |
|
||||
| `/people/{person_id}/delete` | Confirm permanent deletion. |
|
||||
|
||||
## List Behavior
|
||||
|
||||
- The title is **Archival Entities: People**.
|
||||
- **Create new person** opens the create route.
|
||||
- The table defaults to Full Name order and supports search and column sorting.
|
||||
- Columns are Full Name, Display Name, Maiden Name, Birth Date, and Death Date.
|
||||
- Full Name is left-aligned; Display Name, Maiden Name, and date columns are centered.
|
||||
- Birth and death values independently prefer exact date, then approximate date, then `Unknown`.
|
||||
- Selecting a row opens Person Detail.
|
||||
- No records displays `No person records found in repository.`
|
||||
|
||||
## Create and Edit Behavior
|
||||
|
||||
Required:
|
||||
|
||||
- Full name.
|
||||
|
||||
Optional:
|
||||
|
||||
- Display name and maiden name.
|
||||
- Exact and approximate birth/death dates.
|
||||
- Birth/death places.
|
||||
- Biography.
|
||||
- Portrait path or uploaded portrait.
|
||||
- FamilySearch ID.
|
||||
|
||||
Rules:
|
||||
|
||||
- Missing Full name blocks save with a warning.
|
||||
- Exact date inputs are native browser date inputs.
|
||||
- FamilySearch IDs are normalized and validated by `PeopleService`.
|
||||
- Portrait uploads are stored under the configured upload root in a Person-specific directory and update Portrait path.
|
||||
- Metadata JSON remains hidden.
|
||||
- Save success returns to Person Detail.
|
||||
|
||||
## Detail Behavior
|
||||
|
||||
- The header provides **New Document**, **Edit Person**, and **Delete**.
|
||||
- **New Document** opens Document creation with this Person requested for author preselection.
|
||||
- The portrait viewer resolves supported relative upload paths and absolute HTTP/data URLs.
|
||||
- Biographical Record shows names, compact birth/death dates, places, and an **Open in FamilySearch** link when an ID exists.
|
||||
- Biography has an explicit empty value.
|
||||
- Linked Documents show Document name, relationship role, and an action to open Document Detail.
|
||||
- No links shows both an empty state and guidance to link from a Document workflow.
|
||||
- System Logistics shows created and updated timestamps.
|
||||
|
||||
## Delete Behavior
|
||||
|
||||
- The page warns when linked Document relationships exist.
|
||||
- Confirmed deletion removes the Person and its relationship links; it does not delete Documents.
|
||||
- Success returns to the People list.
|
||||
- Missing or already-deleted records return to a safe list state.
|
||||
|
||||
## Acceptance Checklist
|
||||
|
||||
- List fields, alignment, date fallback, search, sorting, and navigation match this contract.
|
||||
- Full name is enforced on create and edit.
|
||||
- FamilySearch ID validation and link generation use the fixed supported identifier format.
|
||||
- Portrait upload and rendering remain constrained to supported media paths.
|
||||
- New Document carries the Person context.
|
||||
- Linked Documents show the correct role and target.
|
||||
- Delete wording distinguishes removal of relationship links from deletion of Documents.
|
||||
|
||||
## Implementation Anchors
|
||||
|
||||
- `src/transcription/ui/pages/people_page.py`
|
||||
- `src/transcription/ui/components/table/people.py`
|
||||
- `src/transcription/services/people.py`
|
||||
- `tests/ui/test_people_page.py`
|
||||
- `tests/services/test_v2_crud.py`
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- Structured name fields, merge/deduplication, advanced metadata editing, and Person-side relationship editing are not current behavior.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Sources Page Contract
|
||||
|
||||
## Purpose
|
||||
|
||||
Sources manages individual archived page/file records. It provides source-media viewing, current processing context, provider evidence inspection, previous/next page navigation, and human revision without allowing machine output to be edited.
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `/sources` | Global or filtered Source list. |
|
||||
| `/sources/{source_id}` | View media, transcription, revision, metadata, and evidence. |
|
||||
| `/sources/{source_id}/delete` | Confirm or block deletion. |
|
||||
|
||||
The list accepts optional `document_id` and `job_id` query parameters. Document context takes precedence if both parse successfully.
|
||||
|
||||
## List Behavior
|
||||
|
||||
- The title is **Source Asset Records**, **Sources for Document**, or **Sources for Job** according to context.
|
||||
- Global context provides **Create Job**.
|
||||
- Filtered context provides **Back to Document** or **Back to Job**.
|
||||
- Rows are ordered by page number and then upload name.
|
||||
- Columns are Document Name, Page Number, Upload Title, Status, and Error Detail.
|
||||
- Document Name, Upload Title, and Error Detail are left-aligned; Status is centered.
|
||||
- Stored Filename is intentionally absent from the list.
|
||||
- Selecting a row opens Source Detail.
|
||||
- No records displays `No source asset records found in repository.`
|
||||
|
||||
## Detail Behavior
|
||||
|
||||
- The heading shows page number, upload name, and Source ID.
|
||||
- **Back to Sources** returns to the global list.
|
||||
- **Delete Source** opens the guarded delete route.
|
||||
- Previous and Next navigate only among Sources belonging to the same Document in page order; unavailable boundary actions are disabled.
|
||||
- The media viewer resolves the stored Source path through the configured upload root.
|
||||
- Transcription Text is read-only and prefers the latest JobSource transcription, then the Source projection.
|
||||
- Editable Revision is seeded from an existing revision or the machine transcription.
|
||||
- Source Metadata shows upload name, stored filename, page number, Document Name, Document ID, and stored path. Source ID appears in the page-header subtitle.
|
||||
- SourceJob Metadata shows latest status, Job ID, execution time, provider, model, prompt, and failure detail.
|
||||
- Revision Logistics shows revised state, last-revised time, and upload time.
|
||||
|
||||
## Provider Evidence
|
||||
|
||||
- Provider Evidence is associated with the latest JobSource execution.
|
||||
- New attempts display separate expandable Request Manifest, Transport Response, OpenRouter SDK Response Snapshot,
|
||||
Normalized Metadata, Software Context, and Derived Artifacts sections.
|
||||
- Historical `raw_api_response` values are labeled as OpenRouter SDK response snapshots.
|
||||
- Missing evidence has an explicit empty state.
|
||||
- Historical executions explicitly state that exact transport evidence was not captured.
|
||||
- **Export Evidence** downloads a versioned package containing source identity, attempts, artifacts, relationships,
|
||||
schema versions, and integrity digests without source binaries, credentials, or machine-local source paths.
|
||||
|
||||
## Revision Behavior
|
||||
|
||||
- Machine transcription is never edited directly.
|
||||
- A revision must contain non-whitespace text.
|
||||
- Save persists revised text and updates the saved timestamp without leaving the page.
|
||||
- Reset restores the in-memory revision from page load or the most recent successful save. When no revision exists, it restores the machine transcription; it does not re-read the database.
|
||||
- A failed latest execution displays guidance that a human revision can preserve corrected text.
|
||||
|
||||
## Delete Behavior
|
||||
|
||||
- Deletion is allowed only when the Source has no JobSource links.
|
||||
- A linked Source shows cleanup guidance and navigation to Jobs.
|
||||
- An unlinked Source requires explicit permanent deletion.
|
||||
- Success returns to the Sources list.
|
||||
|
||||
## Acceptance Checklist
|
||||
|
||||
- Global, Document-filtered, and Job-filtered lists show the correct context and return action.
|
||||
- List columns and alignments match this contract and omit Stored Filename.
|
||||
- Previous/next navigation never crosses Document boundaries.
|
||||
- Detail keeps machine output read-only and human revision separately editable.
|
||||
- Empty, failed, and missing-evidence states remain explicit.
|
||||
- JSON evidence is readable without being mislabeled as native transport evidence.
|
||||
- Delete cannot remove a Source with processing-history links.
|
||||
|
||||
## Implementation Anchors
|
||||
|
||||
- `src/transcription/ui/pages/sources_page.py`
|
||||
- `src/transcription/ui/components/table/sources.py`
|
||||
- `src/transcription/services/sources.py`
|
||||
- `tests/ui/test_sources_page.py`
|
||||
- `tests/services/test_transcription_service.py`
|
||||
- `tests/services/test_v2_crud.py`
|
||||
|
||||
## Planned Changes
|
||||
|
||||
- Source page reordering is deferred beyond V4.3 and may be reconsidered if a demonstrated workflow need emerges.
|
||||
@@ -1,308 +0,0 @@
|
||||
# System Architecture (Version 1)
|
||||
|
||||
This document describes the production architecture of the personal historical-document transcription system. The system is intentionally optimized for single-user operation, low operational overhead, and clean internal boundaries that support future growth without rewrites.
|
||||
|
||||
## Architecture Objectives
|
||||
|
||||
The production architecture is designed to:
|
||||
|
||||
- preserve verbatim family-history source material as searchable text
|
||||
- keep operational complexity low for a personal deployment
|
||||
- support asynchronous transcription without requiring distributed infrastructure
|
||||
- maintain clear module boundaries so extensions can be added incrementally
|
||||
|
||||
## Production Scope And Scale
|
||||
|
||||
The deployed system targets personal use and a corpus of several thousand documents processed over time. The architecture favors simple, composable building blocks over distributed orchestration.
|
||||
|
||||
Current scope includes:
|
||||
|
||||
- content source upload and metadata capture
|
||||
- asynchronous transcription jobs
|
||||
- prompt-library driven transcription behavior, with one Markdown file per prompt
|
||||
- original transcription review and optional revision review
|
||||
- full-text search over accepted transcripts
|
||||
- export of transcript data
|
||||
|
||||
## Deployment Topology
|
||||
|
||||
The production deployment uses [Docker Compose](https://docs.docker.com/compose/) and treats containerized databases as extremely lightweight operational dependencies.
|
||||
|
||||
Running [PostgreSQL](https://www.postgresql.org/docs/) in its own container is considered simple by default for this system.
|
||||
|
||||
Running [MongoDB](https://www.mongodb.com/docs/) in its own container is also considered simple when document-centric storage is enabled.
|
||||
|
||||
Container count is not a hard architectural limit; a three-container deployment (app, PostgreSQL, MongoDB) is an acceptable baseline.
|
||||
|
||||
### Baseline Topology (Two Containers)
|
||||
|
||||
- one application container
|
||||
- one PostgreSQL container
|
||||
- embedded background worker execution inside the app process
|
||||
|
||||
### Expanded Topology (Three Containers)
|
||||
|
||||
- application container
|
||||
- PostgreSQL container
|
||||
- MongoDB container
|
||||
|
||||
No additional queue, scheduler, or search-engine containers are required in the baseline production setup.
|
||||
|
||||
## Runtime Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User[Browser User] --> App[FastAPI + NiceGUI Service]
|
||||
App --> Worker[In-process Background Worker]
|
||||
App --> PG[(PostgreSQL)]
|
||||
App --> MG[(MongoDB Document Store)]
|
||||
Worker --> AI[Transcription Provider]
|
||||
Worker --> PG
|
||||
Worker --> MG
|
||||
```
|
||||
|
||||
## Runtime Ownership And Startup Policy
|
||||
|
||||
The current implementation now uses explicit lifespan-owned runtime resources.
|
||||
|
||||
- application lifespan initializes and disposes database runtime resources
|
||||
- worker lifecycle is owned by application lifespan startup/shutdown
|
||||
- worker receives lifespan-owned database engine dependency explicitly
|
||||
- schema bootstrap policy is environment-aware and explicit:
|
||||
- development/test default to bootstrap enabled
|
||||
- production defaults to bootstrap disabled
|
||||
- explicit override is available via configuration
|
||||
|
||||
This aligns implementation toward REQ-7 and REQ-10 while preserving personal-scale operational simplicity.
|
||||
|
||||
## Layered Module Structure
|
||||
|
||||
### Interface Layer
|
||||
|
||||
Responsibility:
|
||||
|
||||
- HTTP API and UI routes
|
||||
- request/response validation
|
||||
- status and result presentation
|
||||
|
||||
Out of scope:
|
||||
|
||||
- business-rule enforcement
|
||||
- data-access implementation
|
||||
|
||||
### Application Layer
|
||||
|
||||
Responsibility:
|
||||
|
||||
- upload and job orchestration
|
||||
- state transitions and retry policy
|
||||
- coordination across domain and infrastructure ports
|
||||
|
||||
Out of scope:
|
||||
|
||||
- provider-specific protocol details
|
||||
- ORM or storage-specific logic
|
||||
|
||||
### Domain Layer
|
||||
|
||||
Responsibility:
|
||||
|
||||
- verbatim transcription policy
|
||||
- revision and provenance invariants
|
||||
- confidence and annotation semantics
|
||||
|
||||
Out of scope:
|
||||
|
||||
- web framework concerns
|
||||
- database and network I/O
|
||||
|
||||
### Infrastructure Layer
|
||||
|
||||
Responsibility:
|
||||
|
||||
- persistence adapters (PostgreSQL and MongoDB)
|
||||
- transcription-provider adapter
|
||||
|
||||
Out of scope:
|
||||
|
||||
- business policy decisions
|
||||
|
||||
## Processing Workflow
|
||||
|
||||
Production transcription flow:
|
||||
|
||||
1. A user uploads one or more content sources through the UI or API.
|
||||
2. The application validates payloads and creates document, source, and job records.
|
||||
3. The in-process worker de-queues the job and calls the transcription provider.
|
||||
4. The application persists original transcription output on the job, plus confidence metadata and provenance events.
|
||||
5. Job status transitions from queued to processing to transcribed or failed.
|
||||
6. The UI and API expose status, optional revision to original transcription, and searchable transcription text.
|
||||
|
||||
## Data Model Ownership
|
||||
|
||||
System-of-record entities:
|
||||
|
||||
- documents and content sources
|
||||
- transcription jobs, original transcription, and status events
|
||||
- transcript revisions
|
||||
- provenance metadata
|
||||
|
||||
### Original Transcription And Revision Ownership
|
||||
|
||||
- each processing job stores the original immutable provider output (`text`)
|
||||
- provider metadata (`provider`, `model`, `prompt_name`) and failure detail (`error_detail`) are job-owned processing artifacts
|
||||
- revisions are optional user-authored edits linked to a content source
|
||||
- a revision can be created from original `job.text`
|
||||
- many jobs will have zero revisions; revisions are additive and never overwrite original provider output
|
||||
- a document groups one or more content sources (images, PDFs, and future source types)
|
||||
|
||||
Storage strategy:
|
||||
|
||||
- PostgreSQL for relational system-of-record entities
|
||||
- MongoDB for document-oriented payloads and large transcription artifacts
|
||||
- versioned prompt artifacts stored as individual Markdown files for human editing and refinement
|
||||
- in-memory execution state treated as ephemeral
|
||||
|
||||
## Transcription Prompt Asset Policy
|
||||
|
||||
The production system treats transcription prompts as maintainable content assets.
|
||||
|
||||
- each transcription prompt is stored in its own Markdown file
|
||||
- prompt files are designed for direct human editing and iterative refinement
|
||||
- prompt updates are independent and do not require bundling unrelated prompt changes
|
||||
- prompt file identity and revision history are tracked through normal repository version control
|
||||
|
||||
## Simplicity Guardrails
|
||||
|
||||
The production system enforces these constraints to prevent accidental over-engineering:
|
||||
|
||||
- PostgreSQL in a container is treated as a lightweight default dependency
|
||||
- MongoDB in a container is treated as a lightweight optional dependency
|
||||
- three containers (app, PostgreSQL, MongoDB) is an acceptable simple deployment
|
||||
- no dedicated queue or search cluster is introduced without measured need
|
||||
- external infrastructure is added only behind existing ports/adapters
|
||||
|
||||
## Extension Path
|
||||
|
||||
The architecture supports additive growth without changing domain contracts.
|
||||
|
||||
### Stage 1: Foundation (Current)
|
||||
|
||||
- upload, transcription, review, search, export
|
||||
- in-process worker execution
|
||||
- single provider adapter
|
||||
- app plus PostgreSQL deployment
|
||||
|
||||
### Stage 2: Throughput Hardening
|
||||
|
||||
- optional MongoDB document-store enablement
|
||||
- optional external worker/queue process
|
||||
- stronger retry and dead-letter handling
|
||||
|
||||
### Stage 3: Intelligence Features
|
||||
|
||||
- entity extraction and cross-document linking
|
||||
- timeline and narrative assembly
|
||||
- optional multi-provider routing
|
||||
|
||||
Each stage preserves existing module boundaries and keeps migration risk low.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
The test strategy is aligned to personal-scale operation with fast, deterministic feedback.
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- domain transcription rules and annotation behavior
|
||||
- revision-history invariants
|
||||
- job state-transition logic
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- repository behavior and transaction boundaries
|
||||
- persistence-adapter and provider adapter contract mapping
|
||||
- upload-to-persistence roundtrip
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
- happy path: upload, transcribe, review, search, export
|
||||
- failure path: provider error, retry, surfaced failed status
|
||||
|
||||
### CI Execution Model
|
||||
|
||||
- fast suite on each push
|
||||
- optional slower provider-sandbox checks on scheduled runs
|
||||
|
||||
## Risks And Controls
|
||||
|
||||
### Runtime Responsiveness
|
||||
|
||||
Risk:
|
||||
|
||||
- long jobs can reduce responsiveness in a single-process deployment
|
||||
|
||||
Control:
|
||||
|
||||
- bounded concurrency and visible job status in the UI
|
||||
|
||||
### Database Concurrency Limits
|
||||
|
||||
Risk:
|
||||
|
||||
- contention can appear under sustained concurrent writes in personal-scale infrastructure
|
||||
|
||||
Control:
|
||||
|
||||
- tuned connection pooling and phased use of MongoDB for document-heavy workloads
|
||||
|
||||
### Provider Output Variance
|
||||
|
||||
Risk:
|
||||
|
||||
- transcription quality varies by content source type, handwriting legibility, and source quality
|
||||
|
||||
Control:
|
||||
|
||||
- first-class human review and immutable revision history
|
||||
|
||||
---
|
||||
|
||||
## Technology References
|
||||
|
||||
- [FastAPI documentation](https://fastapi.tiangolo.com/)
|
||||
- [NiceGUI documentation](https://nicegui.io/documentation)
|
||||
- [Docker Compose documentation](https://docs.docker.com/compose/)
|
||||
- [PostgreSQL documentation](https://www.postgresql.org/docs/)
|
||||
- [MongoDB documentation](https://www.mongodb.com/docs/)
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v1.md)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](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)
|
||||
|
||||
## Glossary
|
||||
|
||||
- Adapter: A component that translates between internal interfaces and external systems such as databases or AI services.
|
||||
- Background job: Work executed outside the request/response path so the UI remains responsive.
|
||||
- Boundary: A strict separation between modules with different responsibilities.
|
||||
- CI (Continuous Integration): Automated test execution for code changes.
|
||||
- Contract test: A test that verifies an adapter follows expected input/output behavior at a boundary.
|
||||
- Domain layer: The module that contains core business rules and invariants.
|
||||
- End-to-end test: A test that validates a full user flow across the running system.
|
||||
- Full-text search: Text indexing and querying optimized for natural-language search.
|
||||
- In-process worker: A background executor that runs within the same application process.
|
||||
- Integration test: A test that verifies interactions between real modules and infrastructure components.
|
||||
- MongoDB: A document-oriented database used for flexible, high-variance data structures.
|
||||
- Modular monolith: A single deployable application with strongly separated internal modules.
|
||||
- Port/Interface: A stable contract used by application/domain code to call infrastructure implementations.
|
||||
- Prompt artifact: A single Markdown file that defines one transcription prompt and can be revised independently.
|
||||
- Provenance: Metadata that records where generated data came from and how it was produced.
|
||||
- Revision history: Optional versioned record of user-authored transcription edits over time.
|
||||
- System of record: The authoritative persistent store for canonical data.
|
||||
- Vertical slice: A minimal end-to-end feature path spanning UI/API, application logic, and persistence.
|
||||
@@ -1,288 +0,0 @@
|
||||
# Error Handling Policy
|
||||
|
||||
This document defines the canonical error-handling policy for the document transcription system. It is the single source of truth for how errors are classified, surfaced to users, logged for diagnosis, and handled across UI, API, service, worker, and provider boundaries.
|
||||
|
||||
## Error Handling Objectives
|
||||
|
||||
The production error-handling model is designed to:
|
||||
|
||||
- make failures visible to the user in clear, actionable language
|
||||
- preserve enough diagnostic detail for fast troubleshooting
|
||||
- keep module behavior consistent across all boundaries
|
||||
- distinguish expected domain failures from unexpected defects
|
||||
- support safe retries for transient failures without hiding persistent faults
|
||||
|
||||
## Scope And Authority
|
||||
|
||||
This page governs error-handling behavior for:
|
||||
|
||||
- UI interactions (NiceGUI pages)
|
||||
- API endpoints (FastAPI routes)
|
||||
- application services and orchestration logic
|
||||
- in-process background worker execution
|
||||
- external provider adapters and persistence adapters
|
||||
|
||||
If implementation behavior conflicts with this document, this document is authoritative and implementation should be updated.
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Clarity first:** user-facing messages should explain what failed in plain language.
|
||||
- **Actionability required:** each surfaced error should include a suggested next step.
|
||||
- **Safety by default:** internal details are logged; sensitive details are not exposed by default in UI/API.
|
||||
- **Consistency across boundaries:** category and structure should remain stable from source to surface.
|
||||
- **Fail explicitly:** silent failure is prohibited.
|
||||
- **Traceability:** every non-trivial error should be traceable with an error reference ID.
|
||||
|
||||
## Error Taxonomy
|
||||
|
||||
The system uses stable, implementation-independent categories:
|
||||
|
||||
| Category | Definition | Typical Source | Retriable |
|
||||
| --- | --- | --- | --- |
|
||||
| `validation_error` | Payload or parameter shape/content is invalid | UI/API input validation, service guards | no |
|
||||
| `user_input_error` | User-provided artifact is unacceptable though structurally valid | unsupported file type, empty file, oversized upload | sometimes |
|
||||
| `not_found_error` | Requested resource does not exist | missing job/document/source/revision | no |
|
||||
| `conflict_error` | Requested operation violates current state constraints | invalid state transition | no |
|
||||
| `external_provider_error` | External AI/provider call fails | upstream HTTP/API/provider failures | sometimes |
|
||||
| `infrastructure_transient_error` | Temporary environment issue | network timeout, DB connection reset | yes |
|
||||
| `infrastructure_persistent_error` | Non-transient environment issue | missing permissions, misconfiguration | no |
|
||||
| `internal_unexpected_error` | Unhandled defect or unknown failure | uncaught exceptions, logic errors | unknown (default no) |
|
||||
|
||||
### Classification Rules
|
||||
|
||||
- Classification occurs as close as possible to the origin boundary.
|
||||
- Provider-specific exceptions must be normalized into taxonomy categories before crossing service boundaries.
|
||||
- Unknown exceptions are classified as `internal_unexpected_error` and logged with traceback.
|
||||
- Category names are stable contracts and must not be changed casually.
|
||||
|
||||
## User-Facing Error Experience Contract
|
||||
|
||||
When an error is shown in the GUI, it must include:
|
||||
|
||||
1. **Title** (short context, e.g., “Upload failed”)
|
||||
2. **Message** (plain-language explanation)
|
||||
3. **Suggested action** (explicit next step)
|
||||
4. **Error reference ID** (for support/debug traceability)
|
||||
5. **Technical details** (optional/collapsible for advanced users)
|
||||
|
||||
### UI Message Rules
|
||||
|
||||
- Do not expose raw stack traces by default.
|
||||
- Do not expose secrets, credentials, connection strings, or filesystem internals unless explicitly in debug tooling.
|
||||
- Prefer domain language over implementation language.
|
||||
- Use persistent visibility for important failures (dialog/card), not only transient toasts.
|
||||
|
||||
### Suggested Action Requirements
|
||||
|
||||
Every user-visible error must include a suggested course of action, such as:
|
||||
|
||||
- retry the operation
|
||||
- check file type/size constraints
|
||||
- refresh the jobs page
|
||||
- verify environment configuration
|
||||
- contact operator with error ID and timestamp
|
||||
|
||||
## API Error Response Contract
|
||||
|
||||
API errors should return a structured envelope with stable fields:
|
||||
|
||||
- `error_id`: short unique reference ID
|
||||
- `category`: taxonomy category
|
||||
- `message`: safe human-readable summary
|
||||
- `suggestion`: recommended next step
|
||||
- `details`: optional, only when safe and appropriate
|
||||
- `timestamp`: UTC ISO-8601
|
||||
|
||||
HTTP status mapping guidance:
|
||||
|
||||
- `validation_error`, `user_input_error` -> `400`
|
||||
- `not_found_error` -> `404`
|
||||
- `conflict_error` -> `409`
|
||||
- `external_provider_error` -> `502` or `503` (depending on failure mode)
|
||||
- `infrastructure_transient_error` -> `503`
|
||||
- `infrastructure_persistent_error` -> `500`
|
||||
- `internal_unexpected_error` -> `500`
|
||||
|
||||
## Logging And Observability Contract
|
||||
|
||||
All logged errors must include, where available:
|
||||
|
||||
- `error_id`
|
||||
- `category`
|
||||
- `operation` (e.g., `upload.submit`, `worker.process_job`, `jobs.refresh`)
|
||||
- `exception_type`
|
||||
- `job_id`, `document_id`, `source_id` (when relevant)
|
||||
- UTC timestamp
|
||||
|
||||
Rules:
|
||||
|
||||
- Use structured logging fields where practical.
|
||||
- Use full traceback for unexpected errors (`internal_unexpected_error`).
|
||||
- Log at boundary handoff points to preserve causal trail.
|
||||
- Avoid duplicate noisy logging for the same exception at every layer.
|
||||
|
||||
## Recovery And Retry Policy
|
||||
|
||||
### Retriable Conditions
|
||||
|
||||
Retriable failures include:
|
||||
|
||||
- transient network/provider timeouts
|
||||
- intermittent provider unavailability
|
||||
- temporary DB/network interruptions
|
||||
|
||||
### Non-Retriable Conditions
|
||||
|
||||
Non-retriable failures include:
|
||||
|
||||
- invalid file formats
|
||||
- missing required data
|
||||
- permission/configuration failures
|
||||
- deterministic domain conflicts
|
||||
|
||||
### Worker Behavior
|
||||
|
||||
- The worker must classify and persist failure details consistently.
|
||||
- Retries should be bounded by configured limits.
|
||||
- Exhausted retries must end in explicit failed status with recorded reason.
|
||||
- No infinite retry loops are allowed.
|
||||
|
||||
## Boundary-Specific Responsibilities
|
||||
|
||||
### UI Layer
|
||||
|
||||
Responsibility:
|
||||
|
||||
- display user-safe error summaries and suggested actions
|
||||
- show persistent error visibility for critical failures
|
||||
- include error reference IDs in visible output
|
||||
|
||||
Out of scope:
|
||||
|
||||
- low-level exception parsing
|
||||
- provider-specific protocol interpretation
|
||||
|
||||
### API Layer
|
||||
|
||||
Responsibility:
|
||||
|
||||
- map application exceptions into stable error envelopes and HTTP statuses
|
||||
- preserve category and error_id continuity
|
||||
|
||||
Out of scope:
|
||||
|
||||
- domain-specific remediation logic
|
||||
|
||||
### Service Layer
|
||||
|
||||
Responsibility:
|
||||
|
||||
- classify domain and infrastructure exceptions
|
||||
- convert adapter-specific failures into taxonomy categories
|
||||
- return deterministic error types to callers
|
||||
|
||||
Out of scope:
|
||||
|
||||
- presentation formatting for UI
|
||||
|
||||
### Worker Layer
|
||||
|
||||
Responsibility:
|
||||
|
||||
- execute retry policy for retriable failures
|
||||
- persist terminal failure details for jobs
|
||||
- emit operational logs with category and identifiers
|
||||
|
||||
Out of scope:
|
||||
|
||||
- direct UI messaging
|
||||
|
||||
### Provider Adapter Layer
|
||||
|
||||
Responsibility:
|
||||
|
||||
- normalize provider SDK/HTTP failures into domain-neutral exceptions
|
||||
- preserve raw provider context for logs (safely)
|
||||
|
||||
Out of scope:
|
||||
|
||||
- choosing user-facing wording
|
||||
|
||||
## Error Lifecycle Workflow
|
||||
|
||||
Standard lifecycle:
|
||||
|
||||
1. Failure occurs at a boundary or operation.
|
||||
2. Exception is classified into taxonomy category.
|
||||
3. `error_id` is created (or propagated).
|
||||
4. Error is logged with required structured fields.
|
||||
5. User/API receives safe message + suggested action.
|
||||
6. Persistent job/resource state is updated when applicable.
|
||||
7. Tests verify contract behavior for the pathway.
|
||||
|
||||
## Test Strategy For Error Handling
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- category classification behavior
|
||||
- retry eligibility decisions
|
||||
- exception-to-message mapping safety
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- UI pathways show clear message + suggested action for known failures
|
||||
- API returns structured error envelope with expected status/category
|
||||
- worker persists failed status and failure detail as required
|
||||
|
||||
### Regression Tests
|
||||
|
||||
- each previously observed production issue should have a guarding test
|
||||
- contract tests must cover adapter error normalization behavior
|
||||
|
||||
## Known Failure Patterns And Prescribed Responses
|
||||
|
||||
| Pattern | Category | User Message | Suggested Action |
|
||||
| --- | --- | --- | --- |
|
||||
| Upload payload cannot be parsed by UI handler | `internal_unexpected_error` (until narrowed) | Upload failed due to unexpected processing error | Retry once; if repeated, report error ID and check runtime version compatibility |
|
||||
| Unsupported extension | `user_input_error` | File type is not supported | Upload JPG, PNG, TIFF, or PDF |
|
||||
| Empty file upload | `validation_error` | Uploaded file is empty | Choose a valid non-empty file and retry |
|
||||
| Provider timeout | `external_provider_error` or `infrastructure_transient_error` | Transcription provider timed out | Retry from jobs page; if repeated, check provider status |
|
||||
| Job lookup missing | `not_found_error` | Requested job was not found | Refresh jobs list and open a valid job |
|
||||
|
||||
## Governance And Update Process
|
||||
|
||||
This document is a living policy artifact.
|
||||
|
||||
Update this document when:
|
||||
|
||||
- new error categories are introduced
|
||||
- handling behavior changes at any boundary
|
||||
- a production incident reveals missing guidance
|
||||
- API/UI error contracts change
|
||||
|
||||
Change requirements:
|
||||
|
||||
- update this document and associated tests in the same change set
|
||||
- preserve taxonomy stability; if changed, document migration impact
|
||||
- record noteworthy policy changes in project release notes or changelog
|
||||
|
||||
---
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v1.md)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Architecture](architecture_v1.md)
|
||||
- [System Requirements](requirements_v1.md)
|
||||
- [Data model](schema_v1.md)
|
||||
- Error Handling Policy (this document)
|
||||
- [Implementation Plan](implementation_plan_v1.md)
|
||||
|
||||
## Glossary
|
||||
|
||||
- Error category: Stable classification used to drive handling, messaging, and status mapping.
|
||||
- Error envelope: Structured API payload describing a failure.
|
||||
- Error reference ID: Short identifier used to correlate user-visible failure with logs.
|
||||
- Retriable error: Failure likely to succeed on a later attempt without code changes.
|
||||
- Terminal failure: Failure state after retries are exhausted or retry is not allowed.
|
||||
@@ -1,203 +0,0 @@
|
||||
# Version 1 Implementation Plan
|
||||
|
||||
This plan defines the path from current implementation to **Version 1 complete**, aligned to the updated domain model:
|
||||
|
||||
- `Document` groups one or more content `Source` records
|
||||
- `Job` owns original immutable provider output (`text`) and processing metadata
|
||||
- `Revision` stores optional user-authored edits linked to a `Source`
|
||||
|
||||
The objective is to complete V1 scope with production readiness while keeping non-V1 enhancements out of active delivery.
|
||||
|
||||
---
|
||||
|
||||
## V1 Completion Definition
|
||||
|
||||
V1 is complete when all of the following are true:
|
||||
|
||||
1. **Functional complete**
|
||||
- Upload, queue, processing, status display, and transcription result inspection work end-to-end.
|
||||
- Optional revision workflow is implemented (create/view/update single revision).
|
||||
2. **Data-model complete**
|
||||
- Runtime behavior, persistence, and tests all align to `Document` / `Source` / `Job` / `Revision`.
|
||||
3. **Operational complete**
|
||||
- Error handling, logs, and runbooks support reliable operation.
|
||||
4. **Documentation complete**
|
||||
- Architecture, requirements, schema, error handling, and index are consistent and current.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Data Contract Stabilization (Schema-First)
|
||||
|
||||
**Goal:** Lock a single canonical contract before further feature work.
|
||||
|
||||
### Tasks
|
||||
1. Confirm and document invariants:
|
||||
- `Job.text` is original immutable transcription output.
|
||||
- `Revision` is optional and user-authored.
|
||||
- Revisions are derived from the original `Job.text`.
|
||||
2. Verify relationship cardinality assumptions:
|
||||
- `Document` -> many `Source`
|
||||
- `Document` -> many `Job`
|
||||
- `Source` -> one `Job`
|
||||
- `Source` -> one `Revision`
|
||||
3. Ensure field naming consistency (`date_created`, `date_updated`, `date_uploaded`) across code and docs.
|
||||
4. Freeze V1 status lifecycle to current implementation (`queued`, `processing`, `transcribed`, `failed`).
|
||||
|
||||
### Deliverables
|
||||
- Updated `schema_v1.md` and `requirements.md` traceability alignment.
|
||||
- Explicit V1 data invariants section in architecture docs.
|
||||
|
||||
### Exit Criteria
|
||||
- No conflicting definitions of ownership/cardinality/status remain in docs.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Service Layer Refactor To New Model
|
||||
|
||||
**Goal:** Remove all obsolete `Transcript` assumptions from service/workflow code.
|
||||
|
||||
### Tasks
|
||||
1. Refactor `services/transcription.py`:
|
||||
- Replace transcript CRUD assumptions with job-output + revision operations.
|
||||
2. Refactor `services/jobs.py`:
|
||||
- Replace old timestamp/relationship accessors with current model fields.
|
||||
3. Refactor `services/documents.py` and `services/store.py`:
|
||||
- Ensure upload creates and links `Document`, `Source`, and `Job` correctly.
|
||||
4. Refactor `services/workflows.py`:
|
||||
- Persist original provider output to `Job`.
|
||||
- Persist failure detail to `Job.error_detail`.
|
||||
- Use `Revision` only for user-authored edits.
|
||||
|
||||
### Deliverables
|
||||
- Service layer fully aligned with new schema.
|
||||
|
||||
### Exit Criteria
|
||||
- No service module imports or persists `Transcript` model artifacts.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — UI Contract Alignment
|
||||
|
||||
**Goal:** Align pages/components to source/job/revision semantics.
|
||||
|
||||
### Tasks
|
||||
1. Update job detail and related UI components:
|
||||
- Display original immutable transcription from `Job.text`.
|
||||
- Display optional revision sourced from `Source.revision` (0 or 1).
|
||||
2. Align date fields with new schema naming.
|
||||
3. Preserve clear user messaging when no revisions exist.
|
||||
|
||||
### Deliverables
|
||||
- Updated jobs page and detail components.
|
||||
|
||||
### Exit Criteria
|
||||
- UI behavior and labels match documentation and domain model.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Database Bootstrap, Migration, and Safety
|
||||
|
||||
**Goal:** Make schema transition safe in dev/test and repeatable for deployment.
|
||||
|
||||
### Tasks
|
||||
1. Update bootstrap compatibility logic in `db/operations.py`:
|
||||
- Remove obsolete transcript-table assumptions.
|
||||
- Add forward-compatible patches for current tables only.
|
||||
2. Define migration/backfill approach for existing local data.
|
||||
3. Document rollback and recovery steps.
|
||||
4. Rehearse migration path against representative data.
|
||||
|
||||
### Deliverables
|
||||
- Migration/upgrade runbook.
|
||||
- Validated bootstrap behavior for dev/test.
|
||||
|
||||
### Exit Criteria
|
||||
- Migration path is documented and tested with no unresolved data-loss risk.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Test Suite Realignment
|
||||
|
||||
**Goal:** Restore full confidence after the schema redesign.
|
||||
|
||||
### Tasks
|
||||
1. Rewrite model tests for:
|
||||
- `Document`, `Source`, `Job`, `Revision` relationships and invariants.
|
||||
2. Rewrite service/integration tests:
|
||||
- Worker success/failure paths using `Job.text` / `Job.error_detail`.
|
||||
- Optional single-revision creation/update behavior.
|
||||
3. Update UI tests for new job-detail/revision rendering behavior.
|
||||
4. Re-enable strict CI quality gates (lint, type, tests).
|
||||
|
||||
### Deliverables
|
||||
- Updated test matrix and passing CI.
|
||||
|
||||
### Exit Criteria
|
||||
- Critical user flows and failure paths are covered and green.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Reliability, Operations, and Release Readiness
|
||||
|
||||
**Goal:** Ensure V1 is operable and launch-safe.
|
||||
|
||||
### Tasks
|
||||
1. Verify error taxonomy behavior across UI/API/service/worker.
|
||||
2. Confirm structured logging includes relevant identifiers (`job_id`, `document_id`, `source_id` when applicable).
|
||||
3. Validate retry behavior and terminal failure handling.
|
||||
4. Finalize release checklist, deployment steps, and rollback procedure.
|
||||
5. Execute final acceptance run against requirements traceability.
|
||||
|
||||
### Deliverables
|
||||
- V1 release checklist and acceptance evidence.
|
||||
- `runbook_v1.md` for incident response and operator workflows.
|
||||
- `release_checklist_v1.md` for release sign-off.
|
||||
|
||||
### Exit Criteria
|
||||
- Stakeholder sign-off and launch readiness achieved.
|
||||
|
||||
---
|
||||
|
||||
## Requirement Traceability Focus
|
||||
|
||||
The plan must keep clear evidence against these requirement groups:
|
||||
|
||||
- **Core flow:** REQ-0 to REQ-6
|
||||
- **Runtime and operations constraints:** REQ-7 to REQ-12
|
||||
- **Revision workflow:** REQ-13
|
||||
|
||||
A lightweight traceability table should be maintained with:
|
||||
|
||||
- requirement ID
|
||||
- implementation status (`not started` / `in progress` / `done`)
|
||||
- validation evidence (test name, screenshot, or runbook step)
|
||||
|
||||
---
|
||||
|
||||
## Suggested Execution Rhythm
|
||||
|
||||
- **Weekly:** requirement status and risk review
|
||||
- **Per PR:** contract checks (model names, field names, lifecycle values)
|
||||
- **Milestone checks:** end of Phases 2, 4, and 6
|
||||
|
||||
---
|
||||
|
||||
## Scope Discipline Rule (V1 Focus)
|
||||
|
||||
- Only work required to satisfy V1 requirements enters this plan.
|
||||
- Nice-to-have enhancements are captured in a separate backlog document.
|
||||
- Schema or contract changes after Phase 1 require explicit approval and traceability impact review.
|
||||
|
||||
---
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v1.md)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Architecture](architecture_v1.md)
|
||||
- [System Requirements](requirements_v1.md)
|
||||
- [Data model](schema_v1.md)
|
||||
- [Error Handling Policy](error_handling_v1.md)
|
||||
- Implementation Plan (this document)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
## Document Transcription System Overview
|
||||
|
||||
This project is a production application for transcribing and preserving historical family documents. It is intentionally designed for personal-scale use, with a simplicity-first architecture that is easy to operate and easy to extend.
|
||||
|
||||
## Start Here
|
||||
|
||||
Read [architecture_v1.md](architecture_v1.md) first.
|
||||
|
||||
The architecture page is the primary technical reference and defines:
|
||||
|
||||
- deployed topology and infrastructure limits
|
||||
- module boundaries and dependency flow
|
||||
- processing life cycle and data ownership
|
||||
- test strategy, risk controls, and extension path
|
||||
|
||||
## What The Application Does
|
||||
|
||||
At a high level, users upload images or PDFs as content sources for handwritten, typed, or typeset documents, run asynchronous transcription jobs, review optional revisions, and search across accepted text.
|
||||
|
||||
### Core capabilities:
|
||||
|
||||
- document grouping with one or more content sources and metadata capture
|
||||
- asynchronous transcription with visible job status
|
||||
- immutable original transcription persisted with each job (plus provider/model/prompt metadata)
|
||||
- transcription prompt management with one Markdown file per prompt for human refinement over time
|
||||
- optional revisions for user-authored edits of original immutable transcription text
|
||||
- full-text search over accepted transcripts
|
||||
- export of transcript data
|
||||
|
||||
## Production Operating Model
|
||||
|
||||
The system runs with minimal operational overhead:
|
||||
|
||||
- PostgreSQL in a dedicated Docker container is considered extremely lightweight and simple for this system
|
||||
- MongoDB in a dedicated Docker container is also considered extremely lightweight and simple for document-centric persistence
|
||||
- a three-container deployment (app, PostgreSQL, MongoDB) is a simple and acceptable baseline
|
||||
- no required queue or search-engine containers in the baseline setup
|
||||
|
||||
This operating model keeps deployment and maintenance simple while preserving clean boundaries for future scale.
|
||||
|
||||
---
|
||||
|
||||
## Documentation Map
|
||||
|
||||
- System Overview (this document)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Architecture](architecture_v1.md)
|
||||
- [System Requirements](requirements_v1.md)
|
||||
- [Data model](schema_v1.md)
|
||||
- [Error Handling Policy](error_handling_v1.md)
|
||||
- [Implementation Plan](implementation_plan_v1.md)
|
||||
|
||||
## Glossary
|
||||
|
||||
- Document-oriented persistence: Storing data as flexible records instead of fixed relational rows.
|
||||
- Prompt artifact: A single Markdown file that defines one transcription prompt and is edited independently.
|
||||
- System of record: The authoritative persistent store for canonical data.
|
||||
@@ -1,45 +0,0 @@
|
||||
# V1 Release Readiness Checklist
|
||||
|
||||
Use this checklist before declaring V1 operationally complete.
|
||||
|
||||
## A) Functional Readiness
|
||||
|
||||
- [ ] Upload flow works for supported file types.
|
||||
- [ ] Worker transitions jobs through `queued -> processing -> transcribed|failed`.
|
||||
- [ ] Job detail displays immutable original transcription from `Job.text`.
|
||||
- [ ] Revision workflow supports create/update/view/delete for optional single revision.
|
||||
|
||||
## B) Reliability and Error Handling
|
||||
|
||||
- [ ] Error categories surface with actionable messages in UI/API pathways.
|
||||
- [ ] Failed jobs persist `error_detail` and terminal state.
|
||||
- [ ] Stale processing recovery verified on restart.
|
||||
- [ ] Retry/timeout behavior validated against configured limits.
|
||||
|
||||
## C) Operational Readiness
|
||||
|
||||
- [ ] `runbook_v1.md` reviewed and current.
|
||||
- [ ] `migration_v1.md` reviewed and current.
|
||||
- [ ] Backup and rollback procedures tested at least once.
|
||||
- [ ] Incident escalation packet template is known to operators.
|
||||
|
||||
## D) Quality Gates
|
||||
|
||||
- [ ] Lint/type checks pass.
|
||||
- [ ] `pytest -m "not external" -q` passes.
|
||||
- [ ] Targeted external/provider checks executed (if credentials available).
|
||||
- [ ] Release evidence recorded in `release_evidence_v1.md`.
|
||||
|
||||
## E) Traceability and Documentation
|
||||
|
||||
- [ ] `requirements_v1.md` aligns with implemented V1 behavior.
|
||||
- [ ] `architecture_v1.md`, `schema_v1.md`, and `error_handling_v1.md` are consistent.
|
||||
- [ ] `traceability_v1.md` is updated with current implementation and test evidence.
|
||||
- [ ] `implementation_plan_v1.md` phase status updated with evidence references.
|
||||
- [ ] REQ traceability evidence links recorded (tests/runbook/checks).
|
||||
|
||||
## Release Sign-Off
|
||||
|
||||
- [ ] Technical sign-off complete.
|
||||
- [ ] Operational sign-off complete.
|
||||
- [ ] V1 completion date recorded.
|
||||
@@ -1,39 +0,0 @@
|
||||
# V1 Release Evidence Log
|
||||
|
||||
## Step 5 Quality Gates (2026-07-29)
|
||||
|
||||
### Lint
|
||||
|
||||
- Command: `python -m ruff check .`
|
||||
- Result: ✅ pass
|
||||
- Notes: initial findings were auto-fixed (`ruff --fix`) plus small manual line-wrap/annotation adjustments.
|
||||
|
||||
### Tests (primary gate)
|
||||
|
||||
- Command: `python -m pytest -m "not external" -q`
|
||||
- Result: ✅ pass (`[100%]`)
|
||||
|
||||
### Tests (external smoke)
|
||||
|
||||
- Command: `python -m pytest -m external -q`
|
||||
- Result: ✅ pass (`[100%]`)
|
||||
|
||||
### Type Check
|
||||
|
||||
- Command: `python -m ty check src tests`
|
||||
- Result: ⚠️ not passing
|
||||
- Summary: existing SQLModel/SQLAlchemy typing incompatibilities and test double typing mismatches remain.
|
||||
|
||||
Key current blocker families:
|
||||
|
||||
1. SQLModel relationship/query attribute typing (`selectinload`, `order_by`, `.any()`)
|
||||
2. SQLAlchemy join clause typing in `services/transcription.py`
|
||||
3. Test fake client type mismatch for `OpenRouterTranscriptionProvider(client=...)`
|
||||
4. `Settings(**defaults)` typed-dict strictness in `tests/test_config.py`
|
||||
|
||||
## Current Gate Status
|
||||
|
||||
- Lint: pass
|
||||
- Non-external tests: pass
|
||||
- External smoke tests: pass
|
||||
- Type check: **blocked** (requires dedicated typing cleanup pass)
|
||||
@@ -1,98 +0,0 @@
|
||||
## Document Transcription System Requirements
|
||||
|
||||
This page captures a SysML v1.6-style requirements baseline for the production system described in [index_v1.md](index_v1.md). The model is represented as concise tables and traceability lists that preserve SysML-style IDs and relationship semantics.
|
||||
|
||||
## Scope
|
||||
|
||||
- System of interest: the single Python application service (NiceGUI + FastAPI) with PostgreSQL as the relational system of record and optional MongoDB for document-oriented persistence.
|
||||
- Operational context: local-first execution with Docker Compose and an intentionally lightweight production trajectory.
|
||||
- Primary concern: end-to-end transcription job lifecycle from upload through completion or failure.
|
||||
|
||||
## Requirements Model (Concise Text Form)
|
||||
|
||||
### Requirements
|
||||
|
||||
| ID | Category | Requirement | Risk | Verify Method |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| REQ-0 | System | Provide end-to-end document transcription with persistent, inspectable lifecycle state. | medium | demonstration |
|
||||
| REQ-1 | Functional | Allow users to upload one or more images or PDFs as sources from the web UI. | low | test |
|
||||
| REQ-2 | Functional | Run each upload through asynchronous processing that returns an original transcription or explicit failure. | high | test |
|
||||
| REQ-3 | Functional | Persist and expose job states: queued, processing, transcribed, failed. | high | inspection |
|
||||
| REQ-4 | Functional | Persist transcription output, processing history, and failure details. | medium | test |
|
||||
| REQ-5 | Interface | Expose API and UI views for status inspection and completed transcription reading. | medium | demonstration |
|
||||
| REQ-6 | Performance | Trigger background processing on upload to preserve UI responsiveness. | medium | analysis |
|
||||
| REQ-7 | Design Constraint | Keep lifespan-owned runtime resources: SQLAlchemy engine, async session factory, worker resources, provider clients. | medium | inspection |
|
||||
| REQ-8 | Design Constraint | Initialize configuration and logging once at startup through centralized mechanisms. | low | inspection |
|
||||
| REQ-9 | Design Constraint | Use Docker Compose baseline of app plus PostgreSQL; allow optional MongoDB container when enabled. | medium | demonstration |
|
||||
| REQ-10 | Design Constraint | Keep schema bootstrap explicit and opt-in; normal startup does not mutate production schema. | high | inspection |
|
||||
| REQ-11 | Design Constraint | Use service-backed persistence for core document and job data. | medium | inspection |
|
||||
| REQ-12 | Design Constraint | Store transcription prompts as individual Markdown artifacts for iterative refinement. | medium | inspection |
|
||||
| REQ-13 | Functional | Allow users to create one optional revision of transcription text derived from the original job transcription. | low | test |
|
||||
|
||||
### Requirement Relationships
|
||||
|
||||
- Contains: REQ-0 contains REQ-1 through REQ-13.
|
||||
- Derives: REQ-2 -> REQ-3, REQ-3 -> REQ-4.
|
||||
- Traces: REQ-5 -> REQ-3.
|
||||
- Refines: REQ-6 -> REQ-2.
|
||||
|
||||
### Architecture Elements
|
||||
|
||||
| Element | Type | Doc Reference |
|
||||
| --- | --- | --- |
|
||||
| UI | NiceGUI pages | src/transcription/ui/pages |
|
||||
| API | FastAPI routes | src/transcription/api/routes.py |
|
||||
| GRAPH | Async processing workflow | src/transcription/services, src/transcription/ai |
|
||||
| DBREL | PostgreSQL + SQLModel relational persistence | src/transcription/db |
|
||||
| DBDOC | MongoDB document persistence | src/transcription/db, src/transcription/services |
|
||||
| OPS | Docker Compose runtime | docker-compose.yml |
|
||||
| PROMPTS | Transcription prompt artifact library (Markdown files) | .github/prompts, docs |
|
||||
| TESTS | Pytest verification suite | tests |
|
||||
|
||||
### Satisfaction Mapping
|
||||
|
||||
- UI satisfies REQ-1, REQ-5, REQ-13.
|
||||
- API satisfies REQ-5.
|
||||
- GRAPH satisfies REQ-2, REQ-6.
|
||||
- DBREL satisfies REQ-3, REQ-10, REQ-13.
|
||||
- DBDOC satisfies REQ-4, REQ-11.
|
||||
- OPS satisfies REQ-9.
|
||||
- PROMPTS satisfies REQ-12.
|
||||
|
||||
### Verification Mapping
|
||||
|
||||
- TESTS verifies REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-10, REQ-11, REQ-12, REQ-13.
|
||||
|
||||
## Requirement Notes
|
||||
|
||||
- Requirement IDs (`REQ-*`) are stable references for planning, implementation, and test traceability.
|
||||
- The model uses compact tables and traceability lists for renderer compatibility while preserving SysML-style requirement IDs and relationship semantics.
|
||||
- Requirement categories (functional, interface, performance, and design constraints) are preserved as explicit REQ entries and relationship labels to keep change impact visible.
|
||||
- PostgreSQL containerization and optional MongoDB containerization are both treated as extremely lightweight and simple operational choices in this architecture.
|
||||
|
||||
## Verification Intent
|
||||
|
||||
- Demonstration: validate end-to-end behavior via running system flows and operator-visible outcomes.
|
||||
- Inspection: verify architecture and startup/runtime policies in code and configuration.
|
||||
- Analysis: evaluate asynchronous execution behavior and design sufficiency.
|
||||
- Test: automate behavioral checks through pytest suites and service-level tests.
|
||||
|
||||
---
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v1.md)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Architecture](architecture_v1.md)
|
||||
- System Requirements (this document)
|
||||
- [Data model](schema_v1.md)
|
||||
- [Error Handling Policy](error_handling_v1.md)
|
||||
- [Implementation Plan](implementation_plan_v1.md)
|
||||
|
||||
## Glossary
|
||||
|
||||
- Document-oriented persistence: A storage approach that uses flexible document structures for variable data shapes.
|
||||
- Prompt artifact: A single Markdown file that defines one transcription prompt and is revised independently.
|
||||
- SysML: Systems Modeling Language used to express structured requirements and traceability.
|
||||
- System of record: The authoritative persistent store for canonical business data.
|
||||
@@ -1,129 +0,0 @@
|
||||
# V1 Operations Runbook
|
||||
|
||||
This runbook provides day-2 operational procedures for the V1 baseline.
|
||||
|
||||
## Scope
|
||||
|
||||
Applies to:
|
||||
|
||||
- local/hosted V1 runtime
|
||||
- SQLite-backed persistence
|
||||
- in-process worker lifecycle
|
||||
- OpenRouter provider integration
|
||||
|
||||
## Preconditions
|
||||
|
||||
- `.env` contains `OPENROUTER_API_KEY`
|
||||
- app starts successfully
|
||||
- `uploads/` and `prompts/` are writable
|
||||
- health endpoint responds at `/healthz`
|
||||
|
||||
## Standard Startup Procedure
|
||||
|
||||
1. Start the app using the project-standard command.
|
||||
2. Open `/healthz` and verify `{"status":"ok"}`.
|
||||
3. Open `/ui/upload` and submit a small valid file.
|
||||
4. Confirm job transitions from `queued` -> `processing` -> `transcribed` (or `failed` with detail).
|
||||
|
||||
## Standard Shutdown Procedure
|
||||
|
||||
1. Stop the application process.
|
||||
2. Ensure no active process still holds the SQLite file.
|
||||
3. If maintenance is planned, copy the DB file before edits:
|
||||
- `transcription.db` (or configured `DATABASE_URL` file path)
|
||||
|
||||
## Incident: Jobs Stuck In `processing`
|
||||
|
||||
### Symptoms
|
||||
|
||||
- Jobs remain `processing` for longer than provider timeout
|
||||
- New uploads queue but do not complete
|
||||
- provider usage increases but no terminal job state is visible
|
||||
|
||||
### Checks
|
||||
|
||||
1. Confirm app process is still running.
|
||||
2. Confirm worker loop is active (startup logs include worker lifespan start).
|
||||
3. Inspect recent app logs for:
|
||||
- `worker.process_job`
|
||||
- `error_id`
|
||||
- `category`
|
||||
- `job_id` / `document_id` / `source_id`
|
||||
4. Verify provider credentials and provider status.
|
||||
|
||||
### Recovery
|
||||
|
||||
1. Restart the app to trigger stale-processing recovery.
|
||||
2. On startup, app re-queues stale processing jobs based on timeout policy.
|
||||
3. Re-check jobs page and confirm terminal state progression.
|
||||
4. If persistent, capture logs + error IDs and move to deep investigation.
|
||||
|
||||
## Incident: Provider Authentication Failures
|
||||
|
||||
### Symptoms
|
||||
|
||||
- failures categorized as provider/auth
|
||||
- jobs fail quickly with authentication guidance
|
||||
|
||||
### Recovery
|
||||
|
||||
1. Validate `OPENROUTER_API_KEY` value.
|
||||
2. Restart app after updating env.
|
||||
3. Re-run a small transcription to confirm recovery.
|
||||
|
||||
## Incident: Upload Failures
|
||||
|
||||
### Symptoms
|
||||
|
||||
- UI reports upload errors
|
||||
- unsupported extension or empty payload
|
||||
|
||||
### Recovery
|
||||
|
||||
1. Validate file extension (`.jpg`, `.jpeg`, `.png`, `.tif`, `.tiff`, `.pdf`).
|
||||
2. Validate file is not empty.
|
||||
3. Validate upload directory permissions.
|
||||
4. Retry upload.
|
||||
|
||||
## Incident: Database File/Permission Issues
|
||||
|
||||
### Symptoms
|
||||
|
||||
- persistence errors during upload/job update
|
||||
- startup failures around schema/runtime
|
||||
|
||||
### Recovery
|
||||
|
||||
1. Confirm the configured DB file path exists and is writable.
|
||||
2. Confirm parent directory permissions.
|
||||
3. Restore from last known backup copy if corruption is suspected.
|
||||
4. Restart app and run smoke test.
|
||||
|
||||
## Logging Requirements (Operational)
|
||||
|
||||
Operational triage should always capture:
|
||||
|
||||
- `error_id`
|
||||
- category
|
||||
- operation name
|
||||
- `job_id`, `document_id`, `source_id` when applicable
|
||||
- UTC timestamp
|
||||
|
||||
## Escalation Packet (When opening an issue)
|
||||
|
||||
Include:
|
||||
|
||||
- exact timestamp window
|
||||
- one failing `job_id`
|
||||
- relevant `error_id` values
|
||||
- latest 100 lines of app logs
|
||||
- environment summary (`DATABASE_URL` type, app version/commit)
|
||||
|
||||
## Post-Incident Validation
|
||||
|
||||
After mitigation, verify:
|
||||
|
||||
1. Upload works.
|
||||
2. One job reaches `transcribed`.
|
||||
3. One induced failure reaches `failed` with error detail.
|
||||
4. Jobs page and detail page render correctly.
|
||||
@@ -1,98 +0,0 @@
|
||||
## Database Schema (V1 Baseline)
|
||||
|
||||
This document describes the current relational schema for the transcription system.
|
||||
|
||||
All primary and foreign keys in the domain models are UUID-based in V1.
|
||||
|
||||
---
|
||||
|
||||
## Schema Diagram
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
DOCUMENT {
|
||||
UUID id PK
|
||||
TEXT name
|
||||
}
|
||||
|
||||
JOB {
|
||||
UUID id PK
|
||||
UUID document_id FK
|
||||
TEXT status
|
||||
INTEGER retry_count
|
||||
DATETIME date_created
|
||||
DATETIME date_updated
|
||||
TEXT provider
|
||||
TEXT model
|
||||
TEXT prompt_name
|
||||
TEXT text
|
||||
TEXT error_detail
|
||||
}
|
||||
|
||||
SOURCE {
|
||||
UUID id PK
|
||||
UUID document_id FK
|
||||
UUID job_id FK
|
||||
TEXT upload_name
|
||||
TEXT filename
|
||||
TEXT file_path
|
||||
DATETIME date_uploaded
|
||||
}
|
||||
|
||||
REVISION {
|
||||
UUID id PK
|
||||
UUID source_id "FK, UK"
|
||||
INTEGER revision
|
||||
TEXT text
|
||||
DATETIME date_created
|
||||
}
|
||||
|
||||
DOCUMENT ||--o{ SOURCE : has_many
|
||||
DOCUMENT ||--o{ JOB : has_many
|
||||
JOB ||--o{ SOURCE : referenced_by
|
||||
SOURCE ||--o| REVISION : has_optional_one
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Table Relationships and Constraints
|
||||
|
||||
- A `Document` can have zero or more `Source` records.
|
||||
- A `Document` can have zero or more `Job` records.
|
||||
- A `Source` belongs to exactly one `Document` and one `Job`.
|
||||
- A `Source` may have one optional `Revision`.
|
||||
- Optional `0..1` revision cardinality is enforced by uniqueness on `revision.source_id`.
|
||||
|
||||
### Invariants
|
||||
|
||||
- `Job.text` stores immutable original provider transcription output.
|
||||
- `Revision` rows are optional user-authored edits derived from original transcription.
|
||||
- Revisions do not overwrite original `Job.text`.
|
||||
- Job status lifecycle values are: `queued`, `processing`, `transcribed`, `failed`.
|
||||
|
||||
### Timestamp Fields
|
||||
|
||||
- `Job.date_created`
|
||||
- `Job.date_updated`
|
||||
- `Source.date_uploaded`
|
||||
- `Revision.date_created`
|
||||
|
||||
---
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v1.md)
|
||||
- [System Design Intent](intent.md)
|
||||
- [Transcription Methodology](transcription_methodology.md)
|
||||
- [System Architecture](architecture_v1.md)
|
||||
- [System Requirements](requirements_v1.md)
|
||||
- Data model (this document)
|
||||
- [Error Handling Policy](error_handling_v1.md)
|
||||
- [Implementation Plan](implementation_plan_v1.md)
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Document**: logical grouping for one or more transcribed sources.
|
||||
- **Source**: uploaded file content (image/PDF) linked to a job.
|
||||
- **Job**: processing record that stores lifecycle status and original output.
|
||||
- **Revision**: optional single user-authored edited text linked to a source.
|
||||
@@ -1,40 +0,0 @@
|
||||
# V1 Traceability Matrix
|
||||
|
||||
This matrix provides implementation and validation evidence for V1 requirements (`REQ-0` through `REQ-13`).
|
||||
|
||||
Status values:
|
||||
|
||||
- `done`: implemented and evidence recorded
|
||||
- `in progress`: partially implemented or evidence incomplete
|
||||
- `not started`: no implementation/evidence yet
|
||||
|
||||
## Requirement Evidence Table
|
||||
|
||||
| Requirement | Status | Implementation Evidence | Validation Evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| REQ-0 | done | End-to-end upload + worker pipeline in `src/transcription/services/store.py`, `src/transcription/worker.py`, `src/transcription/services/workflows.py` | `tests/integration/test_pipeline_flow.py` |
|
||||
| REQ-1 | done | Upload UI/page flow in `src/transcription/ui/pages/upload_page.py`, `src/transcription/ui/components/upload.py` | `tests/ui/test_upload_page.py`, `tests/integration/test_pipeline_flow.py` |
|
||||
| REQ-2 | done | Async worker execution and provider call orchestration in `src/transcription/worker.py`, `src/transcription/services/workflows.py` | `tests/integration/test_pipeline_flow.py`, `tests/services/test_workflows_reliability.py` |
|
||||
| REQ-3 | done | Job lifecycle state model + transitions in `src/transcription/models.py`, `src/transcription/services/jobs.py`, `src/transcription/services/workflows.py` | `tests/services/test_job_service.py`, `tests/ui/test_jobs_page.py` |
|
||||
| REQ-4 | done | Persistence of original output and failure detail in `src/transcription/services/transcription.py`, `src/transcription/services/workflows.py` | `tests/integration/test_pipeline_flow.py`, `tests/services/test_workflows_reliability.py` |
|
||||
| REQ-5 | done | Status/result inspection via UI pages and API health route in `src/transcription/ui/pages/jobs_page.py`, `src/transcription/api/health.py` | `tests/ui/test_jobs_page.py`, `tests/ui/test_pages_registration.py`, `tests/api/test_health.py` |
|
||||
| REQ-6 | done | Background processing trigger/worker notifier and non-blocking workflow in `src/transcription/ui/components/upload.py`, `src/transcription/worker.py` | `tests/test_app.py`, `tests/services/test_workflows_reliability.py` |
|
||||
| REQ-7 | done | Lifespan-owned runtime resources in `src/transcription/app.py`, `src/transcription/db/runtime.py` | `tests/test_app.py`, `tests/test_db.py` |
|
||||
| REQ-8 | done | Centralized settings/logging initialization in `src/transcription/config.py`, `src/transcription/app.py` | `tests/test_config.py`, `tests/test_app.py` |
|
||||
| REQ-9 | done | Containerized runtime baseline in `docker-compose.yml`, `Dockerfile` | `release_checklist_v1.md` (Ops checklist), manual demonstration step |
|
||||
| REQ-10 | done | Explicit schema bootstrap policy + runtime controls in `src/transcription/config.py`, `src/transcription/app.py`, `src/transcription/db/operations.py` | `tests/test_db.py`, `tests/test_config.py` |
|
||||
| REQ-11 | done | Service/workflow persistence boundaries in `src/transcription/services/*.py`, `src/transcription/services/workflows.py` | `tests/services/test_job_service.py`, `tests/services/test_transcription_service.py` |
|
||||
| REQ-12 | done | Prompt artifacts in `prompts/` and loading/validation in `src/transcription/services/transcription.py` | `tests/test_prompts.py` |
|
||||
| REQ-13 | done | Optional single revision create/update/view/delete in `src/transcription/services/transcription.py`, `src/transcription/ui/pages/jobs_page.py` | `tests/services/test_transcription_service.py`, `tests/ui/test_jobs_page.py` |
|
||||
|
||||
## Operational Evidence (Step 3 Artifacts)
|
||||
|
||||
- Runbook: `runbook_v1.md`
|
||||
- Migration/backfill/rollback guidance: `migration_v1.md`
|
||||
- Release readiness checklist: `release_checklist_v1.md`
|
||||
|
||||
## Verification Cadence
|
||||
|
||||
- Per change: maintain `tests/test_traceability.py` mappings for touched requirements.
|
||||
- Per milestone: update this table status and evidence links.
|
||||
- Pre-release: confirm all rows are `done` and non-external suite is green.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Implementation Plan (Version 4.1)
|
||||
|
||||
## Goal
|
||||
|
||||
Deliver the V4.1 usability revision as a small, behavior-safe increment over the V4 baseline.
|
||||
|
||||
## Implementation Principles
|
||||
|
||||
- Keep presentation formatting in UI components and route orchestration in pages.
|
||||
- Keep persistence and cross-record queries behind service boundaries.
|
||||
- Reuse shared table and date-label helpers instead of duplicating fallback logic.
|
||||
- Make the FamilySearch schema change additive and nullable.
|
||||
- Add focused tests for changed behavior before broad regression verification.
|
||||
|
||||
## Current Project Impact
|
||||
|
||||
| Area | Expected impact |
|
||||
| --- | --- |
|
||||
| Persistence | Add nullable `Person.family_search_id`; provide the repository's supported schema-upgrade path for existing databases. |
|
||||
| People service | Normalize and validate FamilySearch IDs at the domain/service boundary if model validation does not fully cover writes. |
|
||||
| Documents UI | Add table data, improve relationship labels/links, compact date display, and combine processing navigation. |
|
||||
| People UI | Add table date fields, Person-first Document creation, compact date display, and FamilySearch controls. |
|
||||
| Sources service/UI | Query adjacent document Sources and add bounded navigation; revise list columns and wrapping. |
|
||||
| Jobs UI | Refresh the active detail read model on a timer until terminal status. |
|
||||
| Shared UI | Add reusable constrained/wrapped table presentation and compact date formatting where appropriate. |
|
||||
| Tests | Update model/service and UI coverage for all affected workflows. |
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### 1. Add Shared Presentation Rules
|
||||
|
||||
- Review `ui/components/table/common.py` and packaged theme CSS for the narrowest reusable table-width solution.
|
||||
- Add reusable styles or column slots for constrained, wrapping, left-aligned text.
|
||||
- Add a shared formatter for exact/approximate/unknown dates if it can be reused without coupling components to persistence.
|
||||
- Preserve sorting and search behavior for rendered display values.
|
||||
|
||||
### 2. Update Archival List Tables
|
||||
|
||||
- Extend the Document table read model with author names and the compact document date.
|
||||
- Build author display from eagerly loaded document-person links using the `author` role.
|
||||
- Apply title/type alignment and constrained title wrapping.
|
||||
- Extend the Person table read model with compact birth and death date values.
|
||||
- Apply Display Name and Maiden Name alignment.
|
||||
- Remove Stored Filename from the Source table read model only if no other list behavior consumes it; always remove its rendered column.
|
||||
- Constrain and left-align the requested Source columns.
|
||||
- Add or update UI component tests for serialized rows, columns, and fallback formatting.
|
||||
|
||||
### 3. Improve Document Relationship Workflows
|
||||
|
||||
- Introduce one person-label formatter that combines preferred Display Name, Full Name context, and known birth year without implying uniqueness.
|
||||
- Use Person UUIDs as selector values.
|
||||
- Apply the formatter to every relationship role selector.
|
||||
- Change Related People rows into actions that navigate to `/people/{person_id}`.
|
||||
- Replace separate exact/approximate rows in view mode with one conditional Document Date row.
|
||||
- Combine Pipeline Jobs and Sources into one related-processing card beneath Related People.
|
||||
- Preserve existing job/source counts and navigation actions.
|
||||
|
||||
### 4. Add the Person-First Document Workflow
|
||||
|
||||
- Add a New Document action on Person Detail.
|
||||
- Pass the Person UUID through a narrowly defined query parameter to `/documents/new`.
|
||||
- Validate the requested UUID against the loaded people list.
|
||||
- Preselect that person in the intended default relationship role. Use `author` unless a different role is explicitly encoded later.
|
||||
- Ignore invalid or unavailable preselection values with the application's normal visible error/notification behavior.
|
||||
- Confirm ordinary `/documents/new` behavior remains unchanged.
|
||||
|
||||
### 5. Add FamilySearch Person References
|
||||
|
||||
- Add nullable, unique `family_search_id` to the `Person` model and schema.
|
||||
- Implement a non-destructive upgrade for existing SQLite and PostgreSQL databases using the repository's established schema-management approach.
|
||||
- Normalize values by trimming and uppercasing.
|
||||
- Validate the `XXXX-XXX` alphanumeric identifier shape and return a clear validation error for malformed input.
|
||||
- Report duplicate identifiers as a deterministic conflict rather than a generic persistence failure.
|
||||
- Add the field to Person create/edit forms and preserve it during updates.
|
||||
- Add a URL builder that safely inserts only a validated identifier into the fixed FamilySearch details URL.
|
||||
- Render a FamilySearch action on Person Detail only when an identifier is present.
|
||||
- Add persistence, normalization, validation, form, and link-generation tests.
|
||||
|
||||
### 6. Add Source Page Navigation
|
||||
|
||||
- Add a Sources service query that returns previous/current/next context for a Source within its Document.
|
||||
- Define ordering by `page_number`, with a stable secondary key such as Source UUID for defensive determinism.
|
||||
- Keep navigation bounded to the current `document_id`.
|
||||
- Render previous and next actions adjacent to the source viewer or detail header.
|
||||
- Disable or omit unavailable boundary actions.
|
||||
- Test first, middle, last, single-page, and cross-document cases.
|
||||
|
||||
### 7. Add Job Detail Auto-Refresh
|
||||
|
||||
- Make Job Detail content refreshable without rebuilding unrelated global navigation.
|
||||
- Start a NiceGUI timer only for queued or processing jobs.
|
||||
- On each tick, re-read the Job through `JobService` and refresh the detail content.
|
||||
- Use a 4-second default interval.
|
||||
- Stop or deactivate the timer when status becomes completed, partial success, failed, or cancelled, according to the model's actual terminal states.
|
||||
- Prevent overlapping refresh callbacks.
|
||||
- Retain existing error presentation if a refresh read fails.
|
||||
- Add UI tests for timer creation, refresh, and terminal-state stopping.
|
||||
|
||||
### 8. Simplify View-Mode Date Rows
|
||||
|
||||
- On Document Detail, show exact date, else approximate date, else one not-set value.
|
||||
- On Person Detail, apply the same independent rule to birth and death.
|
||||
- Do not hide either input in create/edit mode.
|
||||
- Test each exact, approximate, and absent state.
|
||||
|
||||
### 9. Verification and Documentation Alignment
|
||||
|
||||
- Run the focused model/service/UI tests covering changed surfaces.
|
||||
- Run the existing regression suite appropriate to persistence and UI changes.
|
||||
- Confirm SQLite and PostgreSQL model compatibility at the schema-definition level.
|
||||
- Update V4.1 documentation if implementation reveals a necessary boundary change; do not silently expand scope.
|
||||
|
||||
## Recommended Delivery Order
|
||||
|
||||
1. Shared formatters and table presentation.
|
||||
2. Additive Person schema change and FamilySearch validation.
|
||||
3. Document and Person list/detail changes.
|
||||
4. Person-first Document workflow.
|
||||
5. Source navigation.
|
||||
6. Job polling.
|
||||
7. Focused and regression verification.
|
||||
|
||||
## Done When
|
||||
|
||||
- Every V4.1 acceptance criterion is demonstrated or covered by a focused test.
|
||||
- Existing Person rows remain valid after the nullable schema addition.
|
||||
- Duplicate FamilySearch references cannot be assigned to multiple local Person records.
|
||||
- FamilySearch links are generated only from normalized, validated IDs.
|
||||
- Auto-refresh performs no polling after a terminal job state.
|
||||
- Adjacent Source navigation never crosses Document boundaries.
|
||||
- The existing V4 workflows remain operational.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Page reordering.
|
||||
- Settings management.
|
||||
- External genealogy API integration.
|
||||
- Raw `.env` editing.
|
||||
- Theme editing.
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [V4.1 Scope Boundary](scope_boundary_v4_1.md)
|
||||
- [V4 Implementation Plan](../ver4/implementation_plan_v4.md)
|
||||
- [V4 Requirements](../ver4/requirements_v4.md)
|
||||
- [V4 Error Handling Policy](../ver4/error_handling_v4.md)
|
||||
@@ -0,0 +1,142 @@
|
||||
# V4.1 Scope Boundary
|
||||
|
||||
This document defines the scope of the first incremental revision to Version 4. V4 remains the product and architecture baseline; V4.1 adds focused usability improvements and one additive Person field.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Improve common archival record workflows without redesigning the application.
|
||||
- Resolve table overflow, ambiguous person selection, and unnecessary navigation.
|
||||
- Add a manually maintained FamilySearch person reference without introducing external API integration.
|
||||
|
||||
## In Scope
|
||||
|
||||
### 1. Archival Documents List
|
||||
|
||||
- Keep every table column within the available page width.
|
||||
- Limit and wrap long Document Title values.
|
||||
- Left-align Document Title and Type.
|
||||
- Add Author and Document Date columns.
|
||||
- Display all people linked through the `author` role in the Author column.
|
||||
- Display exact document date when present, otherwise approximate date when present, otherwise `Unknown`.
|
||||
|
||||
### 2. Document Detail and Editing
|
||||
|
||||
- Use an unambiguous label in person selectors. The label should prefer Display Name, retain Full Name for context, and include the birth year when known.
|
||||
- Do not require Display Name to be unique.
|
||||
- Link each Related People entry to its Person Detail page.
|
||||
- In view mode, display only the populated exact or approximate document date row. Display a single unknown/not-set state when neither exists.
|
||||
- Keep both exact and approximate inputs available in create/edit mode.
|
||||
- Move source navigation out from beneath the media viewer.
|
||||
- Present Pipeline Jobs and Sources together in one related-processing card with counts and actions.
|
||||
|
||||
### 3. People List
|
||||
|
||||
- Left-align Display Name and Maiden Name.
|
||||
- Display exact birth date when present, otherwise approximate birth date when present, otherwise `Unknown`.
|
||||
- Add a Death Date column with the same fallback rule.
|
||||
|
||||
### 4. Person Detail and Editing
|
||||
|
||||
- Add a New Document action that opens Document creation with the current person preselected.
|
||||
- Preserve the existing Document-first workflow.
|
||||
- In view mode, display only the populated exact or approximate row for each of birth and death date. Display a single unknown/not-set state when neither value exists.
|
||||
- Keep both exact and approximate inputs available in create/edit mode.
|
||||
|
||||
### 5. FamilySearch Reference
|
||||
|
||||
- Add a nullable, unique `family_search_id` field to `Person`.
|
||||
- Allow the field to be entered and changed in Person create/edit flows.
|
||||
- Trim whitespace, normalize the identifier to uppercase, and validate it against the supported
|
||||
`XXXX-XXX` alphanumeric shape before persistence.
|
||||
- When an identifier exists, show a FamilySearch action on Person Detail linking to:
|
||||
`https://www.familysearch.org/tree/person/details/{family_search_id}`
|
||||
- Construct the URL in application code; do not persist the full URL.
|
||||
|
||||
### 6. Source List and Detail
|
||||
|
||||
- Keep every Source Asset Records table column within the available page width.
|
||||
- Limit and wrap long Document Name, Upload Title, and Error Detail values.
|
||||
- Left-align Document Name, Upload Title, and Error Detail.
|
||||
- Remove Stored Filename only from the Source Asset Records table. Continue storing it and showing it on Source Detail.
|
||||
- On Source Detail, add previous and next navigation for Sources belonging to the same Document, ordered by `page_number`.
|
||||
- Disable or omit the previous/next action at the first/last page.
|
||||
|
||||
### 7. Job Detail
|
||||
|
||||
- Automatically refresh Job Detail while the job is in a non-terminal state.
|
||||
- Use a modest interval in the 3-5 second range.
|
||||
- Stop polling when the job reaches a terminal state or the page is no longer active.
|
||||
- Preserve manual navigation and existing job actions.
|
||||
|
||||
### 8. Homepage Storage Decision
|
||||
|
||||
- Continue treating homepage markdown and images as mutable application data, not prompt artifacts or packaged source assets.
|
||||
- Keep homepage content separate from `prompts`.
|
||||
- Defer relocation to a configurable application-data root unless the existing location prevents normal installed or deployed operation.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Source page renumbering or reordering.
|
||||
- A Settings page.
|
||||
- Editing `.env` or secrets through the UI.
|
||||
- Runtime theme editing.
|
||||
- FamilySearch authentication, API calls, search, import, synchronization, or conflict resolution.
|
||||
- Ancestry references or other genealogy providers.
|
||||
- Google Maps links from place fields.
|
||||
- Enforcing unique Display Name values.
|
||||
- Changes to transcription execution or provider behavior.
|
||||
- Changes to the V4 API solely to expose the V4.1 presentation enhancements.
|
||||
|
||||
## Locked Design Decisions
|
||||
|
||||
### A. Person Selector Identity
|
||||
|
||||
- Selection values remain internal Person UUIDs.
|
||||
- Display labels provide disambiguating context but are not identity keys.
|
||||
- Duplicate Full Name and Display Name values remain valid.
|
||||
|
||||
### B. Date Presentation
|
||||
|
||||
- Exact dates take precedence over approximate/raw dates for compact list and view presentation.
|
||||
- Create/edit forms retain both fields so either representation can be maintained.
|
||||
- V4.1 does not introduce a new mutual-exclusion database constraint.
|
||||
|
||||
### C. FamilySearch Storage
|
||||
|
||||
- Store only the FamilySearch person identifier.
|
||||
- Treat a FamilySearch person identifier as unique across local Person records.
|
||||
- Use one dedicated nullable Person field while FamilySearch is the only supported external genealogy reference.
|
||||
- Reconsider a generic external-reference model only when a second provider or multiple references per person are required.
|
||||
|
||||
### D. Stored Filename
|
||||
|
||||
- Stored Filename remains part of the Source model and Source Detail diagnostics.
|
||||
- Only the list-table column is removed.
|
||||
|
||||
## Data and Compatibility Policy
|
||||
|
||||
- The `family_search_id` addition must be nullable and non-destructive for existing Person rows.
|
||||
- Existing records, routes, relationships, jobs, Sources, prompt provenance, and uploaded media remain valid.
|
||||
- UI changes must preserve both Document-first and Person-first workflows.
|
||||
- V4.1 must remain portable across SQLite and PostgreSQL.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Document, Person, and Source tables fit their page containers at supported desktop widths without losing requested columns.
|
||||
2. Long table text wraps or is constrained without forcing important columns outside the table container.
|
||||
3. Duplicate-named people can be distinguished in every document relationship selector.
|
||||
4. Related People entries navigate to the correct Person Detail page.
|
||||
5. Compact date displays consistently use exact, then approximate, then unknown fallback behavior.
|
||||
6. Starting from Person Detail can create a Document with that person preselected without breaking normal Document creation.
|
||||
7. A valid FamilySearch ID is persisted and produces the correct Person Detail hyperlink; absent IDs produce no action.
|
||||
8. Source previous/next actions remain within the same Document and follow `page_number`.
|
||||
9. Active Job Detail pages update without manual refresh and stop polling after terminal status.
|
||||
10. Stored Filename is absent from the Source list table but remains available on Source Detail.
|
||||
11. Focused automated tests pass and unaffected V4 behavior remains intact.
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [V4.1 Implementation Plan](implementation_plan_v4_1.md)
|
||||
- [V4 Scope Boundary](../ver4/scope_boundary_v4.md)
|
||||
- [V4 Architecture](../ver4/architecture_v4.md)
|
||||
- [V4 Schema](../ver4/schema_v4.md)
|
||||
@@ -0,0 +1,258 @@
|
||||
# Implementation Plan (Version 4.2)
|
||||
|
||||
## Goal
|
||||
|
||||
Make processing evidence precise, append-only, secret-safe, and exportable while preserving every existing record and creating a provider-neutral home for future OCR/layout artifacts.
|
||||
|
||||
## Implementation Principles
|
||||
|
||||
- Implement the [Digital Evidence and AI Processing Provenance invariant](../invariant/ai_evidence_and_provenance.md), not a provider-specific approximation of it.
|
||||
- Capture transport evidence before SDK parsing.
|
||||
- Keep exact evidence separate from parsed and normalized representations.
|
||||
- Prefer additive schema evolution and explicit compatibility behavior.
|
||||
- Reference source content by digest rather than duplicating it in request JSON.
|
||||
- Use allowlists for safe metadata capture.
|
||||
- Keep persistence and evidence semantics behind service boundaries.
|
||||
- Do not change the default model until a representative benchmark supports that decision.
|
||||
|
||||
## Current-State Gaps
|
||||
|
||||
| Current behavior | Gap to close |
|
||||
| --- | --- |
|
||||
| `Source` stores original file path, digest, and size. | Media type and image/page geometry used for an execution are not frozen with that execution. |
|
||||
| `Job` stores prompt text/hash, requested model after resolution, temperature, and `top_p`. | The complete effective request structure, omitted-versus-explicit parameter state, routing constraints, and software versions are not frozen. |
|
||||
| `JobSource.raw_api_response` stores `model_dump()` output from the OpenRouter SDK. | The exact HTTP body can be normalized by OpenRouter and filtered again by the SDK before persistence. |
|
||||
| `JobSource.ai_metadata` stores finish reason and basic token counts. | Detailed accounting remains only in the SDK snapshot and is not a substitute for exact evidence. |
|
||||
| Provider exceptions become application errors. | Safe HTTP error bodies, statuses, headers, and no-response distinctions are not persisted. |
|
||||
| Worker logs elapsed time. | Execution duration is not stored on `JobSource`. |
|
||||
| Source Detail displays AI metadata and the SDK snapshot. | The UI does not identify evidence layers or expose request/transport/software provenance. |
|
||||
| No generic processing-artifact model exists. | Future OCR geometry would require ad hoc provider fields or an unrelated schema. |
|
||||
|
||||
## Expected Project Impact
|
||||
|
||||
| Area | Expected impact |
|
||||
| --- | --- |
|
||||
| Database models and upgrades | Add execution-specification, transport-evidence, timing, software-context, and generic artifact storage without removing existing columns. |
|
||||
| OpenRouter adapter | Introduce a transport boundary that can capture exact body/status/safe headers before typed SDK parsing, or use supported SDK hooks that expose the unparsed response reliably. |
|
||||
| Provider contract | Return structured evidence for success and failure without leaking provider-specific transport concerns into workflow orchestration. |
|
||||
| Source and workflow services | Persist one append-only execution outcome and its artifacts transactionally; retain compatibility projections. |
|
||||
| UI | Label and inspect evidence layers; export safe evidence packages through service operations. |
|
||||
| Benchmarking | Add a private manifest and repeatable evaluator using the literal-transcription methodology. |
|
||||
| Tests and documentation | Add compatibility, capture, security, integrity, export, and benchmark-scoring coverage; correct overstated V4 evidence language. |
|
||||
|
||||
## Proposed Data Design
|
||||
|
||||
Exact names should be confirmed against existing conventions before migration code is written. The design should provide the following logical records.
|
||||
|
||||
### 1. Execution Evidence
|
||||
|
||||
Extend `JobSource` or associate it one-to-one with a new execution-evidence record containing:
|
||||
|
||||
- Request manifest JSON and manifest schema version.
|
||||
- Transport status, body bytes or exact decoded body plus encoding/content type, and safe headers.
|
||||
- Parsed SDK snapshot retained separately from transport content.
|
||||
- Application, adapter, SDK, and runtime version metadata.
|
||||
- Start, finish, and duration values.
|
||||
- Router/provider request and generation identifiers when available.
|
||||
- Failure phase and whether an HTTP response was received.
|
||||
|
||||
The implementation should evaluate a companion table rather than continuing to widen `JobSource`. A companion record better isolates large/optional evidence and permits clear one-to-one compatibility semantics.
|
||||
|
||||
### 2. Generic Processing Artifact
|
||||
|
||||
Add a one-to-many artifact model associated with a source and, when applicable, a producing execution:
|
||||
|
||||
- Stable artifact UUID.
|
||||
- `source_id` and optional execution/`job_source_id`.
|
||||
- Semantic artifact type.
|
||||
- Media/serialization format.
|
||||
- Schema name and version.
|
||||
- Producer and producer version.
|
||||
- Inline JSON payload or external location.
|
||||
- Payload digest and byte size.
|
||||
- Coordinate-system metadata when relevant.
|
||||
- Creation timestamp.
|
||||
|
||||
Enforce exactly one content location: inline payload or external reference. An external artifact must be written durably and hashed before its database record commits.
|
||||
|
||||
### 3. Compatibility Projections
|
||||
|
||||
- Keep `JobSource.raw_api_response` unchanged for existing and new compatibility reads until a later deprecation decision.
|
||||
- Keep `JobSource.ai_metadata` for indexed/display-ready normalized values.
|
||||
- Keep `Source.raw_transcription` as the latest successful machine-output projection while treating per-execution `JobSource.raw_transcription` as history.
|
||||
- Document that older rows have an SDK snapshot but no exact transport capture.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### 1. Correct Terminology and Define Typed Contracts
|
||||
|
||||
- Add typed domain models for request manifests, software context, transport metadata, failure phase, and artifact descriptors.
|
||||
- Version every persisted JSON contract from its first release.
|
||||
- Define the safe response-header allowlist. Begin with correlation, content type/encoding, date, retry/rate-limit, and router-specific generation identifiers only when documented and non-secret.
|
||||
- Define size limits and external-storage thresholds for exact bodies and artifacts.
|
||||
- Correct `docs/ver4/schema_v4.md` under “Page-Level Execution and AI Outputs” so the existing column is described as an SDK-serialized OpenRouter response snapshot, not a complete provider envelope, exact HTTP body, or native upstream-provider response. Apply the same terminology to architecture and UI schema references.
|
||||
- Add serialization and secret-rejection unit tests before provider changes.
|
||||
|
||||
### 2. Add Additive Persistence and Upgrade Behavior
|
||||
|
||||
- Add the selected execution-evidence and artifact models.
|
||||
- Add foreign keys, uniqueness constraints, and indexes for source/execution lookup.
|
||||
- Implement idempotent upgrades following the repository's existing schema-upgrade policy.
|
||||
- Do not populate exact response fields for historical rows.
|
||||
- Do not write a capture-time classification onto historical rows during migration. Compatibility reads may describe a populated legacy `raw_api_response` as an SDK snapshot, but exports must identify that description as a later compatibility interpretation rather than execution-time metadata.
|
||||
- Verify JSON portability and large-payload behavior for SQLite and PostgreSQL.
|
||||
- Add upgrade tests starting from a representative pre-V4.2 schema.
|
||||
|
||||
### 3. Build Secret-Safe Request Manifests
|
||||
|
||||
- Build the manifest from the concrete outgoing request body immediately before transport, not from a narrower typed projection that may discard unrecognized request fields.
|
||||
- Replace each image payload in that concrete representation with a source reference containing source UUID, digest, byte size, media type, dimensions, and transformation identity.
|
||||
- Store exact prompt content and preserve omitted-versus-explicit parameter state.
|
||||
- Include requested model, routing preferences, response-format requirements, and timeout/retry policy.
|
||||
- Record application version/commit when available, adapter contract version, SDK package/version, and request-manifest schema version.
|
||||
- Hash the canonical manifest representation for integrity checks.
|
||||
- Test that credentials and embedded image data cannot enter the persisted manifest.
|
||||
- Test that every field actually sent to the provider, including routing and future provider options, is represented or explicitly excluded by the manifest transform.
|
||||
|
||||
### 4. Capture OpenRouter Transport Evidence
|
||||
|
||||
- Evaluate the installed OpenRouter SDK hooks/client injection first.
|
||||
- If hooks cannot expose an exact stable response before typed parsing, implement the non-streaming OpenRouter call through the existing async HTTP client boundary while retaining typed validation in the adapter.
|
||||
- Read the response body once, preserve it exactly, then parse and normalize it.
|
||||
- Store status, content type/encoding, allowlisted headers, request/generation ID, and timing.
|
||||
- Maintain current authentication, referer/title headers, timeout behavior, and error classification.
|
||||
- Explicitly document that the captured body is the OpenRouter-normalized transport response, not Gemini/Anthropic/OpenAI native upstream JSON.
|
||||
- Add fixture-based tests proving unknown response fields survive transport capture even if a typed parser ignores them.
|
||||
|
||||
### 5. Preserve Failure Evidence
|
||||
|
||||
- Return or raise a typed provider failure that carries safe evidence separately from its user-facing error.
|
||||
- Persist non-success status/body/allowlisted headers before marking an execution failed.
|
||||
- Represent DNS/connect/TLS/local timeout failures as no-response outcomes with a failure phase and safe diagnostic category.
|
||||
- Preserve response-validation failures with both the exact body and validation details.
|
||||
- Keep transcription-quality rejection distinct from provider failure because a valid provider response was received.
|
||||
- Ensure error strings and logs do not contain authorization data or embedded image payloads.
|
||||
- Add tests for 4xx, 5xx, malformed JSON, schema mismatch, timeout, connection failure, and quality rejection.
|
||||
|
||||
### 6. Make Execution History Reliably Append-Only
|
||||
|
||||
- Confirm retry behavior creates a distinct execution attempt rather than reusing and overwriting a completed evidence record.
|
||||
- Separate queue linkage from execution-attempt identity; the current update-in-place behavior cannot serve as append-only execution history.
|
||||
- Assign each attempt a deterministic, monotonically increasing attempt number scoped to its Job and Source, enforced by a database uniqueness constraint.
|
||||
- Update the latest-transcription projection only after a successful attempt.
|
||||
- Never update prior response bodies, manifests, timings, or artifacts during a retry.
|
||||
- Select the latest attempt and latest successful attempt by the persisted attempt number with a stable identifier as a defensive secondary key, never by timestamp alone.
|
||||
- Add service/workflow tests covering retries, partial success, interrupted jobs, and historical projection behavior.
|
||||
|
||||
### 7. Add Generic Artifact Persistence
|
||||
|
||||
- Implement service operations to create, read, list, verify, export, and, only under explicit retention policy, delete artifacts.
|
||||
- Validate semantic type, schema/version, digest, media type, and coordinate metadata.
|
||||
- Support JSON artifacts inline initially when within the agreed size threshold.
|
||||
- Support external artifacts through a constrained application-data root with atomic write, digest verification, and explicit missing-file errors.
|
||||
- Add a provider-neutral example fixture representing OCR words/lines with polygons and confidence values.
|
||||
- Do not integrate a live OCR vendor in this phase.
|
||||
|
||||
### 8. Add Evidence Inspection and Export
|
||||
|
||||
- Rename the current Source Detail label to identify historical values as an OpenRouter SDK Response Snapshot.
|
||||
- Add separate sections for Request Manifest, Transport Response, Normalized Metadata, Software Context, and Derived Artifacts.
|
||||
- Show an explicit “not captured for this historical execution” state instead of an empty object.
|
||||
- Keep large bodies collapsed by default and avoid rendering embedded source data.
|
||||
- Add a service-owned export that packages a versioned manifest, evidence JSON/body files, artifact content or references, and digest inventory.
|
||||
- Exclude secrets and machine-local paths that are not required to interpret the evidence.
|
||||
- Add UI and export tests for new, historical, failed, and large-evidence records.
|
||||
|
||||
### 9. Establish the Private Benchmark
|
||||
|
||||
- Select a small initial corpus, then expand only when it exposes meaningful differences.
|
||||
- Stratify examples by printed/typed text, handwriting style, degradation, layout complexity, language, and editorial anomaly.
|
||||
- Reference existing Source UUIDs and digests in a private manifest; do not copy family documents into public test fixtures.
|
||||
- Create manually reviewed reference transcriptions following the invariant methodology.
|
||||
- Implement or adopt existing project-compatible CER/WER calculations without changing dependencies unless justified.
|
||||
- Score omissions, inventions, silent modernization, uncertainty markup, and layout fidelity separately from CER/WER.
|
||||
- Record cost and latency from preserved execution evidence.
|
||||
- Run the current `google/gemini-2.5-flash` configuration as the baseline before testing alternatives.
|
||||
- Treat results as model-version/route/corpus specific and preserve each comparison run.
|
||||
|
||||
### 10. Verify, Migrate, and Align Documentation
|
||||
|
||||
- Run the smallest focused model, provider, service, workflow, UI, upgrade, and export test groups first.
|
||||
- Run broader regression tests only after focused validation passes.
|
||||
- Execute all destructive tests through `tools/run_destructive_tests.py`.
|
||||
- Verify backup creation and required restoration behavior before any test touching real application data.
|
||||
- Confirm existing Source Detail records remain readable after upgrade.
|
||||
- Update V4 architecture, schema, requirements, and UI schema mappings to point to V4.2 semantics.
|
||||
- Record any deliberate deviation from this plan in the V4.2 scope before release.
|
||||
|
||||
## Recommended Delivery Order
|
||||
|
||||
1. Typed/versioned evidence contracts and terminology.
|
||||
2. Additive execution-evidence persistence.
|
||||
3. Secret-safe request manifests.
|
||||
4. Exact OpenRouter transport capture.
|
||||
5. Failure evidence and append-only retry semantics.
|
||||
6. Generic artifact persistence.
|
||||
7. Inspection and export.
|
||||
8. Private benchmark tooling and baseline run.
|
||||
9. Migration, regression verification, and documentation alignment.
|
||||
|
||||
## Key Implementation Decisions to Resolve
|
||||
|
||||
1. Whether execution evidence is a one-to-one companion to `JobSource` or part of a new execution-attempt model required for append-only retries.
|
||||
2. Whether exact response bodies remain database values at expected sizes or move to hashed external files above a threshold.
|
||||
3. The canonical JSON algorithm used to hash request manifests.
|
||||
4. The safe-header allowlist supported by OpenRouter and future adapters.
|
||||
5. The application version identity available in local, packaged, and uncommitted development builds.
|
||||
6. The initial inline/external artifact size threshold and application-data root.
|
||||
7. Whether evidence exports include original source binaries by default, optionally, or only by reference.
|
||||
8. The minimum private benchmark corpus size and review process before model comparisons influence defaults.
|
||||
|
||||
These decisions must be settled before their corresponding implementation phase; they do not weaken the invariant or expand V4.2 into live OCR integration.
|
||||
|
||||
## Resolved Implementation Decisions
|
||||
|
||||
1. `JobSource` remains queue linkage and a compatibility projection; immutable retries use a one-to-many
|
||||
`ExecutionAttempt` model with a unique `(job_id, source_id, attempt_number)` constraint.
|
||||
2. Exact OpenRouter response bytes remain database values for V4.2. Generic artifacts use inline canonical JSON up
|
||||
to 1 MiB by default and constrained, atomically written external files above that threshold.
|
||||
3. Request manifests use `transcription-canonical-json-v1`: UTF-8 JSON with sorted keys, compact separators,
|
||||
preserved Unicode, and non-finite numbers rejected.
|
||||
4. Safe response headers are explicitly allowlisted in the evidence contract; all others are discarded before
|
||||
persistence.
|
||||
5. Software identity records the package version, optional `TRANSCRIPTION_COMMIT`, adapter contract version,
|
||||
OpenRouter SDK version, and Python version.
|
||||
6. The artifact root defaults to `data/artifacts` and stores source-scoped relative references.
|
||||
7. Evidence exports include source identity and digest by reference, not original source binaries.
|
||||
8. The benchmark manifest is private and digest-referenced. Corpus size remains archive-dependent, but every run
|
||||
uses preserved execution-attempt identity and the fixed literal scoring contract.
|
||||
|
||||
## Done When
|
||||
|
||||
- Every V4.2 acceptance criterion is satisfied by focused tests or an explicit demonstration.
|
||||
- Existing SDK snapshots retain their content and are labeled accurately.
|
||||
- New successful and failed calls preserve secret-safe provider-boundary evidence.
|
||||
- Unknown transport fields survive even when the typed SDK/parser does not recognize them.
|
||||
- Retries cannot overwrite prior execution evidence.
|
||||
- A generic versioned artifact can represent OCR geometry and pass integrity verification.
|
||||
- Evidence can be safely inspected and exported with schema identities and digests.
|
||||
- The current model has a reproducible private benchmark baseline.
|
||||
- No credential or embedded source payload appears in persisted manifests, safe headers, logs, or exports.
|
||||
- Existing V4.1 behavior remains compatible.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Live OCR/document-AI provider integration.
|
||||
- Automatic model switching.
|
||||
- Archive-wide reprocessing.
|
||||
- Native upstream-provider response capture through OpenRouter when OpenRouter does not expose it.
|
||||
- Guarantees of deterministic hosted-model output.
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [V4.2 Scope Boundary](scope_boundary_v4_2.md)
|
||||
- [Digital Evidence and AI Processing Provenance](../invariant/ai_evidence_and_provenance.md)
|
||||
- [V4 Architecture](../ver4/architecture_v4.md)
|
||||
- [V4 Schema](../ver4/schema_v4.md)
|
||||
- [V4 Requirements](../ver4/requirements_v4.md)
|
||||
- [Draft V4.3 Implementation Plan](../ver4.3/implementation_plan_v4_3.md)
|
||||
@@ -0,0 +1,163 @@
|
||||
# V4.2 Scope Boundary
|
||||
|
||||
This document defines the boundary for the digital-evidence and AI-provenance revision that follows V4.1 and precedes the planned V4.3 settings work. V4 remains the architecture baseline; V4.2 makes the existing evidence claims precise and adds a provider-neutral foundation for future processing artifacts.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Align the application with the [Digital Evidence and AI Processing Provenance invariant](../invariant/ai_evidence_and_provenance.md).
|
||||
- Preserve provider-boundary evidence before SDK parsing can remove unknown fields.
|
||||
- Make successful and failed processing attempts inspectable without storing secrets.
|
||||
- Support future OCR and layout outputs without coupling the database to one vendor.
|
||||
- Establish a repeatable method for comparing transcription models against this archive.
|
||||
|
||||
## In Scope
|
||||
|
||||
### 1. Evidence Terminology and Existing-Data Compatibility
|
||||
|
||||
- Define transport response, router-normalized response, SDK response, normalized metadata, and derived artifact consistently in code, schema documentation, and UI labels.
|
||||
- Treat existing `JobSource.raw_api_response` values as historical SDK response snapshots.
|
||||
- Preserve every existing `Job`, `Source`, and `JobSource` row.
|
||||
- Use additive migrations and compatibility reads; do not reinterpret previously stored values as exact transport captures.
|
||||
- Correct the “Page-Level Execution and AI Outputs” rule in `docs/ver4/schema_v4.md` that currently describes `JOB_SOURCE` as storing a complete provider response envelope. The corrected rule must identify `raw_api_response` as an SDK-serialized OpenRouter response snapshot and state that it is neither the exact HTTP body nor the native upstream-provider response.
|
||||
|
||||
### 2. Secret-Safe Request Manifests
|
||||
|
||||
- Persist the effective request specification for each page execution without storing credentials or duplicate base64 media.
|
||||
- Include requested provider/model, routing constraints, prompt content and hash, explicitly supplied parameters, source digest, media type, dimensions when known, and page identity.
|
||||
- Distinguish an omitted optional parameter from an explicitly supplied null or value.
|
||||
- Record application, provider-adapter, Python client, and relevant schema versions.
|
||||
- Use source or derivative references in place of embedded media bytes.
|
||||
|
||||
### 3. Provider-Boundary Response Capture
|
||||
|
||||
- Capture the exact HTTP response body before OpenRouter SDK parsing for non-streaming transcription calls.
|
||||
- Store HTTP status and an explicit allowlist of safe response headers.
|
||||
- Store router request/generation identifiers and resolved model/provider-routing metadata when exposed.
|
||||
- Preserve the current parsed SDK snapshot and normalized metadata where useful.
|
||||
- Keep exact body, parsed representation, and normalized fields distinguishable.
|
||||
|
||||
### 4. Failure Evidence and Timing
|
||||
|
||||
- Create or update a page execution record for every attempted provider call.
|
||||
- Persist safe response evidence for non-success HTTP responses.
|
||||
- Distinguish HTTP response failures, connection failures, local timeouts, response-validation failures, and transcription-quality failures.
|
||||
- Store execution start/end times or duration using a clearly defined clock policy.
|
||||
- Do not collapse a provider error body into only a generic user-facing message.
|
||||
|
||||
### 5. Generic Processing Artifacts
|
||||
|
||||
- Add a provider-neutral representation for versioned derived artifacts.
|
||||
- Support inline JSON and externally stored payloads with a digest and stable reference.
|
||||
- Record artifact type, format, schema/version, producer/version, source, producing execution, and creation time.
|
||||
- Define coordinate-system metadata sufficient for word, line, block, or page geometry.
|
||||
- Permit future OCR/layout/confidence results without implementing a vendor-specific table for each provider.
|
||||
|
||||
### 6. Evidence Inspection and Export
|
||||
|
||||
- Expand Source Detail and/or Job Detail to identify the evidence layer being displayed.
|
||||
- Provide readable JSON inspection for request manifests, transport metadata, parsed responses, normalized metadata, and derived artifacts.
|
||||
- Provide a safe export containing evidence content or references, relationships, schema versions, and digests.
|
||||
- Clearly label evidence that was not captured for historical records.
|
||||
- Do not display or export credentials, unrestricted headers, or embedded base64 source media.
|
||||
|
||||
### 7. Representative-Corpus Benchmark Protocol
|
||||
|
||||
- Define a private benchmark manifest referencing source digests rather than duplicating archival media.
|
||||
- Include representative printed, typed, handwritten, degraded, tabular, and spatially complex pages.
|
||||
- Pair each benchmark item with a manually reviewed literal transcription.
|
||||
- Score character error rate, word error rate, omissions, inventions, silent normalization, uncertainty handling, layout fidelity, cost, and latency.
|
||||
- Preserve the complete execution provenance for every benchmark run.
|
||||
- Keep the current model as a baseline; do not change the application default solely from vendor benchmarks.
|
||||
|
||||
### 8. Migration, Integrity, and Verification
|
||||
|
||||
- Provide non-destructive upgrade behavior for supported SQLite and PostgreSQL deployments.
|
||||
- Backfill only facts that can be derived reliably from existing records.
|
||||
- Mark unavailable historical evidence as unavailable rather than fabricating it.
|
||||
- Add digest, serialization, header-allowlist, failure-path, compatibility, artifact, export, and UI inspection tests.
|
||||
- Run destructive tests only through the repository's required backup-and-restore wrapper.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Selecting or declaring a permanent best transcription model.
|
||||
- Changing the default transcription model without benchmark evidence and a separate decision.
|
||||
- Integrating Azure Document Intelligence, Google Document AI, Transkribus, Mistral OCR, or another OCR provider in V4.2.
|
||||
- Generating bounding boxes retroactively for existing transcriptions.
|
||||
- Bulk reprocessing the archive.
|
||||
- Packet capture, TLS evidence, full unrestricted request/response headers, or credential retention.
|
||||
- Storing duplicate base64 source images in request manifests.
|
||||
- Guaranteeing byte-identical reproduction from nondeterministic or updated hosted models.
|
||||
- Automatic entity extraction, biography generation, or genealogical inference.
|
||||
- Replacing the relational database with an event store or content-addressed object store.
|
||||
- Destructive renaming or removal of `raw_api_response`.
|
||||
|
||||
## Locked Design Decisions
|
||||
|
||||
### A. The Original Source Is Primary Evidence
|
||||
|
||||
- Original uploaded bytes and their digest remain authoritative.
|
||||
- Processing derivatives and outputs are independently identified derived evidence.
|
||||
- Future OCR/layout work reuses the original or a documented derivative.
|
||||
|
||||
### B. Evidence Is Layered
|
||||
|
||||
- Exact transport evidence, SDK-parsed objects, normalized metadata, and transcription text serve different purposes.
|
||||
- One representation must not silently stand in for another.
|
||||
- UI and export labels name the stored evidence layer.
|
||||
|
||||
### C. History Is Append-Only
|
||||
|
||||
- A retry or reprocessing attempt creates new execution evidence.
|
||||
- Convenience caches may change, but historical execution output does not.
|
||||
- Human revisions remain separate from machine output.
|
||||
|
||||
### D. Capture Is Secret-Safe by Construction
|
||||
|
||||
- Safe headers are allowlisted.
|
||||
- Authorization, cookies, API keys, and unrestricted headers are never persisted.
|
||||
- Request manifests reference source digests instead of embedding source bytes.
|
||||
|
||||
### E. Derived Artifacts Are Generic and Versioned
|
||||
|
||||
- Artifact storage is not limited to bounding boxes.
|
||||
- Coordinate metadata declares units, origin, dimensions, and transformations.
|
||||
- Provider-specific payloads may be retained without making provider-specific fields the durable application contract.
|
||||
|
||||
### F. Existing Evidence Keeps Its Original Meaning
|
||||
|
||||
- Existing `raw_api_response` data remains an SDK response snapshot.
|
||||
- A migration may label or classify it but may not claim that missing transport data was captured.
|
||||
- Historical nulls and absent fields remain distinguishable from new explicitly captured values.
|
||||
|
||||
## Data and Compatibility Policy
|
||||
|
||||
- All schema changes are additive in V4.2.
|
||||
- Existing source files, hashes, transcriptions, revisions, prompts, jobs, and relationships remain valid.
|
||||
- Compatibility reads continue to display historical SDK snapshots.
|
||||
- Large derived artifacts may be stored outside the database when the database retains a stable reference, digest, media type, and schema identity.
|
||||
- JSON evidence must remain portable across SQLite and PostgreSQL.
|
||||
- Exports use explicit schema versions so later releases can interpret older packages.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. A new execution can be traced from its source digest through its frozen request manifest, transport response, parsed/normalized data, and derived outputs.
|
||||
2. Exact response content is captured before SDK parsing and is clearly distinguished from the existing SDK snapshot.
|
||||
3. Failed HTTP calls retain safe provider evidence; calls with no response record that fact explicitly.
|
||||
4. Omitted parameters remain distinguishable from explicit values.
|
||||
5. No persisted request, header set, UI display, log, or export contains API credentials.
|
||||
6. Retrying or reprocessing does not overwrite prior execution evidence.
|
||||
7. Historical records remain readable and are not mislabeled as exact transport captures.
|
||||
8. A versioned generic artifact can represent OCR/layout JSON and its coordinate system without a provider-specific schema change.
|
||||
9. Evidence exports include relationships, schema identities, and digests sufficient for independent integrity checks.
|
||||
10. The benchmark protocol can compare the current baseline with another model on the same private corpus and scoring rules.
|
||||
11. Additive migrations and focused tests work across the supported persistence model.
|
||||
12. All destructive-test runs comply with the backup-and-restore protocol.
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [V4.2 Implementation Plan](implementation_plan_v4_2.md)
|
||||
- [Digital Evidence and AI Processing Provenance](../invariant/ai_evidence_and_provenance.md)
|
||||
- [V4 Architecture](../ver4/architecture_v4.md)
|
||||
- [V4 Schema](../ver4/schema_v4.md)
|
||||
- [V4 Requirements](../ver4/requirements_v4.md)
|
||||
- [Transcription Methodology](../invariant/transcription_methodology.md)
|
||||
@@ -0,0 +1,121 @@
|
||||
# Implementation Plan (Version 4.3)
|
||||
|
||||
## Goal
|
||||
|
||||
Deliver constrained, installation-local application settings while preserving the completed V4.2 behavioral baseline and historical provenance.
|
||||
|
||||
## Planning Constraints
|
||||
|
||||
- V4, V4.1, and V4.2 remain the behavioral baseline.
|
||||
- Settings must use explicit domain operations rather than direct database, environment-file, or arbitrary filesystem access from UI pages.
|
||||
- Prompt changes must preserve historical Job provenance and use a defined safe-write policy.
|
||||
- Source Page Reordering is excluded.
|
||||
- Database, integration, and UI tests must use confirmed isolated test data and must never modify `data/transcription.db`.
|
||||
- Potentially destructive tests must run only through `tools/run_destructive_tests.py`.
|
||||
|
||||
## Expected Project Impact
|
||||
|
||||
| Area | Expected impact |
|
||||
| --- | --- |
|
||||
| Documents service | Expand controlled Document Type maintenance operations. |
|
||||
| People service | Expand controlled Person Role maintenance operations. |
|
||||
| Prompt adapter/service | Add constrained listing, reading, validation, atomic writing, backup, and explicit recovery of existing prompt artifacts. |
|
||||
| UI composition/navigation | Register Settings routes and navigation without moving persistence into UI code. |
|
||||
| Tests | Add isolated registry lifecycle, prompt safety, and UI workflow coverage. |
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### 1. Define Service Contracts
|
||||
|
||||
- Define Document Type maintenance commands for create, relabel, activate, deactivate, and delete-if-unreferenced.
|
||||
- Define Person Role maintenance commands for create, relabel, activate, deactivate, and delete-if-unreferenced.
|
||||
- Define a Prompt Store interface for constrained list, read, write, backup-status, and explicit recovery behavior.
|
||||
- Map validation, conflict, not-found, dependency, and filesystem failures to existing `AppError` categories.
|
||||
|
||||
### 2. Expand Registry Maintenance Services
|
||||
|
||||
- Reuse existing Document and People service ownership.
|
||||
- Add explicit write methods rather than passing UI-mutated ORM objects directly where practical.
|
||||
- Normalize Document Type labels and reject case-insensitive duplicates deterministically.
|
||||
- Keep Person Role stable-code validation and duplicate rejection.
|
||||
- Permit deletion only after a service-owned reference check proves the entry is unreferenced.
|
||||
- Reject deletion of referenced entries deterministically without partial mutation.
|
||||
- Permit label changes whether or not an entry is referenced.
|
||||
- Preserve inactive entries for historical reads.
|
||||
- Order Document Types alphabetically by normalized label.
|
||||
- Order Person Roles deterministically by label and then code without adding a schema field.
|
||||
- Add service tests for create, relabel, activation, deactivation, duplicates, immutable codes, ordering, allowed deletion, and blocked referenced deletion.
|
||||
|
||||
### 3. Add Constrained Prompt Storage
|
||||
|
||||
- Place filesystem access behind a dedicated Prompt Store/service boundary.
|
||||
- Resolve all filenames directly beneath the configured prompt root and reject traversal.
|
||||
- Permit only existing files with the agreed Markdown extension and reject empty content.
|
||||
- Exclude prompt creation and deletion.
|
||||
- Write new content to a sibling temporary file, flush and sync it, preserve the active file as the sole previous-version backup, and atomically replace the active file.
|
||||
- Expose explicit backup recovery through the same filename validation and safe-write path; never perform automatic rollback.
|
||||
- Clean up temporary files after failed writes while preserving the active prompt and any valid backup.
|
||||
- Preserve file encoding and provide explicit failures for read-only or unavailable storage.
|
||||
- Do not modify any Job row when prompt defaults change.
|
||||
- Add unit tests for valid reads/writes, traversal, invalid names, nonexistent-file creation attempts, empty content, atomic replacement failures, single-backup rotation, explicit recovery, filesystem failures, and unchanged Job provenance.
|
||||
|
||||
### 4. Build the Settings UI
|
||||
|
||||
- Register a Settings landing page and navigation entry.
|
||||
- Add separate pages or panels for Document Types, Person Roles, and Prompts.
|
||||
- Keep pages responsible for orchestration and notifications only.
|
||||
- Use service callbacks for all mutations.
|
||||
- Explain inactive historical entries and future-only prompt effects in the UI.
|
||||
- Present deletion only for unreferenced registry entries and preserve clear conflict feedback if references appear before submission.
|
||||
- Present prompt backup availability and recovery as an explicit operator action.
|
||||
- Do not render raw environment values or secrets.
|
||||
- Add no settings API routes.
|
||||
|
||||
### 5. Verification and Rollout
|
||||
|
||||
- Confirm every database, integration, and UI test is configured for an isolated test database before execution.
|
||||
- Never run those tests against live data and never modify or replace `data/transcription.db`.
|
||||
- Invoke potentially destructive tests only through `tools/run_destructive_tests.py`.
|
||||
- Run focused service tests before UI integration tests.
|
||||
- Verify inactive registry behavior in both historical display and create/edit selectors.
|
||||
- Verify referenced entries can be relabeled or deactivated but not deleted.
|
||||
- Verify unreferenced entries can be deleted.
|
||||
- Verify prompt changes are picked up by newly created Jobs while historical Jobs retain frozen content/hash.
|
||||
- Run the relevant regression suite.
|
||||
|
||||
## Migration and Compatibility Notes
|
||||
|
||||
- Existing registry records remain valid.
|
||||
- Prompt editing changes mutable application files, not database provenance already captured on Jobs.
|
||||
- V4.3 must not require users to recreate existing Sources, Documents, People, roles, or types.
|
||||
- Person Role ordering requires no schema migration.
|
||||
- Registry deletion introduces no cascade behavior; references always block deletion.
|
||||
|
||||
## Delivery Order
|
||||
|
||||
1. Implement registry maintenance service operations.
|
||||
2. Implement the Prompt Store and safety policy.
|
||||
3. Build Settings pages.
|
||||
4. Run isolated integration and regression verification.
|
||||
|
||||
## Done Criteria
|
||||
|
||||
- All V4.3 acceptance criteria are testable and satisfied.
|
||||
- Settings mutations cross explicit service or adapter boundaries.
|
||||
- Document Types use UUID-only identity and unique labels; Person Role codes cannot be accidentally changed.
|
||||
- Referenced registry entries can be relabeled or deactivated but cannot be deleted.
|
||||
- Unreferenced registry entries can be deleted without cascade behavior.
|
||||
- Prompt writes cannot escape the configured directory or rewrite historical provenance.
|
||||
- Prompt writes are atomic, retain one backup, and support explicit recovery.
|
||||
- No secret or raw environment editor exists.
|
||||
- No settings API surface exists.
|
||||
- V4.1 and V4.2 workflows remain intact.
|
||||
- Verification does not touch live data or `data/transcription.db`.
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [V4.3 Scope Boundary](scope_boundary_v4_3.md)
|
||||
- [V4.2 Implementation Plan](../ver4.2/implementation_plan_v4_2.md)
|
||||
- [V4.1 Implementation Plan](../ver4.1/implementation_plan_v4_1.md)
|
||||
- [V4 Implementation Plan](../ver4/implementation_plan_v4.md)
|
||||
- [V4 Error Handling Policy](../ver4/error_handling_v4.md)
|
||||
@@ -0,0 +1,154 @@
|
||||
# V4.3 Scope Boundary
|
||||
|
||||
This document defines the frozen boundary for the constrained-settings revision that follows the completed V4.2 evidence-and-provenance work. V4, V4.1, and V4.2 remain the behavioral and architecture baseline.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Provide a constrained Settings area for safe maintenance of selected application-managed configuration.
|
||||
- Avoid exposing secrets, restart-sensitive settings, or unrestricted filesystem editing through the UI.
|
||||
|
||||
## In Scope
|
||||
|
||||
### 1. Settings Navigation
|
||||
|
||||
- Add a Settings entry to application navigation.
|
||||
- Provide separate, clearly described settings areas rather than a raw configuration editor.
|
||||
- Restrict V4.3 settings to application-managed values that can be validated and safely changed at runtime.
|
||||
|
||||
### 2. Document Type Maintenance
|
||||
|
||||
- List active and inactive Document Types.
|
||||
- Add new types with a unique user-facing label.
|
||||
- Edit labels and active state.
|
||||
- Activate or deactivate types without invalidating historical Documents.
|
||||
- Allow deletion only when no Document references the type.
|
||||
- Allow label changes regardless of whether the type is referenced.
|
||||
- Display types alphabetically by label.
|
||||
|
||||
### 3. Person Role Maintenance
|
||||
|
||||
- List active and inactive Person Roles.
|
||||
- Add new roles with a stable unique code and user-facing label.
|
||||
- Edit mutable labels.
|
||||
- Activate or deactivate roles without invalidating historical links.
|
||||
- Do not allow changing a stable code after creation.
|
||||
- Order roles deterministically by label and then code; do not add persisted role sort order.
|
||||
- Allow deletion only when no document-person link references the role.
|
||||
- Allow label changes regardless of whether the role is referenced.
|
||||
|
||||
### 4. Prompt Maintenance
|
||||
|
||||
- List prompt markdown files from the configured prompt directory.
|
||||
- View a prompt with a concise explanation of its purpose and use.
|
||||
- Edit an existing prompt as plain markdown text.
|
||||
- Validate the filename boundary and reject empty prompt content.
|
||||
- Save changes explicitly and report filesystem failures.
|
||||
- Preserve submission-time prompt text and hash already frozen on existing Jobs.
|
||||
- Edit existing prompt files only; prompt creation and deletion are excluded.
|
||||
- Save through a sibling temporary file, flush and sync file content, retain one previous-version backup, and atomically replace the active file.
|
||||
- Provide an explicit recovery operation that restores the retained backup through the same safe-write path; do not silently roll back a failed or unwanted edit.
|
||||
|
||||
### 5. Deployment Boundary
|
||||
|
||||
- Settings changes apply only to the current installation.
|
||||
- V4.3 adds no settings API endpoints.
|
||||
- Service contracts must remain independent of the UI so a separately authorized API can be considered later.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Viewing or editing raw `.env` files.
|
||||
- Displaying or changing provider API keys and other secrets.
|
||||
- Editing host, port, database connection, upload paths, or other restart-sensitive runtime settings.
|
||||
- Arbitrary file browsing or arbitrary prompt paths.
|
||||
- Runtime theme/CSS editing.
|
||||
- Installing themes or plugins.
|
||||
- Source page renumbering or reordering.
|
||||
- Source movement between Documents.
|
||||
- Automatic ordering based on filenames, OCR, or image content.
|
||||
- Prompt creation, deletion, and multi-version history.
|
||||
- Persisted sort-order maintenance for Person Roles.
|
||||
- Settings read or write API endpoints.
|
||||
- FamilySearch API synchronization.
|
||||
- A generic external-reference registry.
|
||||
- Ancestry references and Google Maps links.
|
||||
|
||||
## Locked Design Decisions
|
||||
|
||||
### A. Registry Identity and Lifecycle
|
||||
|
||||
- Document Types use UUID identity and case-insensitively unique labels; no separate code is exposed or stored.
|
||||
- Person Role codes remain stable identifiers.
|
||||
- Labels and active state remain mutable.
|
||||
- Historical references remain valid when a registry entry is inactive.
|
||||
- Labels may be updated for referenced and unreferenced entries.
|
||||
- Unreferenced entries may be deleted; referenced entries may only be deactivated.
|
||||
|
||||
### B. No Raw Environment Editor
|
||||
|
||||
- `.env` may contain secrets and values that are not safely reloadable.
|
||||
- V4.3 exposes only purpose-built forms backed by explicit validation and service methods.
|
||||
|
||||
### C. Prompt Editing Is Constrained
|
||||
|
||||
- Prompt maintenance is limited to direct children of the configured prompt directory.
|
||||
- Existing Job provenance is never rewritten when a prompt file changes.
|
||||
- The UI must distinguish editing the default for future submissions from inspecting historical Job prompts.
|
||||
|
||||
### D. Prompt Writes Are Atomic and Recoverable
|
||||
|
||||
- Writes use a sibling temporary file and atomic replacement so readers observe either the old or new complete prompt.
|
||||
- The immediately previous prompt version is retained as the sole backup.
|
||||
- Recovery is an explicit operator action and uses the same validated safe-write path.
|
||||
- Prompt creation and deletion are not available in V4.3.
|
||||
|
||||
### E. Person Role Ordering Is Deterministic, Not Persisted
|
||||
|
||||
- Person Roles are ordered by label and then stable code.
|
||||
- V4.3 does not add a `sort_order` field to Person Roles.
|
||||
- Document Types use alphabetical label ordering and have no persisted sort order.
|
||||
|
||||
### F. Settings Are Installation-Local
|
||||
|
||||
- V4.3 provides Settings through the local application UI and domain services only.
|
||||
- No settings API surface is introduced.
|
||||
|
||||
## Data and Compatibility Policy
|
||||
|
||||
- V4.3 does not rewrite existing Documents, document-person links, Jobs, Sources, execution evidence, or prompt provenance.
|
||||
- Deactivation preserves referenced registry entries for historical display while excluding them from default create selectors.
|
||||
- Deletion checks are performed at the service boundary and must fail deterministically when references exist.
|
||||
- Prompt files are constrained to existing Markdown files that are direct children of the configured prompt root.
|
||||
- Settings UI code performs no direct database, environment-file, or arbitrary filesystem mutations.
|
||||
- Source page numbering and ordering behavior is unchanged.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Document Type UUID identity and Person Role stable codes preserve historical references.
|
||||
2. Inactive registry entries remain visible on historical records but are excluded from default create selectors.
|
||||
3. Labels can be changed for referenced or unreferenced registry entries.
|
||||
4. An unreferenced Document Type or Person Role can be deleted, while deletion of a referenced entry fails without partial mutation.
|
||||
5. Document Types use alphabetical label ordering; Person Roles use deterministic label/code ordering.
|
||||
6. Prompt edits are restricted to existing Markdown files directly beneath the configured prompt directory.
|
||||
7. Prompt saves use atomic replacement, retain exactly one previous-version backup, and support explicit recovery.
|
||||
8. A prompt edit affects future Jobs only and leaves stored Job provenance unchanged.
|
||||
9. No Settings page exposes secrets, unrestricted filesystem access, or a settings API.
|
||||
10. Focused service and UI tests pass without regressing V4.1 or V4.2 workflows.
|
||||
11. Database, integration, and UI tests use confirmed isolated test data and never modify `data/transcription.db`; potentially destructive tests run only through `tools/run_destructive_tests.py`.
|
||||
|
||||
## Scope Freeze Gate
|
||||
|
||||
V4.3 is sufficiently frozen to begin implementation:
|
||||
|
||||
- V4.2 is the completed behavioral baseline.
|
||||
- Registry lifecycle and ordering behavior are resolved.
|
||||
- Prompt lifecycle, atomic-write, backup, and recovery behavior are resolved.
|
||||
- The installation-local deployment boundary is resolved.
|
||||
- The implementation plan is a committed delivery plan.
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [V4.3 Implementation Plan](implementation_plan_v4_3.md)
|
||||
- [V4.2 Scope Boundary](../ver4.2/scope_boundary_v4_2.md)
|
||||
- [V4.1 Scope Boundary](../ver4.1/scope_boundary_v4_1.md)
|
||||
- [V4 Architecture](../ver4/architecture_v4.md)
|
||||
- [V4 Schema](../ver4/schema_v4.md)
|
||||
@@ -0,0 +1,188 @@
|
||||
# Implementation Plan (Version 4.4)
|
||||
|
||||
## Goal
|
||||
|
||||
Deliver hidden semantic identity for built-in registries, a single atomic Linked People workflow, and safe browser-native printing of archival Documents and their current transcriptions.
|
||||
|
||||
## Planning Constraints
|
||||
|
||||
- V4.3 is the completed implementation baseline.
|
||||
- V4.4 may replace V4/V4.3 registry and document-person contracts only as specified by the V4.4 scope.
|
||||
- Semantic keys are internal and immutable; UI and public API contracts use UUIDs and labels.
|
||||
- Document and link edits must not partially commit.
|
||||
- Print output must not execute stored text or expose machine-local source paths.
|
||||
- Source page reordering remains excluded.
|
||||
- Database, integration, and UI tests must use confirmed isolated data and never modify `data/transcription.db`.
|
||||
- Potentially destructive tests must run only through `tools/run_destructive_tests.py`.
|
||||
|
||||
## Expected Project Impact
|
||||
|
||||
| Area | Expected impact |
|
||||
| --- | --- |
|
||||
| Models and schema bootstrap | Add nullable unique semantic keys, simplify document-person identity, and seed frozen built-ins. |
|
||||
| Document service | Maintain built-in Document Types, usage summaries, UUID assignment, and atomic Document/link writes. |
|
||||
| People service | Maintain built-in Person Roles, link summaries, UUID-only role assignment, and one-person-per-document enforcement. |
|
||||
| V4 document API | Remove role-code selectors and compatibility role fields; enforce UUID-only relationship writes. |
|
||||
| Settings UI | Use matching table workflows for Document Types and Person Roles. |
|
||||
| Document Create/Edit | Replace role-specific multiselects with one staged Linked People table and inline editor. |
|
||||
| Document Detail/printing | Add format selection, print preview, safe Source media rendering, print CSS, and job metadata. |
|
||||
| Tests and documentation | Replace superseded cardinality/identity assertions and add isolated registry, editor, transaction, and print coverage. |
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### 1. Align Durable Registry Contracts
|
||||
|
||||
- Add nullable, unique `semantic_key` fields to `DocumentType` and `PersonRole`.
|
||||
- Keep UUIDs as primary and foreign-key identity.
|
||||
- Add normalized-label storage and uniqueness to Person Roles using the same trim and case-normalization policy as Document Types.
|
||||
- Remove the user-created Person Role code contract.
|
||||
- Define built-in detection as `semantic_key is not null`.
|
||||
- Centralize the frozen built-in definitions in one domain-owned location.
|
||||
- Seed six Document Types and three Person Roles idempotently.
|
||||
- Ensure label edits never change semantic keys.
|
||||
- Reject deletion of every built-in before checking references.
|
||||
- Continue blocking deletion of referenced custom entries.
|
||||
- Return deterministic validation, conflict, dependency, and not-found errors through existing error categories.
|
||||
|
||||
### 2. Establish the Clean Schema
|
||||
|
||||
- Remove legacy `DocumentPerson.role` compatibility storage and the fixed `DocumentPersonRole` enum.
|
||||
- Make `DocumentPerson.role_id` required.
|
||||
- Replace role-specific uniqueness with a unique `(document_id, person_id)` constraint.
|
||||
- Remove obsolete Document Type and Person Role migration paths that exist only for disposable development data.
|
||||
- Keep fresh schema creation and built-in seeding portable across SQLite and PostgreSQL.
|
||||
- Make configured development-database recreation a separate operator-confirmed step that displays the resolved target path rather than assuming `app.db` or `data/transcription.db`.
|
||||
- Never invoke recreation from application startup or test setup.
|
||||
- Add isolated schema tests for fresh creation, seed idempotence, semantic-key uniqueness, normalized-label uniqueness, required roles, and link uniqueness.
|
||||
|
||||
### 3. Refine Registry Services and API Contracts
|
||||
|
||||
- Add summary queries for Document counts and Person Role link counts without per-row queries.
|
||||
- Order both registries by normalized label with UUID as a deterministic tie-breaker.
|
||||
- Expose built-in status as a derived read value where the Settings UI needs it.
|
||||
- Keep semantic-key lookup behind service methods for application-owned behavior such as resolving authors.
|
||||
- Ensure create operations always produce custom entries with null semantic keys.
|
||||
- Ensure update operations accept only label and active state.
|
||||
- Remove `role_code` request alternatives and compatibility role responses from the V4 document API.
|
||||
- Require `role_id` for document-person creation and updates.
|
||||
- Add service and API tests for hidden semantic identity, relabeling, activation, built-in protection, custom deletion, counts, ordering, UUID-only writes, and conflicts.
|
||||
|
||||
### 4. Build Matching Settings Tables
|
||||
|
||||
- Retain the existing Document Types table workflow and add the Built-in column.
|
||||
- Replace the current per-row Person Role controls with the same selection-based table pattern.
|
||||
- Render the agreed columns and usage counts.
|
||||
- Keep labels as the only registry text shown in selectors.
|
||||
- Add creates custom entries only.
|
||||
- Edit dialogs expose label and active state only.
|
||||
- Delete reports protected-built-in and referenced-custom conflicts clearly.
|
||||
- Avoid direct persistence queries from the Settings page.
|
||||
- Add component-level UI assertions for columns, actions, label-only selectors, and immutable built-in presentation.
|
||||
|
||||
### 5. Add a Staged Linked People Editor
|
||||
|
||||
- Introduce a small typed UI-state model for staged `(person_id, role_id)` rows rather than storing raw widget values.
|
||||
- Share the editor component between Create Document and Edit Document.
|
||||
- Render a multi-selection table with Person and Role labels.
|
||||
- Add an inline editor whose mode is explicitly Add or Edit.
|
||||
- Disable already-linked People when adding; retain the edited Person as an option during Edit.
|
||||
- Require exactly one row for Edit and allow one or more rows for Delete.
|
||||
- Save and Delete mutate only staged UI state.
|
||||
- Cancel discards only the active inline edit.
|
||||
- Preserve inactive-role historical rows in Edit while restricting new assignments and changes to active roles.
|
||||
- Preserve `person_id` preselection by staging that Person with the active built-in `author` role, with warning behavior for invalid or unavailable selections.
|
||||
- Preserve the `return_to=jobs_new` success path.
|
||||
- Keep navigation to Person creation separate; V4.4 does not add an embedded Person editor.
|
||||
- Add UI tests for staging, duplicate prevention, selection rules, inactive roles, cancel behavior, and both Document forms.
|
||||
|
||||
### 6. Persist Document and Links Atomically
|
||||
|
||||
- Add service commands for Create Document with complete links and Update Document with complete links.
|
||||
- Validate Document Type, every Person, every Person Role, active assignment rules, and duplicate People before mutation.
|
||||
- Compute deterministic add, update, and remove deltas for Edit.
|
||||
- Apply Document and link mutations in one database transaction and commit once.
|
||||
- Roll back the complete operation on any validation, conflict, or persistence failure.
|
||||
- Return the persisted Document detail required by the UI after success.
|
||||
- Reuse these commands from UI orchestration rather than sequencing independent service commits.
|
||||
- Add failure-injection tests proving no partial Document or link mutation survives.
|
||||
|
||||
### 7. Define a Print Projection
|
||||
|
||||
- Add a read-only service projection containing:
|
||||
- Document title and selected archival metadata.
|
||||
- Authors resolved by the `author` semantic key.
|
||||
- Notes.
|
||||
- Ordered Sources with application media URLs and current transcription text.
|
||||
- Ordered Job metadata.
|
||||
- Load the projection with bounded queries and deterministic ordering.
|
||||
- Use non-null `revised_text`, including an intentionally empty revision; otherwise fall back to `raw_transcription`.
|
||||
- Map empty or whitespace-only current text to the explicit unavailable state without falling back past an intentional revision.
|
||||
- Represent unavailable text and optional metadata explicitly.
|
||||
- Do not expose semantic keys, direct file paths, full prompts, provider evidence, or raw API responses.
|
||||
- Keep the projection independent of NiceGUI rendering so formatting tests can use plain typed values.
|
||||
|
||||
### 8. Build Print Preview and Styles
|
||||
|
||||
- Add a Print action to Document Detail.
|
||||
- Open a dedicated persisted-Document print route with a Facsimile/Text-only format choice.
|
||||
- Render the exact content order frozen in the scope.
|
||||
- Render stored Notes and transcription as escaped text.
|
||||
- For Text-only mode, normalize whitespace by joining single line breaks inside paragraphs while preserving blank-line paragraph boundaries.
|
||||
- For Facsimile mode, preserve line breaks and use a two-column Source layout.
|
||||
- Start each Facsimile Source on a new printed sheet with CSS page breaks.
|
||||
- Allow long transcription content to continue rather than clipping it.
|
||||
- Fetch images through an application-controlled Source media route.
|
||||
- Add print-only CSS that hides navigation, controls, and non-document chrome.
|
||||
- Invoke the browser print dialog only from an explicit user action.
|
||||
- Add rendering tests for both modes, missing data, long text, special characters, image URLs, and page ordering.
|
||||
|
||||
### 9. Align Documentation and Verification
|
||||
|
||||
- Update V4 architecture, requirements, schema, and Document UI contracts to reflect:
|
||||
- UUID plus hidden semantic-key registries.
|
||||
- Built-in protection.
|
||||
- One Person per Document.
|
||||
- UUID-only role API writes.
|
||||
- Atomic Document/link synchronization.
|
||||
- Browser-native print projection and formats.
|
||||
- Confirm every database, integration, and UI test target is isolated before execution.
|
||||
- Run focused registry and service tests first.
|
||||
- Run schema tests only through the destructive-test wrapper when they are potentially destructive.
|
||||
- Run Linked People UI and print rendering tests against isolated fixtures.
|
||||
- Run the broader non-external regression suite after focused coverage passes.
|
||||
- Verify that `data/transcription.db` was not changed by test execution.
|
||||
|
||||
## Delivery Order
|
||||
|
||||
1. Registry and clean-schema contracts.
|
||||
2. Registry services, API changes, and Settings tables.
|
||||
3. Atomic Document/link service commands.
|
||||
4. Shared staged Linked People editor.
|
||||
5. Print projection.
|
||||
6. Print preview and styles.
|
||||
7. Documentation alignment and regression verification.
|
||||
|
||||
## Done Criteria
|
||||
|
||||
- All V4.4 acceptance criteria are implemented and testable.
|
||||
- UI and API contracts use UUID identity and never expose semantic keys.
|
||||
- Built-in registries retain meaning after relabeling and cannot be deleted.
|
||||
- Custom registries retain reference-aware deletion.
|
||||
- Both Document forms use one Linked People table.
|
||||
- One Person cannot be linked twice to the same Document.
|
||||
- Main Document saves are atomic across fields and relationships.
|
||||
- Print preview provides both frozen formats and content sections.
|
||||
- Print output uses current human-preferred text, deterministic ordering, escaped content, and application media URLs.
|
||||
- Job metadata lists every Job oldest-to-newest and ends with Status.
|
||||
- Source page reordering and server-generated PDFs are not introduced.
|
||||
- Verification uses isolated data and does not modify `data/transcription.db`.
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [V4.4 Scope Boundary](scope_boundary_v4_4.md)
|
||||
- [V4.3 Scope Boundary](../ver4.3/scope_boundary_v4_3.md)
|
||||
- [V4.3 Implementation Plan](../ver4.3/implementation_plan_v4_3.md)
|
||||
- [V4 Architecture](../ver4/architecture_v4.md)
|
||||
- [V4 Schema](../ver4/schema_v4.md)
|
||||
- [V4 Requirements](../ver4/requirements_v4.md)
|
||||
- [V4 Error Handling Policy](../ver4/error_handling_v4.md)
|
||||
@@ -0,0 +1,232 @@
|
||||
# V4.4 Scope Boundary
|
||||
|
||||
This document defines the frozen boundary for the semantic-registry, linked-people, and document-printing revision that follows the completed V4.3 Settings work. V4 through V4.3 remain the architecture and behavioral baseline except where this document explicitly replaces a registry or document-person contract.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Keep registry identifiers stable without exposing duplicate machine codes in Settings tables or selectors.
|
||||
- Replace role-specific person selectors with one coherent Linked People editor.
|
||||
- Provide an archival print view containing document metadata, source pages, current transcription text, and transcription-job metadata.
|
||||
|
||||
## In Scope
|
||||
|
||||
### 1. Semantic Registry Identity
|
||||
|
||||
- `DocumentType` and `PersonRole` use UUIDs as their canonical record and relationship identity.
|
||||
- Both registries may carry a nullable, unique, immutable `semantic_key` used only for application-defined built-ins.
|
||||
- Semantic keys are internal implementation details. Settings tables, selectors, and public API payloads do not display or accept them.
|
||||
- Labels are trimmed, case-insensitively unique, editable, and used for all user-facing display.
|
||||
- Active state remains editable. Inactive entries remain valid for historical records and are excluded from default assignment selectors.
|
||||
- Built-in status is derived from the presence of a semantic key and is displayed as a read-only Yes/No value.
|
||||
- Built-in entries cannot be deleted or converted to custom entries.
|
||||
- Custom entries have no semantic key and may be deleted only when unreferenced.
|
||||
- Users may create custom entries but cannot create, change, or assign semantic keys through the UI or API.
|
||||
|
||||
### 2. Built-In Document Types
|
||||
|
||||
- Seed these built-in semantic keys and initial labels:
|
||||
|
||||
| Semantic key | Initial label |
|
||||
| --- | --- |
|
||||
| `book` | Book |
|
||||
| `letter` | Letter |
|
||||
| `postcard` | Postcard |
|
||||
| `photo` | Photo |
|
||||
| `journal` | Journal |
|
||||
| `form` | Form |
|
||||
|
||||
- The Document Types Settings table contains Select, Label, Documents, Active, and Built-in columns.
|
||||
- Document Types are ordered alphabetically by normalized label.
|
||||
- Add, Edit, and Delete actions operate on table selection.
|
||||
- Add creates a custom type. Edit changes only label and active state.
|
||||
- The Documents count is the number of Documents referencing the type.
|
||||
- Document Type selectors display labels only and submit UUIDs.
|
||||
|
||||
### 3. Built-In Person Roles
|
||||
|
||||
- Seed these built-in semantic keys and initial labels:
|
||||
|
||||
| Semantic key | Initial label |
|
||||
| --- | --- |
|
||||
| `author` | Author |
|
||||
| `recipient` | Recipient |
|
||||
| `mentioned` | Mentioned |
|
||||
|
||||
- Application behavior that requires authorship resolves the built-in `author` semantic key rather than matching a mutable label.
|
||||
- The Person Roles Settings table contains Select, Label, Links, Active, and Built-in columns.
|
||||
- Person Roles are ordered alphabetically by normalized label.
|
||||
- Add, Edit, and Delete actions operate on table selection.
|
||||
- Add creates a custom role. Edit changes only label and active state.
|
||||
- The Links count is the number of document-person relationships referencing the role.
|
||||
- Person Role selectors display labels only and submit UUIDs.
|
||||
|
||||
### 4. Linked People Editor
|
||||
|
||||
- Replace the separate role-specific person selectors on both Create Document and Edit Document with one Linked People table.
|
||||
- The table contains Select, Person, and Role columns.
|
||||
- Add opens an inline editor beneath the table with Person and Person Role selectors.
|
||||
- Edit requires exactly one selected row and loads it into the inline editor.
|
||||
- Save stages the inline addition or edit in the table.
|
||||
- Cancel exits the inline editor without changing the staged link set.
|
||||
- Delete stages removal of one or more selected rows.
|
||||
- A Person may be linked to a Document only once, regardless of role.
|
||||
- Every link has exactly one Person Role.
|
||||
- Already-linked People are unavailable when adding another row.
|
||||
- Existing links using inactive roles remain visible and unchanged unless explicitly edited.
|
||||
- Only active roles are available for new links or role changes.
|
||||
- Create Document preserves the existing `person_id` preselection workflow by staging that Person with the active built-in `author` role. An invalid Person or unavailable Author role produces a warning rather than an invalid link.
|
||||
- Create Document preserves the existing `return_to=jobs_new` success path.
|
||||
- Linked People changes remain staged until the main Create Document or Save Changes action.
|
||||
- The Document and its complete staged link set are persisted atomically. A conflict or validation failure leaves both unchanged.
|
||||
- The API and service contracts identify roles by `role_id`; role-code selectors and compatibility role strings are removed.
|
||||
- Persistence enforces uniqueness on `(document_id, person_id)`.
|
||||
|
||||
### 5. Document Print View
|
||||
|
||||
- Add a Print action to Document Detail.
|
||||
- The action opens a dedicated print-preview page for the persisted Document.
|
||||
- The preview offers two formats:
|
||||
- **Facsimile:** source image on the left and current transcription on the right. Original transcription line breaks are preserved, and each Source begins on a new printed sheet.
|
||||
- **Text only:** no source images. Single line breaks inside a paragraph are reflowed as spaces, while blank-line paragraph boundaries remain.
|
||||
- Both formats use browser printing through a dedicated print stylesheet and the browser print dialog.
|
||||
- Server-generated PDF files are not part of V4.4; users may select the browser's Save as PDF destination.
|
||||
- Sources are ordered by existing `page_number`, with UUID as a deterministic tie-breaker.
|
||||
- The current transcription for each Source is the non-null `revised_text`, including an intentionally empty revision, otherwise the latest successful machine-output projection in `raw_transcription`.
|
||||
- Empty or whitespace-only current text displays the explicit unavailable message rather than falling back past an intentional revision.
|
||||
- A Source with no current transcription displays an explicit unavailable message.
|
||||
- Transcription and Notes content is treated as text and escaped; model output is not executed as arbitrary HTML.
|
||||
- Facsimile images use an application-controlled Source media route. Generated markup does not expose direct machine-local file paths.
|
||||
|
||||
### 6. Printed Content Contract
|
||||
|
||||
The print view contains, in this order:
|
||||
|
||||
1. Document title using the Document name.
|
||||
2. Archival Metadata table:
|
||||
- Author, containing People linked through the built-in `author` role.
|
||||
- Date.
|
||||
- Location Created.
|
||||
- Archival Identifier.
|
||||
3. Notes.
|
||||
4. Document section containing one numbered section per Source.
|
||||
5. Transcription Job Metadata table.
|
||||
|
||||
Empty metadata values remain visible as `Not set`. Empty Notes display `No notes recorded`.
|
||||
|
||||
The job metadata table:
|
||||
|
||||
- Lists field names in the first column and adds one column for every Job associated with the Document.
|
||||
- Orders Job columns from oldest to newest by creation date, then UUID.
|
||||
- Includes every Job status: `queued`, `processing`, `transcribed`, `completed`, `partial_success`, and `failed`.
|
||||
- Contains these rows in order:
|
||||
- Job ID.
|
||||
- Date, using the Job creation/submission timestamp with timezone.
|
||||
- Provider.
|
||||
- Model.
|
||||
- Prompt, using the frozen prompt filename/name rather than full prompt content.
|
||||
- Retry Count.
|
||||
- Status as the final row.
|
||||
- Displays `Not set` for unavailable optional metadata.
|
||||
|
||||
### 7. Clean Development Schema
|
||||
|
||||
- V4.4 does not require preservation or migration of rows in the operator-configured development database.
|
||||
- Implementation may recreate the configured development database, including `data/transcription.db` when it is the explicitly selected target, only through a separate operator-confirmed action that identifies the resolved path. Startup and test execution never delete it automatically.
|
||||
- Fresh schema creation seeds the agreed built-in Document Types and Person Roles idempotently.
|
||||
- No test may use, modify, replace, or restore `data/transcription.db`.
|
||||
- Database, integration, and UI tests use confirmed isolated databases.
|
||||
- Potentially destructive tests run only through `tools/run_destructive_tests.py`.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- User creation, editing, deletion, or direct display of semantic keys.
|
||||
- Treating custom registry entries as built-ins.
|
||||
- Additional built-in Document Types or Person Roles beyond the frozen lists.
|
||||
- Assigning more than one role to the same Person on the same Document.
|
||||
- Preserving multiple historical links that violate the new one-person-per-document constraint.
|
||||
- Source page renumbering or reordering.
|
||||
- Printing unsaved Create/Edit Document state.
|
||||
- Print actions on Job Detail or other pages.
|
||||
- Batch printing multiple Documents.
|
||||
- Server-side PDF generation or PDF file storage.
|
||||
- Markdown, DOCX, or evidence-package export through the print feature.
|
||||
- User-editable print templates, fonts, margins, headers, or footers.
|
||||
- Full frozen prompt content, prompt hashes, transport evidence, API responses, or execution-attempt details in the print footer.
|
||||
- Rendering transcription text as unrestricted Markdown or HTML.
|
||||
- Pixel-identical pagination across browsers and printer drivers.
|
||||
|
||||
## Locked Design Decisions
|
||||
|
||||
### A. UUID Identifies the Row; Semantic Key Identifies Built-In Meaning
|
||||
|
||||
- UUIDs remain the only relationship and API identity.
|
||||
- A hidden semantic key permits reliable built-in behavior after a label is renamed.
|
||||
- Mutable labels are never used to infer built-in meaning.
|
||||
|
||||
### B. Built-Ins Are Protected but Mutable in Presentation
|
||||
|
||||
- Built-in labels and active state may change.
|
||||
- Built-in semantic identity cannot change, and built-ins cannot be deleted.
|
||||
- Custom entries remain reference-aware and deletable when unreferenced.
|
||||
|
||||
### C. Linked People Is a Single-Role Relationship
|
||||
|
||||
- One `(document_id, person_id)` row represents the complete relationship.
|
||||
- Changing a role updates that row rather than adding another relationship.
|
||||
- The main Document save owns one atomic Document-and-links transaction.
|
||||
|
||||
### D. Printing Uses the Current Human-Preferred Text
|
||||
|
||||
- Human-revised text takes precedence over the latest successful machine-output projection.
|
||||
- Job metadata provides processing context but does not claim that a later human revision is raw output from a listed Job.
|
||||
|
||||
### E. Printing Is Browser-Native
|
||||
|
||||
- A print-specific HTML view and CSS support physical printing and browser Save as PDF.
|
||||
- Source media is served through application-controlled routes, and all textual content is escaped.
|
||||
|
||||
### F. Source Order Is Read-Only in V4.4
|
||||
|
||||
- Print order follows existing page numbers.
|
||||
- Source page reordering remains explicitly excluded.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Registry selectors and Settings forms never display a machine code or semantic key.
|
||||
2. Document Types and Person Roles use UUID relationship identity and case-insensitively unique labels.
|
||||
3. The six Document Type and three Person Role built-ins are seeded with immutable internal semantic keys.
|
||||
4. Built-ins may be relabeled or disabled but cannot be deleted.
|
||||
5. Unreferenced custom entries may be deleted; referenced custom entries may only be relabeled or disabled.
|
||||
6. Settings tables show the agreed columns, alphabetical label order, usage counts, and selection-based actions.
|
||||
7. Create and Edit Document use one Linked People table with inline staged Add/Edit/Save/Cancel and multi-row Delete.
|
||||
8. The same Person cannot be staged or persisted twice for one Document, even under different roles.
|
||||
9. Document fields and Linked People changes commit atomically.
|
||||
10. Historical inactive roles remain displayable, while only active roles are assignable.
|
||||
11. Document Detail opens a print preview with Facsimile and Text-only formats.
|
||||
12. Print pages use current revised text when available and deterministic Source ordering.
|
||||
13. Printed archival metadata resolves authors through the hidden `author` semantic key after any label change.
|
||||
14. The job table contains one oldest-to-newest column per Job and ends with the Status row.
|
||||
15. Print output escapes stored text and does not disclose direct local source paths.
|
||||
16. Source reordering, server PDF generation, and print-template editing are absent.
|
||||
17. Database, integration, and UI verification uses isolated test data and never modifies `data/transcription.db`.
|
||||
|
||||
## Scope Freeze Gate
|
||||
|
||||
V4.4 is sufficiently frozen to begin implementation:
|
||||
|
||||
- Built-in registry identity, membership, lifecycle, display, and selector behavior are resolved.
|
||||
- Linked People selection, editing, uniqueness, inactive-role, staging, and transaction behavior are resolved.
|
||||
- Print entry point, formats, content order, transcription precedence, page order, job metadata, and output mechanism are resolved.
|
||||
- Clean development-schema and destructive-test boundaries are resolved.
|
||||
- Source page reordering remains excluded.
|
||||
|
||||
Any expansion of the built-in catalogs, relationship cardinality, print formats, export formats, or print customization requires an explicit V4.4 scope amendment or a later revision.
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [V4.4 Implementation Plan](implementation_plan_v4_4.md)
|
||||
- [V4.3 Scope Boundary](../ver4.3/scope_boundary_v4_3.md)
|
||||
- [V4.3 Implementation Plan](../ver4.3/implementation_plan_v4_3.md)
|
||||
- [V4 Architecture](../ver4/architecture_v4.md)
|
||||
- [V4 Schema](../ver4/schema_v4.md)
|
||||
- [V4 Requirements](../ver4/requirements_v4.md)
|
||||
@@ -0,0 +1,197 @@
|
||||
# System Architecture (Version 4)
|
||||
|
||||
This document describes the production architecture of the document transcription system.
|
||||
|
||||
## Architecture Objectives
|
||||
|
||||
- Preserve original source material, per-execution machine output, and separate human revision.
|
||||
- Support batching one or more images into ordered multi-page documents.
|
||||
- Capture submission-time prompt provenance and a per-page OpenRouter SDK response snapshot.
|
||||
- Execute page transcription concurrently with bounded `asyncio` workers.
|
||||
- Maintain relational portability across SQLite and PostgreSQL.
|
||||
- Keep operator workflows cross-platform and Python-driven.
|
||||
- Support many-to-many document-person relationships with extensible roles.
|
||||
- Support registry-driven document type classification.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- Ingest one or more images into sequential `Source` pages under a `Document`.
|
||||
- Execute asynchronous vision transcription with bounded worker concurrency.
|
||||
- Preserve original source files with SHA-256 digests and byte sizes.
|
||||
- Freeze prompt text, prompt hash, model, and explicitly configured sampling parameters on each `Job`.
|
||||
- Preserve page-level machine output, normalized metadata, and an SDK-serialized OpenRouter response snapshot on `JobSource`.
|
||||
- Organize historical `Person` records through many-to-many Document relationships and extensible roles.
|
||||
- Classify Documents through a UUID-identified registry with unique labels.
|
||||
- Maintain human revision separately from machine-generated text.
|
||||
- Isolate page failures so multi-page jobs can complete with partial success.
|
||||
- Operate across supported platforms through Python-based application and maintenance tooling.
|
||||
|
||||
V4.2 extends this baseline with immutable execution attempts, exact OpenRouter transport evidence, safe
|
||||
versioned exports, and provider-neutral derived-artifact provenance. `JobSource` remains the mutable queue and
|
||||
compatibility projection; `ExecutionAttempt` is the authoritative append-only processing history. See the
|
||||
[V4.2 Scope Boundary](../ver4.2/scope_boundary_v4_2.md).
|
||||
|
||||
## Technical Stack
|
||||
|
||||
- **Runtime:** Python 3.12 or later.
|
||||
- **Web application:** FastAPI and NiceGUI.
|
||||
- **Persistence:** SQLModel and SQLAlchemy, with SQLite and PostgreSQL support.
|
||||
- **Validation and settings:** Pydantic V2 and pydantic-settings.
|
||||
- **Concurrency:** Python `asyncio` workers.
|
||||
- **Vision integration:** OpenRouter through the application's provider adapter.
|
||||
- **Testing and quality:** pytest, pytest-asyncio, Ruff, and ty.
|
||||
|
||||
## Runtime Topology
|
||||
|
||||
The runtime operates as an asynchronous Python application:
|
||||
|
||||
- FastAPI + NiceGUI web application process.
|
||||
- In-process `asyncio` worker engine for transcription execution.
|
||||
- Relational persistence via SQLModel / SQLAlchemy.
|
||||
- Pydantic V2 validation across API payloads, prompt configuration, and structured metadata.
|
||||
|
||||
^^^mermaid
|
||||
flowchart LR
|
||||
U[Browser User] --> A[FastAPI + NiceGUI App]
|
||||
A --> W[Asyncio Worker Engine]
|
||||
A --> DB[(Relational DB)]
|
||||
W --> P[Vision Provider APIs]
|
||||
W --> DB
|
||||
^^^
|
||||
|
||||
## Lifecycle Ownership
|
||||
|
||||
Application lifespan owns runtime setup and teardown:
|
||||
|
||||
- Initialize logging, settings, directories, and prompt configuration.
|
||||
- Manage asynchronous database engine connection pools.
|
||||
- Execute database bootstrap or migrations.
|
||||
- Recover stale or interrupted jobs on startup.
|
||||
- Manage graceful shutdown of active background tasks.
|
||||
|
||||
## Layered Module Structure
|
||||
|
||||
### Interface Layer
|
||||
|
||||
- `src/transcription/ui/**`
|
||||
- `src/transcription/api/**`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Render document, source, person, job, and classification views.
|
||||
- Accept user input for uploads, editing, linking, and revisions.
|
||||
- Present structured validation and conflict feedback.
|
||||
|
||||
### Application and Async Worker Layer
|
||||
|
||||
- `src/transcription/services/workflows.py`
|
||||
- `src/transcription/worker.py`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Orchestrate uploads, job creation, and status transitions.
|
||||
- Execute per-page provider calls through bounded concurrency.
|
||||
- Persist page-level outcomes and update aggregate job state.
|
||||
|
||||
### Domain and Service Layer
|
||||
|
||||
- `src/transcription/db/models.py`
|
||||
- `src/transcription/services/documents.py`
|
||||
- `src/transcription/services/sources.py`
|
||||
- `src/transcription/services/jobs.py`
|
||||
- `src/transcription/services/people.py`
|
||||
- `src/transcription/services/workflows.py`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Keep one primary service boundary per aggregate: Documents, Sources, Jobs, and People.
|
||||
- Documents own document records and the document-type registry.
|
||||
- Sources own source records, revisions, source media formats, MIME resolution, and page execution evidence.
|
||||
- Jobs own job lifecycle state and transitions.
|
||||
- People own person records, relationship roles, document-person links, and portrait media.
|
||||
- Apply deterministic conflict handling for relationship-role writes.
|
||||
- Use set-based synchronization for many-to-many relationship updates.
|
||||
- Resolve and validate registry-backed document types by UUID.
|
||||
|
||||
### Source Media Policy
|
||||
|
||||
- `services/sources.py` is the single authority for accepted Source extensions and canonical MIME types.
|
||||
- Storage and provider payload loading must call the same Source validation functions.
|
||||
- Supported Source formats are JPEG, PNG, TIFF, and PDF.
|
||||
- Upload is an interface action, not a domain aggregate. Service names, errors, and workflow variables use
|
||||
`Source` terminology; compatibility aliases may remain temporarily at old import boundaries.
|
||||
|
||||
### Infrastructure Layer
|
||||
|
||||
- `src/transcription/db/**`
|
||||
- `src/transcription/providers/**`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Provide async database sessions and engine configuration.
|
||||
- Provide provider adapters for vision model execution.
|
||||
|
||||
## Core Workflows
|
||||
|
||||
### 1. Multi-Page Transcription
|
||||
|
||||
1. User uploads one or more images for a `Document`.
|
||||
2. System stores files, hashes them, creates ordered `Source` rows, and creates a `Job`.
|
||||
3. Worker claims the job, marks it `processing`, and executes page calls concurrently.
|
||||
4. Each provider call appends an `ExecutionAttempt` with its request manifest, transport evidence, SDK snapshot,
|
||||
normalized metadata, timing, and outcome.
|
||||
5. The linked `JobSource` is updated as a compatibility projection, and a successful attempt updates the
|
||||
`Source.raw_transcription` latest-success projection.
|
||||
6. Aggregate status becomes `completed`, `partial_success`, or `failed`.
|
||||
|
||||
### 2. Document-Person Relationship Management
|
||||
|
||||
1. User opens a document or person edit flow.
|
||||
2. UI loads existing links grouped by role.
|
||||
3. User adds or removes people within one or more roles.
|
||||
4. Service computes add/remove deltas rather than replacing all links blindly.
|
||||
5. Conflict checks enforce uniqueness and deterministic write semantics before persistence commits.
|
||||
|
||||
### 3. Document Type Management
|
||||
|
||||
1. User selects a registry-backed document type for a document.
|
||||
2. Service resolves the Document Type UUID.
|
||||
3. Persistence stores the `document_type_id` reference.
|
||||
4. Inactive types remain valid for historical rows but are excluded from default selectors.
|
||||
|
||||
## V4 Domain Rules
|
||||
|
||||
- `JobSource.raw_transcription` preserves page output for its Job execution.
|
||||
- `Source.raw_transcription` is the latest-success machine-output projection for a page.
|
||||
- Human corrections occur only in `Source.revised_text`.
|
||||
- Prompt and parameter provenance is frozen on `Job` at submission time.
|
||||
- The SDK-serialized OpenRouter response snapshot is stored on `JobSource` for each successful page execution.
|
||||
- Every V4.2 provider call appends a distinct `ExecutionAttempt`; retries never rewrite earlier attempts.
|
||||
- Exact response bytes identify the OpenRouter HTTP boundary and are not labeled as native upstream-provider JSON.
|
||||
- Generic `ProcessingArtifact` records use versioned schemas, digests, and one inline or external content location.
|
||||
- `DocumentPerson` links are unique for `(document_id, person_id, role_id)`.
|
||||
- Relationship mutations are deterministic and set-based.
|
||||
- `DocumentType.id` is canonical identity; its unique label may evolve.
|
||||
|
||||
## Data Model Summary
|
||||
|
||||
- `Document` has one `DocumentType`, many `Source` pages, many `Job` runs, and many `Person` records through `DocumentPerson`.
|
||||
- `Source` belongs to one `Document` and may participate in many `JobSource` executions.
|
||||
- `Job` has many `JobSource` rows.
|
||||
- `PersonRole` defines available relationship roles.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Unit tests for models, validation, hashing, and registry resolution.
|
||||
- Service tests for CRUD, set-based sync, uniqueness conflicts, and deterministic relationship writes.
|
||||
- Async workflow tests for page isolation, partial failure handling, and stored evidence.
|
||||
- UI integration tests for multi-page rendering, role grouping, and document type selection.
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v4.md)
|
||||
- [System Requirements](requirements_v4.md)
|
||||
- [Data Model](schema_v4.md)
|
||||
- [Error Handling Policy](error_handling_v4.md)
|
||||
- [Error Handling Invariant](../invariant/error_handling.md)
|
||||
- [Digital Evidence and AI Processing Provenance](../invariant/ai_evidence_and_provenance.md)
|
||||
@@ -0,0 +1,115 @@
|
||||
# Error Handling Policy (Version 4)
|
||||
|
||||
This document defines the Version 4 taxonomy, contracts, and framework behavior used to satisfy the cross-version [Error Handling invariant](../invariant/error_handling.md).
|
||||
|
||||
## Invariant Alignment
|
||||
|
||||
Version 4 implements the invariant through:
|
||||
|
||||
- The shared error taxonomy below.
|
||||
- Structured error envelopes with correlation IDs.
|
||||
- Page-level failure isolation and explicit aggregate job status.
|
||||
- Atomic relationship and classification writes.
|
||||
- Consistent translation across API, UI, service, worker, persistence, and provider boundaries.
|
||||
- Bounded retry guidance based on category and idempotency.
|
||||
|
||||
## Scope and Authority
|
||||
|
||||
This policy governs error behavior across:
|
||||
|
||||
- NiceGUI pages
|
||||
- FastAPI routes
|
||||
- Service-layer orchestration
|
||||
- `asyncio` worker tasks
|
||||
- Database interactions
|
||||
- Provider adapters
|
||||
|
||||
## Error Taxonomy
|
||||
|
||||
| Category | Definition | Retriable |
|
||||
| --- | --- | --- |
|
||||
| `validation_error` | Payload, parameter, or schema validation failure | no |
|
||||
| `user_input_error` | Unacceptable file, invalid selection, or malformed request from the operator | no |
|
||||
| `not_found_error` | Requested `Document`, `Source`, `Person`, `Job`, role, or type does not exist | no |
|
||||
| `conflict_error` | Operation violates uniqueness or relationship-write policy | no |
|
||||
| `external_provider_error` | Provider API failure, rate limit, or execution problem | yes |
|
||||
| `infrastructure_transient_error` | Temporary DB, file-system, or network instability | yes |
|
||||
| `infrastructure_persistent_error` | Persistent configuration, credential, or database availability failure | no |
|
||||
| `internal_unexpected_error` | Uncaught exception or logic defect | no |
|
||||
|
||||
## Async Batch and Page-Level Error Behavior
|
||||
|
||||
In multi-page `asyncio` processing:
|
||||
|
||||
1. Exceptions from individual page calls are trapped within the page task wrapper.
|
||||
2. Failed page detail is written to `JobSource.error_detail` and the page state becomes `failed`.
|
||||
3. Aggregate job status is derived from page outcomes:
|
||||
- all pages succeed -> `completed`
|
||||
- some succeed and some fail -> `partial_success`
|
||||
- all fail -> `failed`
|
||||
4. Successful pages remain valid even when sister pages fail.
|
||||
|
||||
## Relationship and Classification Conflict Behavior
|
||||
|
||||
When relationship or document-type writes fail policy checks:
|
||||
|
||||
1. Reject the full write operation.
|
||||
2. Return structured conflict detail including target identifiers and the violated rule.
|
||||
3. Preserve existing persisted relationships unchanged.
|
||||
|
||||
## API Error Response Contract
|
||||
|
||||
API error responses return a structured envelope:
|
||||
|
||||
^^^json
|
||||
{
|
||||
"error_id": "err_uuid_12345",
|
||||
"category": "conflict_error",
|
||||
"message": "Relationship write conflicts with existing links.",
|
||||
"suggestion": "Adjust the requested relationship links and retry.",
|
||||
"details": {
|
||||
"document_id": "...",
|
||||
"person_id": "...",
|
||||
"attempted_role": "recipient",
|
||||
"operation": "add_link",
|
||||
"conflict_reason": "duplicate document-person-role link"
|
||||
},
|
||||
"timestamp": "2026-08-10T15:00:00Z"
|
||||
}
|
||||
^^^
|
||||
|
||||
HTTP status mappings:
|
||||
|
||||
- `validation_error`, `user_input_error` -> `400`
|
||||
- `not_found_error` -> `404`
|
||||
- `conflict_error` -> `409`
|
||||
- `external_provider_error` -> `502` or `503`
|
||||
- `infrastructure_transient_error` -> `503`
|
||||
- `infrastructure_persistent_error`, `internal_unexpected_error` -> `500`
|
||||
|
||||
## UI Error Presentation Rules
|
||||
|
||||
- Display concise failure summaries with the next action the operator can take.
|
||||
- Keep form state in context when feasible.
|
||||
- Distinguish validation issues, conflict issues, provider failures, and infrastructure failures.
|
||||
- For bulk relationship updates, identify the specific role or person that caused a conflict.
|
||||
|
||||
## Logging and Audit Expectations
|
||||
|
||||
- Log worker failures with correlation IDs and provider context.
|
||||
- Log relationship and classification conflicts with machine-readable detail.
|
||||
- Log persisted provider errors and page-level execution failures.
|
||||
|
||||
## Retry Guidance
|
||||
|
||||
- Do not auto-retry validation or conflict failures.
|
||||
- Permit user-driven retry after the input or selection changes.
|
||||
- Allow bounded retry for transient provider or infrastructure failures when the operation is idempotent.
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [Error Handling Invariant](../invariant/error_handling.md)
|
||||
- [System Overview](index_v4.md)
|
||||
- [System Requirements](requirements_v4.md)
|
||||
- [Data Model](schema_v4.md)
|
||||
- [System Architecture](architecture_v4.md)
|
||||
@@ -0,0 +1,102 @@
|
||||
# Implementation Plan (Version 4)
|
||||
|
||||
## Goal
|
||||
|
||||
Implement the Version 4 project definition from the current repository state while preserving existing data by default.
|
||||
|
||||
## Migration Policy
|
||||
|
||||
- Database changes are non-destructive by default.
|
||||
- Exception: the legacy `document_type` text field may be replaced by a `document_type_id` reference without migrating existing text values.
|
||||
- Exception: `document_person` links may be recreated manually.
|
||||
|
||||
## Current Project Impact
|
||||
|
||||
- `src/transcription/db/models.py` requires full schema alignment with the V4 core documents.
|
||||
- `src/transcription/services/documents.py` requires set-based document-person sync and document-type resolution.
|
||||
- API modules require additive role-aware relationship behavior and document-type selection behavior.
|
||||
- UI pages require grouped role displays, multi-role editing, and registry-backed document-type selection.
|
||||
- Existing tests require updates for role enforcement, document-type selection, and regression safety.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### 1. Finalize the Transition Documents
|
||||
|
||||
- Confirm the reset scope.
|
||||
- Confirm the database exception policy.
|
||||
- Keep core V4 documents as the only authoritative product definition.
|
||||
|
||||
### 2. Align the Persistence Layer
|
||||
|
||||
- Update SQLModel definitions to match the final V4 schema.
|
||||
- Add `person_role` and `document_type` support.
|
||||
- Replace legacy document-type storage with `document_type_id`.
|
||||
- Apply the accepted manual exception strategy for `document_type` and `document_person` data.
|
||||
- Preserve all other data structures non-destructively.
|
||||
|
||||
### 3. Update Services and Write Semantics
|
||||
|
||||
- Organize service ownership around Documents, Sources, Jobs, and People.
|
||||
- Centralize Source extension and MIME policy in the Sources service.
|
||||
- Treat upload as an interface action and remove it from domain service naming where compatibility permits.
|
||||
- Implement set-based synchronization for document-person updates.
|
||||
- Implement deterministic uniqueness and relationship-write conflict checks.
|
||||
- Remove suggestion-related service behavior.
|
||||
- Add document-type resolution and validation by UUID.
|
||||
|
||||
### 4. Update API Contracts
|
||||
|
||||
- Keep API evolution additive.
|
||||
- Add role-aware relationship retrieval and write behavior.
|
||||
- Add document-type catalog retrieval and UUID-based selection for document writes.
|
||||
- Remove suggestion-related API surfaces from the V4 target state.
|
||||
|
||||
### 5. Update UI Workflows
|
||||
|
||||
- Replace single-person link editing with grouped multi-role editing.
|
||||
- Render grouped role links on document and person detail views.
|
||||
- Replace free-text document type entry with registry-backed selection.
|
||||
- Preserve clear validation and conflict messaging.
|
||||
|
||||
### 6. Verification and Hardening
|
||||
|
||||
- Add or update service tests for many-per-role behavior, uniqueness conflict handling, and set-based sync correctness.
|
||||
- Add API tests for relationship behavior and document-type selection.
|
||||
- Add UI tests or walkthrough coverage for grouped roles and type selection.
|
||||
- Add regression coverage for delete and cleanup semantics.
|
||||
- Enforce backup-first test execution for AI-run unit tests: backup `./data` before tests, then always prompt for restore after successful tests.
|
||||
- Keep restore confirmation-gated by default so code and test outcomes can be reviewed before data is reverted.
|
||||
|
||||
## Done When
|
||||
|
||||
- Core V4 documents and code paths agree on the final project definition.
|
||||
- Relationship-role writes are deterministic and non-destructive.
|
||||
- Relationship-write conflict rules are enforced consistently.
|
||||
- Document type selection is registry-backed.
|
||||
- The accepted manual exceptions for `document_type` and `document_person` are completed.
|
||||
- The focused test coverage passes.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Suggested/asserted relationship state.
|
||||
- Suggestion review or extraction workflows.
|
||||
- Global person entity-resolution engine.
|
||||
- Automated semantic document-type classification.
|
||||
|
||||
## Delivery Order Recommendation
|
||||
|
||||
1. Freeze scope boundary and implementation plan.
|
||||
2. Freeze core V4 documents.
|
||||
3. Align persistence models.
|
||||
4. Align services and API behavior.
|
||||
5. Align UI behavior.
|
||||
6. Run focused verification and regression checks.
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [V4 Scope Boundary](scope_boundary_v4.md)
|
||||
- [System Overview](index_v4.md)
|
||||
- [System Requirements](requirements_v4.md)
|
||||
- [Data Model](schema_v4.md)
|
||||
- [System Architecture](architecture_v4.md)
|
||||
- [Error Handling Policy](error_handling_v4.md)
|
||||
@@ -0,0 +1,30 @@
|
||||
# Document Transcription System Overview (Version 4)
|
||||
|
||||
Version 4 is the architecture baseline for the personal-scale application used to transcribe, organize, and preserve historical documents, source images, and related people records.
|
||||
|
||||
## Recommended Reading Order
|
||||
|
||||
1. [System Architecture](architecture_v4.md) for capabilities, technical stack, runtime structure, workflows, and component ownership.
|
||||
2. [System Requirements](requirements_v4.md) for the verifiable V4 contract.
|
||||
3. [Data Model](schema_v4.md) for entities, relationships, constraints, and persistence rules.
|
||||
4. [Error Handling Policy](error_handling_v4.md) for the V4 taxonomy and boundary contracts.
|
||||
|
||||
## Cross-Version Invariants
|
||||
|
||||
- [Historical Document Transcription Design Intent](../invariant/intent.md)
|
||||
- [Transcription Methodology](../invariant/transcription_methodology.md)
|
||||
- [Error Handling](../invariant/error_handling.md)
|
||||
- [Digital Evidence and AI Processing Provenance](../invariant/ai_evidence_and_provenance.md)
|
||||
- [UI Style Guide](../invariant/ui_style_guide.md)
|
||||
|
||||
## V4 Transition Documents
|
||||
|
||||
- [Scope Boundary](scope_boundary_v4.md)
|
||||
- [Implementation Plan](implementation_plan_v4.md)
|
||||
|
||||
## Incremental Revisions
|
||||
|
||||
- [V4.1 Scope](../ver4.1/scope_boundary_v4_1.md) and [Implementation Plan](../ver4.1/implementation_plan_v4_1.md)
|
||||
- [V4.2 Evidence and Provenance Scope](../ver4.2/scope_boundary_v4_2.md) and [Implementation Plan](../ver4.2/implementation_plan_v4_2.md)
|
||||
- [V4.3 Settings Scope](../ver4.3/scope_boundary_v4_3.md) and [Implementation Plan](../ver4.3/implementation_plan_v4_3.md)
|
||||
- [V4.4 Semantic Registries, Linked People, and Printing Scope](../ver4.4/scope_boundary_v4_4.md) and [Implementation Plan](../ver4.4/implementation_plan_v4_4.md)
|
||||
@@ -0,0 +1,51 @@
|
||||
# Document Transcription System Requirements (Version 4)
|
||||
|
||||
This document defines the baseline requirements for the document transcription system.
|
||||
|
||||
## Requirements Model
|
||||
|
||||
| ID | Category | Requirement | Verify Method |
|
||||
| --- | --- | --- | --- |
|
||||
| REQ-0 | System | Provide end-to-end multi-page document transcription with persistent, inspectable async job states. | demonstration |
|
||||
| REQ-1 | Functional | Allow users to upload one or more images as ordered `Source` pages under a `Document`. | test |
|
||||
| REQ-2 | Functional | Process page transcription asynchronously using an `asyncio` worker pool bounded by rate limits. | test |
|
||||
| REQ-3 | Functional | Persist submission-time request provenance and accurately labeled page-level SDK evidence; V4.2 adds exact OpenRouter-boundary transport evidence for new attempts. | test |
|
||||
| REQ-4 | Functional | Support job states `queued`, `processing`, `completed`, `partial_success`, and `failed`, plus page states `pending`, `transcribed`, and `failed`. | inspection |
|
||||
| REQ-5 | Functional | Allow users to manage historical `Person` records and link multiple people per role to a `Document`. | test |
|
||||
| REQ-6 | Functional | Support an extensible role taxonomy for document-person relationships. | inspection |
|
||||
| REQ-7 | Policy Constraint | Enforce deterministic relationship-role writes with uniqueness on `(document_id, person_id, role_id)` and explicit conflict responses for invalid duplicate link attempts. | test |
|
||||
| REQ-8 | Functional | Use set-based synchronization for document-person mutations so updates add and remove only the intended links. | test |
|
||||
| REQ-9 | Functional | Maintain immutable machine output on `Source.raw_transcription` while permitting inline human edits on `Source.revised_text`. | test |
|
||||
| REQ-10 | Functional | Support a UUID-identified `DocumentType` taxonomy with unique user-facing labels and active/inactive lifecycle control. | test |
|
||||
| REQ-11 | Data Constraint | Store `Document` type as a controlled reference to `DocumentType`. | test |
|
||||
| REQ-12 | Interface | Render multi-page transcriptions sequentially by `page_number` with document, people, and document-type metadata. | demonstration |
|
||||
| REQ-13 | Interface | Document create/edit UI must support selecting multiple people per role and selecting an active document type from the registry. | demonstration |
|
||||
| REQ-14 | API Constraint | Expose additive, role-aware retrieval and write behavior for document-person links and UUID-based selection for document types. | test |
|
||||
| REQ-15 | Data Constraint | Calculate and store cryptographic file hashes (SHA-256) and file sizes for uploaded source images. | test |
|
||||
| REQ-16 | Data Constraint | Preserve a portable relational model across supported backends using SQLModel, SQLAlchemy, SQLite, and PostgreSQL. | inspection |
|
||||
| REQ-17 | Reliability | Ensure delete and update flows for documents, people, and relationship links remain deterministic and safe. | test |
|
||||
| REQ-18 | Operations Constraint | Keep canonical development, testing, restore, and recovery workflows OS-independent; for AI-run unit tests, require a pre-test backup of `./data` and an always-shown post-success confirmation prompt before any restore action. | inspection |
|
||||
| REQ-19 | Quality | Provide automated coverage for async transcription workflows, relationship-role enforcement, document-type selection, and regression behavior. | test |
|
||||
|
||||
## Clarifying Constraints
|
||||
|
||||
1. `DocumentType.id` is its sole identity; labels are unique ignoring case and surrounding whitespace.
|
||||
2. `PersonRole.code` is a stable machine identifier; `PersonRole.label` may evolve.
|
||||
3. Relationship-write policy and conflict handling must be consistent across UI, API, services, and persistence.
|
||||
4. Many-per-role behavior is required for document-person links.
|
||||
5. Relationship conflicts must fail deterministically without partial mutation.
|
||||
|
||||
## Element Satisfaction Mapping
|
||||
|
||||
- UI (NiceGUI): Satisfies REQ-0, REQ-1, REQ-5, REQ-9, REQ-12, REQ-13.
|
||||
- API (FastAPI): Satisfies REQ-1, REQ-4, REQ-5, REQ-7, REQ-8, REQ-14.
|
||||
- Worker (`asyncio`): Satisfies REQ-2, REQ-3, REQ-4.
|
||||
- Persistence (SQLModel / SQLAlchemy): Satisfies REQ-3, REQ-9, REQ-10, REQ-11, REQ-15, REQ-16, REQ-17.
|
||||
- Test Suite: Verifies all test-marked requirements and satisfies REQ-19.
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v4.md)
|
||||
- [System Architecture](architecture_v4.md)
|
||||
- [Data Model](schema_v4.md)
|
||||
- [Error Handling Policy](error_handling_v4.md)
|
||||
@@ -0,0 +1,236 @@
|
||||
# Database Schema (Version 4)
|
||||
|
||||
This document defines the relational schema for the document transcription system.
|
||||
|
||||
## Entity Relationship Diagram
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
DOCUMENT_TYPE {
|
||||
UUID id PK
|
||||
TEXT label
|
||||
TEXT normalized_label
|
||||
BOOLEAN is_active
|
||||
TIMESTAMPTZ created_at
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
PERSON_ROLE {
|
||||
UUID id PK
|
||||
TEXT code
|
||||
TEXT label
|
||||
BOOLEAN is_active
|
||||
TIMESTAMPTZ created_at
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
PERSON {
|
||||
UUID id PK
|
||||
TEXT full_name
|
||||
TEXT display_name
|
||||
TEXT maiden_name
|
||||
DATE birth_date
|
||||
TEXT birth_date_raw
|
||||
TEXT birth_place
|
||||
DATE death_date
|
||||
TEXT death_date_raw
|
||||
TEXT death_place
|
||||
TEXT biography
|
||||
TEXT portrait_path
|
||||
JSONB metadata
|
||||
TIMESTAMPTZ created_at
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
DOCUMENT {
|
||||
UUID id PK
|
||||
UUID document_type_id FK
|
||||
TEXT name
|
||||
DATE document_date
|
||||
TEXT document_date_raw
|
||||
TEXT location_created
|
||||
TEXT notes
|
||||
TEXT archive_identifier
|
||||
TIMESTAMPTZ created_at
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
DOCUMENT_PERSON {
|
||||
UUID id PK
|
||||
UUID document_id FK
|
||||
UUID person_id FK
|
||||
UUID role_id FK
|
||||
TIMESTAMPTZ created_at
|
||||
TIMESTAMPTZ updated_at
|
||||
}
|
||||
|
||||
JOB {
|
||||
UUID id PK
|
||||
UUID document_id FK
|
||||
VARCHAR status
|
||||
INTEGER retry_count
|
||||
TEXT provider
|
||||
TEXT model
|
||||
TEXT prompt_name
|
||||
TEXT prompt_hash
|
||||
TEXT system_prompt
|
||||
TEXT user_prompt
|
||||
FLOAT temperature
|
||||
FLOAT top_p
|
||||
TIMESTAMPTZ date_created
|
||||
TIMESTAMPTZ date_updated
|
||||
}
|
||||
|
||||
SOURCE {
|
||||
UUID id PK
|
||||
UUID document_id FK
|
||||
INTEGER page_number
|
||||
TEXT upload_name
|
||||
TEXT filename
|
||||
TEXT file_path
|
||||
TEXT file_hash
|
||||
BIGINT file_size_bytes
|
||||
TEXT raw_transcription
|
||||
TEXT revised_text
|
||||
TIMESTAMPTZ date_uploaded
|
||||
TIMESTAMPTZ date_revised
|
||||
}
|
||||
|
||||
JOB_SOURCE {
|
||||
UUID id PK
|
||||
UUID job_id FK
|
||||
UUID source_id FK
|
||||
VARCHAR status
|
||||
TEXT raw_transcription
|
||||
JSONB ai_metadata
|
||||
JSONB raw_api_response
|
||||
TEXT error_detail
|
||||
TIMESTAMPTZ executed_at
|
||||
}
|
||||
|
||||
EXECUTION_ATTEMPT {
|
||||
UUID id PK
|
||||
UUID job_source_id FK
|
||||
UUID job_id FK
|
||||
UUID source_id FK
|
||||
INTEGER attempt_number
|
||||
VARCHAR status
|
||||
JSONB request_manifest
|
||||
TEXT request_manifest_sha256
|
||||
INTEGER transport_status_code
|
||||
BINARY transport_body
|
||||
JSONB transport_safe_headers
|
||||
JSONB sdk_response_snapshot
|
||||
JSONB normalized_metadata
|
||||
JSONB software_context
|
||||
TEXT raw_transcription
|
||||
TEXT failure_phase
|
||||
TIMESTAMPTZ started_at
|
||||
TIMESTAMPTZ finished_at
|
||||
INTEGER duration_ms
|
||||
}
|
||||
|
||||
PROCESSING_ARTIFACT {
|
||||
UUID id PK
|
||||
UUID source_id FK
|
||||
UUID execution_attempt_id FK
|
||||
TEXT artifact_type
|
||||
TEXT media_type
|
||||
TEXT schema_name
|
||||
TEXT schema_version
|
||||
TEXT producer
|
||||
TEXT producer_version
|
||||
JSONB inline_payload
|
||||
TEXT external_reference
|
||||
TEXT payload_sha256
|
||||
BIGINT byte_size
|
||||
JSONB coordinate_metadata
|
||||
TIMESTAMPTZ created_at
|
||||
}
|
||||
|
||||
DOCUMENT_TYPE ||--o{ DOCUMENT : classifies
|
||||
DOCUMENT ||--o{ DOCUMENT_PERSON : has_people
|
||||
PERSON ||--o{ DOCUMENT_PERSON : appears_in
|
||||
PERSON_ROLE ||--o{ DOCUMENT_PERSON : labels
|
||||
DOCUMENT ||--o{ JOB : has_jobs
|
||||
DOCUMENT ||--o{ SOURCE : contains_pages
|
||||
JOB ||--o{ JOB_SOURCE : executes
|
||||
SOURCE ||--o{ JOB_SOURCE : processed_in
|
||||
JOB_SOURCE ||--o{ EXECUTION_ATTEMPT : projects
|
||||
SOURCE ||--o{ PROCESSING_ARTIFACT : derives
|
||||
EXECUTION_ATTEMPT ||--o{ PROCESSING_ARTIFACT : produces
|
||||
```
|
||||
|
||||
## Domain Invariants and Provenance Rules
|
||||
|
||||
### Page-Level Execution and AI Outputs
|
||||
|
||||
- Every single page execution by an AI model produces a dedicated `JOB_SOURCE` record.
|
||||
- Every `JOB` stores the frozen prompt identifier, prompt text, and hyperparameters used at submission time.
|
||||
- `JOB_SOURCE.raw_api_response` is a compatibility projection containing an SDK-serialized OpenRouter response
|
||||
snapshot. It is neither the exact HTTP body nor the native upstream-provider response.
|
||||
- Every new provider call creates an immutable `EXECUTION_ATTEMPT` containing the frozen request manifest,
|
||||
exact OpenRouter-boundary response bytes when received, safe transport metadata, SDK snapshot, normalized
|
||||
metadata, timing, and outcome.
|
||||
- `EXECUTION_ATTEMPT(job_id, source_id, attempt_number)` is unique; retries increment the persisted attempt number.
|
||||
- Historical `JOB_SOURCE` rows without an `EXECUTION_ATTEMPT` remain SDK snapshots and are explicitly labeled as
|
||||
lacking transport evidence.
|
||||
- `SOURCE.raw_transcription` caches the latest successful machine output for that page.
|
||||
|
||||
### Generic Processing Artifacts
|
||||
|
||||
- `PROCESSING_ARTIFACT` stores provider-neutral versioned derived outputs.
|
||||
- Exactly one of `inline_payload` and `external_reference` is populated.
|
||||
- Externally stored artifacts use application-managed relative references and are verified by SHA-256 and byte size.
|
||||
- Coordinate metadata declares units, origin, dimensions, and transformations when geometry is present.
|
||||
|
||||
### Image Storage and Integrity
|
||||
|
||||
- Binary images are stored on disk; `SOURCE.file_path` stores the persisted path.
|
||||
- `SOURCE.file_hash` stores a SHA-256 digest.
|
||||
- `SOURCE.file_size_bytes` stores the original file size.
|
||||
|
||||
### Page Ordering and Revisions
|
||||
|
||||
- `SOURCE.page_number` dictates page ordering within a document.
|
||||
- `SOURCE.raw_transcription` remains immutable machine output.
|
||||
- `SOURCE.revised_text` stores human edits and is the preferred display value when present.
|
||||
|
||||
### Document-Person Role Governance
|
||||
|
||||
- Documents support zero, one, or many people per relationship role.
|
||||
- Relationship roles are defined by `PERSON_ROLE` rather than hardcoded columns.
|
||||
- `DOCUMENT_PERSON` must be unique for `(document_id, person_id, role_id)`.
|
||||
- Relationship writes must be deterministic and use explicit add/remove link intent.
|
||||
|
||||
### Document Type Governance
|
||||
|
||||
- Every document type is defined by `DOCUMENT_TYPE`.
|
||||
- `DOCUMENT_TYPE.id` is the sole machine identity.
|
||||
- `DOCUMENT_TYPE.label` is mutable display text and is unique after trimming and case normalization.
|
||||
- `DOCUMENT_TYPE.normalized_label` stores the normalized uniqueness key.
|
||||
- Inactive types remain valid for historical rows but should be excluded from default selection UIs.
|
||||
|
||||
## Constraint Summary
|
||||
|
||||
- `DOCUMENT_TYPE.normalized_label` is unique.
|
||||
- `PERSON_ROLE.code` is unique.
|
||||
- `DOCUMENT_PERSON(document_id, person_id, role_id)` is unique.
|
||||
|
||||
## Indexing Guidance
|
||||
|
||||
- `document(document_type_id)`
|
||||
- `document_person(document_id)`
|
||||
- `document_person(person_id)`
|
||||
- `document_person(role_id)`
|
||||
- `source(document_id, page_number)`
|
||||
- `job(document_id, status)`
|
||||
- `job_source(job_id)`
|
||||
- `job_source(source_id)`
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [System Overview](index_v4.md)
|
||||
- [System Architecture](architecture_v4.md)
|
||||
- [System Requirements](requirements_v4.md)
|
||||
- [Error Handling Policy](error_handling_v4.md)
|
||||
@@ -0,0 +1,89 @@
|
||||
# V4 Scope Boundary
|
||||
|
||||
This document defines the scope for the transition from the current repository state to the Version 4 project definition.
|
||||
|
||||
## Purpose
|
||||
|
||||
Define what this revision includes, what it intentionally excludes, and what migration rules govern the transition work.
|
||||
|
||||
## In Scope
|
||||
|
||||
### 1. Relationship Model
|
||||
|
||||
- Extensible role taxonomy for document-person relationships.
|
||||
- Many-to-many document-person links with many people per role.
|
||||
- Set-based add/remove synchronization for document-person updates.
|
||||
|
||||
### 2. Document Type Governance
|
||||
|
||||
- Registry-driven `DocumentType` model with UUID identity, unique labels, and controlled selection.
|
||||
- Minimal rollout for the current corpus with no alias helper table.
|
||||
|
||||
### 3. UI and API Behavior
|
||||
|
||||
- Grouped role links on document and person views.
|
||||
- Multi-role relationship editing on document create/edit flows.
|
||||
- Role-aware API retrieval and write behavior.
|
||||
- Additive API evolution with explicit deprecations.
|
||||
|
||||
### 4. Verification
|
||||
|
||||
- Tests for many-per-role behavior.
|
||||
- Tests for set-based relationship mutation behavior.
|
||||
- Tests for document and person delete/link cleanup regressions.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Suggested versus asserted relationship states.
|
||||
- Suggestion storage, review, acceptance, or rejection workflows.
|
||||
- Automatic relationship extraction or recommendation features.
|
||||
- Full entity resolution or identity merge across all people.
|
||||
- Automated semantic document type classification.
|
||||
- Redesign of the core transcription execution model.
|
||||
|
||||
## Locked Design Decisions
|
||||
|
||||
### A. Role Extensibility Mechanism
|
||||
|
||||
- Use registry tables for relationship roles.
|
||||
|
||||
### B. API Compatibility Strategy
|
||||
|
||||
- Use additive API evolution.
|
||||
- In development mode, the current revision is authoritative.
|
||||
- Deprecations should be explicit and short-lived.
|
||||
|
||||
### C. Document Type Rollout Strategy
|
||||
|
||||
- Use a minimal registry rollout for the current corpus.
|
||||
- Do not introduce a `document_type_alias` helper table.
|
||||
|
||||
### D. Database Change Policy
|
||||
|
||||
- Future schema changes are non-destructive by default.
|
||||
- Exception: `document_type` text may be replaced by `document_type_id` without migrating the legacy text values.
|
||||
- Exception: `document_person` links may be recreated manually.
|
||||
|
||||
## Compatibility and Rollout
|
||||
|
||||
- Preserve existing repository behavior where unaffected by the V4 scope.
|
||||
- Treat scope boundary and implementation plan as the only transition documents.
|
||||
- Treat core V4 documents as the authoritative project definition once rewritten.
|
||||
|
||||
## Exit Criteria for Scope Freeze
|
||||
|
||||
V4 scope is considered frozen when:
|
||||
|
||||
- Relationship model and document-type governance are approved.
|
||||
- Relationship model and document-type governance are approved.
|
||||
- Additive API change list and deprecation schedule are approved.
|
||||
- Migration exceptions are explicitly acknowledged.
|
||||
|
||||
## Core V4 Documents
|
||||
|
||||
1. `docs/ver4/index_v4.md`
|
||||
2. `docs/ver4/requirements_v4.md`
|
||||
3. `docs/ver4/schema_v4.md`
|
||||
4. `docs/ver4/architecture_v4.md`
|
||||
5. `docs/ver4/error_handling_v4.md`
|
||||
6. `docs/ver4/implementation_plan_v4.md`
|
||||
@@ -5,9 +5,11 @@ This directory stores transcription prompts as individual Markdown artifacts.
|
||||
## Conventions
|
||||
- Keep one prompt per file.
|
||||
- Use stable, descriptive snake_case file names.
|
||||
- Store prompt files directly in this directory; nested paths are rejected.
|
||||
- Prefer incremental edits to a single prompt per change for clean history.
|
||||
- Keep prompts human-readable and policy-focused.
|
||||
- Do not store secrets in prompt files.
|
||||
- Runtime jobs snapshot prompt text, SHA-256 provenance, and sampling configuration.
|
||||
|
||||
## Current Prompt
|
||||
- `transcribe_document.md`: baseline verbatim transcription policy for historical documents.
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
You are an assistant that may call tools.
|
||||
|
||||
Tool safety rules:
|
||||
1) Tool arguments MUST be strict JSON matching the schema exactly.
|
||||
2) Never place disallowed, sensitive, explicit, or policy-violating text directly into tool arguments.
|
||||
3) If user content may be unsafe, first produce a brief neutral summary and pass only that summary.
|
||||
4) Prefer IDs, enums, booleans, and short fields over raw free-form text.
|
||||
5) Keep all string arguments <= 300 chars unless schema says otherwise.
|
||||
6) If you cannot safely provide valid tool args, do not call the tool; respond with "NO_TOOL_CALL" and explain briefly.
|
||||
7) Never include markdown/code fences in tool arguments.
|
||||
@@ -46,6 +46,16 @@ Do not summarize. Do not paraphrase. Do not modernize style.
|
||||
- Signal location before the note text.
|
||||
- Example form: `[written in left margin: ...]`
|
||||
|
||||
### Printed and handwritten text
|
||||
- Preserve printed and handwritten text together in their original reading context.
|
||||
- On mixed documents such as completed forms, leave printed labels and instructions unmarked.
|
||||
- Wrap handwritten entries in `[handwritten: ...]`.
|
||||
- Mark handwritten signatures as `[handwritten signature: ...]`.
|
||||
- If the main body is entirely handwritten, add `[document body handwritten]` once at the beginning rather than marking every line.
|
||||
- Mark later notes or uncertain additions as `[handwritten annotation: ...]`.
|
||||
- When authorship is unclear, use `[handwritten annotation, author uncertain: ...]`.
|
||||
- Do not infer authorship, writing date, or whether different handwriting belongs to different people unless explicitly evident.
|
||||
|
||||
### Line-break hyphenation
|
||||
- Rejoin words split across line breaks when they are clearly one word.
|
||||
- Remove only line-break hyphens used for wrapping.
|
||||
@@ -70,3 +80,4 @@ Before finalizing, ensure:
|
||||
2. Uncertain/illegible areas are explicitly marked.
|
||||
3. Crossed-out and inserted text are preserved with required tags.
|
||||
4. Structure/ordering is preserved as faithfully as possible.
|
||||
5. Handwriting is identified using the mixed-text conventions without separating it from its printed context.
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
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 = parse_cli_settings()
|
||||
application = "transcription.__main__:create_cli_app" if settings.reload else create_app(settings=settings)
|
||||
uvicorn.run(
|
||||
application,
|
||||
factory=settings.reload,
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
log_level=settings.log_level,
|
||||
reload=settings.reload,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Additive V4 API routes for relationship and classification registries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import Request
|
||||
from fastapi import Response
|
||||
from pydantic import BaseModel
|
||||
from pydantic import ConfigDict
|
||||
from pydantic import Field
|
||||
from pydantic import model_validator
|
||||
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
from transcription.db.models import DocumentType
|
||||
from transcription.db.models import PersonRole
|
||||
from transcription.services import DocumentService
|
||||
from transcription.services import PeopleService
|
||||
|
||||
router = APIRouter(prefix="/api/v4", tags=["v4-documents"])
|
||||
|
||||
|
||||
class ApiModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True)
|
||||
|
||||
|
||||
class SelectorRequest(ApiModel):
|
||||
@model_validator(mode="after")
|
||||
def require_exactly_one_selector(self):
|
||||
values = (self.selector_id, self.selector_code)
|
||||
if sum(value is not None for value in values) != 1:
|
||||
raise ValueError(f"Provide exactly one of {self.selector_names[0]} or {self.selector_names[1]}")
|
||||
return self
|
||||
|
||||
@property
|
||||
def selector_id(self) -> UUID | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def selector_code(self) -> str | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def selector_names(self) -> tuple[str, str]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DocumentTypeRead(ApiModel):
|
||||
id: UUID
|
||||
label: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class PersonRoleRead(ApiModel):
|
||||
id: UUID
|
||||
code: str
|
||||
label: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class DocumentTypeWriteRequest(ApiModel):
|
||||
document_type_id: UUID
|
||||
|
||||
|
||||
class DocumentTypeWriteResponse(ApiModel):
|
||||
document_id: UUID
|
||||
document_type_id: UUID
|
||||
|
||||
|
||||
class DocumentPersonWriteRequest(ApiModel):
|
||||
person_id: UUID
|
||||
role_id: UUID | None = None
|
||||
role_code: str | None = Field(default=None, min_length=1, pattern=r"^[a-z0-9_]+$")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_conflicting_role_selectors(self):
|
||||
if self.role_id is not None and self.role_code is not None:
|
||||
raise ValueError("Provide role_id or role_code, not both")
|
||||
return self
|
||||
|
||||
|
||||
class DocumentPersonRoleUpdateRequest(SelectorRequest):
|
||||
role_id: UUID | None = None
|
||||
role_code: str | None = Field(default=None, min_length=1, pattern=r"^[a-z0-9_]+$")
|
||||
|
||||
@property
|
||||
def selector_id(self) -> UUID | None:
|
||||
return self.role_id
|
||||
|
||||
@property
|
||||
def selector_code(self) -> str | None:
|
||||
return self.role_code
|
||||
|
||||
@property
|
||||
def selector_names(self) -> tuple[str, str]:
|
||||
return "role_id", "role_code"
|
||||
|
||||
|
||||
class DocumentPersonRead(ApiModel):
|
||||
id: UUID
|
||||
document_id: UUID
|
||||
person_id: UUID
|
||||
role_id: UUID | None
|
||||
role_code: str
|
||||
person_name: str | None = None
|
||||
|
||||
|
||||
class DocumentPeopleResponse(ApiModel):
|
||||
document_id: UUID
|
||||
links: list[DocumentPersonRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _document_type_to_read(item: DocumentType) -> DocumentTypeRead:
|
||||
return DocumentTypeRead(
|
||||
id=item.id,
|
||||
label=item.label,
|
||||
is_active=item.is_active,
|
||||
)
|
||||
|
||||
|
||||
def _person_role_to_read(item: PersonRole) -> PersonRoleRead:
|
||||
return PersonRoleRead(
|
||||
id=item.id,
|
||||
code=item.code,
|
||||
label=item.label,
|
||||
is_active=item.is_active,
|
||||
)
|
||||
|
||||
|
||||
def _document_person_to_read(item: DocumentPerson) -> DocumentPersonRead:
|
||||
role_code = item.role_ref.code if item.role_ref is not None else str(item.role)
|
||||
|
||||
person_name = item.person.full_name if item.person is not None else None
|
||||
return DocumentPersonRead(
|
||||
id=item.id,
|
||||
document_id=item.document_id,
|
||||
person_id=item.person_id,
|
||||
role_id=item.role_id,
|
||||
role_code=role_code,
|
||||
person_name=person_name,
|
||||
)
|
||||
|
||||
|
||||
def _document_to_type_response(item: Document) -> DocumentTypeWriteResponse:
|
||||
if item.document_type_id is None:
|
||||
raise ValueError("Document Type assignment did not persist")
|
||||
return DocumentTypeWriteResponse(
|
||||
document_id=item.id,
|
||||
document_type_id=item.document_type_id,
|
||||
)
|
||||
|
||||
|
||||
def get_document_service(request: Request) -> DocumentService:
|
||||
"""Resolve the document service from app lifespan state when available."""
|
||||
services = getattr(request.app.state, "services", None)
|
||||
if services is not None:
|
||||
return services.documents
|
||||
return DocumentService()
|
||||
|
||||
|
||||
def get_people_service(request: Request) -> PeopleService:
|
||||
"""Resolve the People service from app lifespan state when available."""
|
||||
services = getattr(request.app.state, "services", None)
|
||||
if services is not None:
|
||||
return services.people
|
||||
return PeopleService()
|
||||
|
||||
|
||||
DocumentServiceDependency = Annotated[DocumentService, Depends(get_document_service)]
|
||||
PeopleServiceDependency = Annotated[PeopleService, Depends(get_people_service)]
|
||||
|
||||
|
||||
@router.get("/document-types", response_model=list[DocumentTypeRead])
|
||||
async def list_document_types(
|
||||
service: DocumentServiceDependency,
|
||||
active_only: bool = True,
|
||||
) -> list[DocumentTypeRead]:
|
||||
items = await service.list_document_types(active_only=active_only)
|
||||
return [_document_type_to_read(item) for item in items]
|
||||
|
||||
|
||||
@router.get("/person-roles", response_model=list[PersonRoleRead])
|
||||
async def list_person_roles(
|
||||
service: PeopleServiceDependency,
|
||||
active_only: bool = True,
|
||||
) -> list[PersonRoleRead]:
|
||||
items = await service.list_person_roles(active_only=active_only)
|
||||
return [_person_role_to_read(item) for item in items]
|
||||
|
||||
|
||||
@router.put("/documents/{document_id}/type", response_model=DocumentTypeWriteResponse)
|
||||
async def set_document_type(
|
||||
document_id: UUID,
|
||||
payload: DocumentTypeWriteRequest,
|
||||
service: DocumentServiceDependency,
|
||||
) -> DocumentTypeWriteResponse:
|
||||
document = await service.set_document_type(
|
||||
document_id=document_id,
|
||||
document_type_id=payload.document_type_id,
|
||||
)
|
||||
return _document_to_type_response(document)
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}/people", response_model=DocumentPeopleResponse)
|
||||
async def list_document_people(
|
||||
document_id: UUID,
|
||||
service: PeopleServiceDependency,
|
||||
) -> DocumentPeopleResponse:
|
||||
links = await service.list_document_people(document_id=document_id)
|
||||
return DocumentPeopleResponse(document_id=document_id, links=[_document_person_to_read(item) for item in links])
|
||||
|
||||
|
||||
@router.post("/documents/{document_id}/people", response_model=DocumentPersonRead)
|
||||
async def add_document_person_link(
|
||||
document_id: UUID,
|
||||
payload: DocumentPersonWriteRequest,
|
||||
service: PeopleServiceDependency,
|
||||
) -> DocumentPersonRead:
|
||||
link = await service.add_document_person_link(
|
||||
document_id=document_id,
|
||||
person_id=payload.person_id,
|
||||
role_id=payload.role_id,
|
||||
role_code=payload.role_code,
|
||||
)
|
||||
return _document_person_to_read(link)
|
||||
|
||||
|
||||
@router.patch("/document-people/{document_person_id}", response_model=DocumentPersonRead)
|
||||
async def set_document_person_role(
|
||||
document_person_id: UUID,
|
||||
payload: DocumentPersonRoleUpdateRequest,
|
||||
service: PeopleServiceDependency,
|
||||
) -> DocumentPersonRead:
|
||||
link = await service.set_document_person_role(
|
||||
document_person_id=document_person_id,
|
||||
role_id=payload.role_id,
|
||||
role_code=payload.role_code,
|
||||
)
|
||||
return _document_person_to_read(link)
|
||||
|
||||
|
||||
@router.delete("/document-people/{document_person_id}", status_code=204)
|
||||
async def delete_document_person_link(
|
||||
document_person_id: UUID,
|
||||
service: PeopleServiceDependency,
|
||||
) -> Response:
|
||||
await service.remove_document_person_link(document_person_id=document_person_id)
|
||||
return Response(status_code=204)
|
||||
+21
-10
@@ -16,13 +16,18 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .api.errors import register_error_handlers
|
||||
from .api.health import router as health_router
|
||||
from .api.v4_documents import router as v4_documents_router
|
||||
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 .services import ServiceBundle
|
||||
from .services.documents import DocumentService
|
||||
from .services.jobs import JobService
|
||||
from .services.people import PeopleService
|
||||
from .services.sources import SourceService
|
||||
from .ui import register_pages
|
||||
from .worker import worker_consumer_lifespan
|
||||
|
||||
@@ -31,12 +36,17 @@ 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)
|
||||
session_factory = app.state.runtime.session_factory
|
||||
app.state.services = ServiceBundle(
|
||||
documents=DocumentService(session_factory=session_factory, settings=settings),
|
||||
sources=SourceService(session_factory=session_factory, settings=settings),
|
||||
jobs=JobService(session_factory=session_factory, settings=settings),
|
||||
people=PeopleService(session_factory=session_factory, settings=settings),
|
||||
)
|
||||
|
||||
if settings.should_bootstrap_schema:
|
||||
await create_all(engine=app.state.runtime.engine)
|
||||
@@ -73,26 +83,27 @@ async def _recover_stale_processing_jobs(app: FastAPI) -> None:
|
||||
logger.warning("Recovered %s stale processing job(s) at startup", recovered)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
||||
settings = get_settings()
|
||||
app.state.settings = settings
|
||||
active_settings = settings or get_settings()
|
||||
app.state.settings = active_settings
|
||||
app.mount(
|
||||
"/uploads",
|
||||
StaticFiles(directory=settings.upload_dir, check_dir=False),
|
||||
StaticFiles(directory=active_settings.upload_dir, check_dir=False),
|
||||
name="uploads",
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root_redirect() -> RedirectResponse:
|
||||
return RedirectResponse(url="/ui", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||
return RedirectResponse(url="/ui/homepage", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||
|
||||
@app.get("/ui", include_in_schema=False)
|
||||
async def ui_redirect() -> RedirectResponse:
|
||||
return RedirectResponse(url="/ui/upload", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||
return RedirectResponse(url="/ui/homepage", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||
|
||||
register_error_handlers(app)
|
||||
register_pages(app)
|
||||
app.include_router(health_router)
|
||||
app.include_router(v4_documents_router)
|
||||
register_pages(app)
|
||||
return app
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from transcription.db.runtime import DatabaseRuntime
|
||||
from transcription.db.runtime import get_session_factory
|
||||
from transcription.db.session import get_session_factory
|
||||
from transcription.worker import WorkerNotifier
|
||||
from transcription.worker import resolve_worker_notifier
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Private-corpus benchmark contracts and deterministic text scoring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import ConfigDict
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
class BenchmarkModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class BenchmarkItem(BenchmarkModel):
|
||||
"""One private benchmark item referenced by archival identity."""
|
||||
|
||||
source_id: UUID
|
||||
source_digest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
categories: frozenset[str] = Field(min_length=1)
|
||||
reference_transcription: str = Field(min_length=1)
|
||||
|
||||
|
||||
class BenchmarkManifest(BenchmarkModel):
|
||||
"""Versioned private benchmark definition without copied source media."""
|
||||
|
||||
schema_name: str = "transcription.private-benchmark"
|
||||
schema_version: str = "1"
|
||||
name: str = Field(min_length=1)
|
||||
items: tuple[BenchmarkItem, ...] = Field(min_length=1)
|
||||
|
||||
|
||||
class EditorialAssessment(BenchmarkModel):
|
||||
"""Manually reviewed errors not represented adequately by CER or WER."""
|
||||
|
||||
omissions: int = Field(default=0, ge=0)
|
||||
inventions: int = Field(default=0, ge=0)
|
||||
silent_normalizations: int = Field(default=0, ge=0)
|
||||
uncertainty_errors: int = Field(default=0, ge=0)
|
||||
layout_errors: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class BenchmarkScore(BenchmarkModel):
|
||||
"""Measured score for one preserved execution attempt."""
|
||||
|
||||
execution_attempt_id: UUID
|
||||
character_error_rate: float = Field(ge=0)
|
||||
word_error_rate: float = Field(ge=0)
|
||||
character_edits: int = Field(ge=0)
|
||||
word_edits: int = Field(ge=0)
|
||||
reference_characters: int = Field(ge=0)
|
||||
reference_words: int = Field(ge=0)
|
||||
assessment: EditorialAssessment
|
||||
latency_ms: int = Field(ge=0)
|
||||
cost_usd: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
def score_transcription(
|
||||
*,
|
||||
execution_attempt_id: UUID,
|
||||
reference: str,
|
||||
candidate: str,
|
||||
assessment: EditorialAssessment,
|
||||
latency_ms: int,
|
||||
cost_usd: float | None = None,
|
||||
) -> BenchmarkScore:
|
||||
"""Score literal text without case-folding or silent normalization."""
|
||||
reference_words = reference.split()
|
||||
candidate_words = candidate.split()
|
||||
character_edits = _levenshtein(list(reference), list(candidate))
|
||||
word_edits = _levenshtein(reference_words, candidate_words)
|
||||
return BenchmarkScore(
|
||||
execution_attempt_id=execution_attempt_id,
|
||||
character_error_rate=character_edits / max(1, len(reference)),
|
||||
word_error_rate=word_edits / max(1, len(reference_words)),
|
||||
character_edits=character_edits,
|
||||
word_edits=word_edits,
|
||||
reference_characters=len(reference),
|
||||
reference_words=len(reference_words),
|
||||
assessment=assessment,
|
||||
latency_ms=latency_ms,
|
||||
cost_usd=cost_usd,
|
||||
)
|
||||
|
||||
|
||||
def _levenshtein(reference: list[str], candidate: list[str]) -> int:
|
||||
if len(reference) < len(candidate):
|
||||
reference, candidate = candidate, reference
|
||||
previous = list(range(len(candidate) + 1))
|
||||
for reference_index, reference_value in enumerate(reference, start=1):
|
||||
current = [reference_index]
|
||||
for candidate_index, candidate_value in enumerate(candidate, start=1):
|
||||
current.append(
|
||||
min(
|
||||
current[-1] + 1,
|
||||
previous[candidate_index] + 1,
|
||||
previous[candidate_index - 1] + (reference_value != candidate_value),
|
||||
)
|
||||
)
|
||||
previous = current
|
||||
return previous[-1]
|
||||
+76
-20
@@ -6,12 +6,19 @@ are resolved by the provider adapters, not here.
|
||||
"""
|
||||
|
||||
import logging.config
|
||||
from contextvars import ContextVar
|
||||
from collections.abc import Sequence
|
||||
from enum import StrEnum
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import ConfigDict
|
||||
from pydantic import Field
|
||||
from pydantic import SecretStr
|
||||
from pydantic import StringConstraints
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic_settings import SettingsConfigDict
|
||||
|
||||
@@ -22,35 +29,80 @@ class Provider(StrEnum):
|
||||
OPENROUTER = "openrouter"
|
||||
|
||||
|
||||
NonEmptyStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
PromptFilename = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, pattern=r"^[^/\\]+$")]
|
||||
Probability = Annotated[float, Field(ge=0.0, le=1.0)]
|
||||
Temperature = Annotated[float, Field(ge=0.0, le=2.0)]
|
||||
|
||||
|
||||
class SqliteSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
driver: Literal["sqlite"] = "sqlite"
|
||||
path: NonEmptyStr = "app.db"
|
||||
|
||||
|
||||
class PostgresSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
driver: Literal["postgres"] = "postgres"
|
||||
host: NonEmptyStr
|
||||
port: int = Field(default=5432, ge=1, le=65535)
|
||||
database: NonEmptyStr
|
||||
user: NonEmptyStr
|
||||
password: SecretStr
|
||||
|
||||
|
||||
DatabaseSettings = Annotated[
|
||||
SqliteSettings | PostgresSettings,
|
||||
Field(discriminator="driver"),
|
||||
]
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
env_nested_delimiter="__",
|
||||
cli_implicit_flags=True,
|
||||
cli_kebab_case=True,
|
||||
frozen=True,
|
||||
)
|
||||
|
||||
# --- NiceGUI Server ---
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
log_level: Literal["critical", "error", "warning", "info", "debug", "trace"] = "info"
|
||||
reload: bool = False
|
||||
|
||||
# --- AI provider ---
|
||||
provider: Provider = Provider.OPENROUTER
|
||||
openrouter_api_key: str
|
||||
provider_model: str | None = None
|
||||
openrouter_http_referer: str | None = None
|
||||
openrouter_app_title: str | None = None
|
||||
openrouter_api_key: SecretStr
|
||||
provider_model: NonEmptyStr | None = None
|
||||
openrouter_http_referer: NonEmptyStr | None = None
|
||||
openrouter_app_title: NonEmptyStr | None = None
|
||||
default_prompt_name: PromptFilename = "transcribe_document.md"
|
||||
transcription_temperature: Temperature | None = None
|
||||
transcription_top_p: Probability | None = None
|
||||
|
||||
# --- runtime environment ---
|
||||
environment: Literal["development", "test", "production"] = "development"
|
||||
|
||||
# --- persistence ---
|
||||
database_url: str = "sqlite:///./transcription.db"
|
||||
bootstrap_schema_on_startup: bool | None = None
|
||||
database: DatabaseSettings = Field(default_factory=SqliteSettings)
|
||||
bootstrap_schema_on_startup: bool = False
|
||||
sqlite_check_same_thread: bool = False
|
||||
|
||||
# --- filesystem paths ---
|
||||
upload_dir: Path = Path("./uploads")
|
||||
prompt_dir: Path = Path("./prompts")
|
||||
artifact_dir: Path = Path("./data/artifacts")
|
||||
artifact_inline_threshold_bytes: int = Field(default=1_048_576, ge=1)
|
||||
|
||||
# --- worker reliability ---
|
||||
worker_max_retries: int = 0
|
||||
worker_retry_backoff_seconds: float = 0.0
|
||||
worker_max_retries: int = Field(default=0, ge=0)
|
||||
worker_retry_backoff_seconds: float = Field(default=0.0, ge=0.0)
|
||||
worker_provider_timeout_seconds: float = Field(default=20.0, gt=0.0, le=20.0)
|
||||
worker_min_transcription_chars: int = Field(default=0, ge=0)
|
||||
worker_min_transcription_lines: int = Field(default=0, ge=0)
|
||||
@@ -59,23 +111,24 @@ 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"}
|
||||
|
||||
|
||||
_settings: ContextVar[Settings | None] = ContextVar("settings", default=None)
|
||||
@cache
|
||||
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 get_settings(**kwargs) -> Settings:
|
||||
settings = _settings.get()
|
||||
if settings is None:
|
||||
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
_settings.set(settings)
|
||||
return settings
|
||||
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, object] = {
|
||||
LOGGING_CONFIG: dict[str, Any] = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
@@ -105,7 +158,10 @@ LOGGING_CONFIG: dict[str, object] = {
|
||||
}
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
def configure_logging(settings: Settings | None = None) -> None:
|
||||
"""Configure root logging once at startup."""
|
||||
logging.config.dictConfig(LOGGING_CONFIG)
|
||||
cfg = LOGGING_CONFIG.copy()
|
||||
active_settings = settings or get_settings()
|
||||
cfg["loggers"]["transcription"]["level"] = active_settings.log_level.upper()
|
||||
logging.config.dictConfig(cfg)
|
||||
logger.debug("Logging configured")
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
from .operations import create_all
|
||||
from .operations import upgrade_schema
|
||||
from .runtime import dispose_database_runtime
|
||||
from .runtime import get_session
|
||||
from .runtime import initialize_database_runtime
|
||||
from .session import session_scope
|
||||
from .session import transaction_scope
|
||||
|
||||
__all__ = ["create_all", "dispose_database_runtime", "get_session", "initialize_database_runtime"]
|
||||
__all__ = [
|
||||
"create_all",
|
||||
"dispose_database_runtime",
|
||||
"initialize_database_runtime",
|
||||
"session_scope",
|
||||
"transaction_scope",
|
||||
"upgrade_schema",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
from functools import cache
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import URL
|
||||
from sqlalchemy import StaticPool
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from ..config import PostgresSettings
|
||||
from ..config import Settings
|
||||
from ..config import SqliteSettings
|
||||
from ..config import get_settings
|
||||
|
||||
|
||||
def get_database_url(settings: Settings) -> str:
|
||||
match settings.database:
|
||||
case SqliteSettings(path=path):
|
||||
url = URL.create(
|
||||
drivername="sqlite+aiosqlite",
|
||||
database=path,
|
||||
)
|
||||
case PostgresSettings() as database:
|
||||
url = URL.create(
|
||||
drivername="postgresql+asyncpg",
|
||||
host=database.host,
|
||||
port=database.port,
|
||||
database=database.database,
|
||||
username=database.user,
|
||||
password=database.password.get_secret_value(),
|
||||
)
|
||||
return url.render_as_string(hide_password=False)
|
||||
|
||||
|
||||
def resolve_engine(settings: Settings | None = None) -> AsyncEngine:
|
||||
active_settings = settings or get_settings()
|
||||
return get_engine(get_database_url(active_settings))
|
||||
|
||||
|
||||
@cache
|
||||
def get_engine(database_url: str) -> AsyncEngine:
|
||||
kwargs: dict[str, Any] = {"echo": False, "pool_pre_ping": True}
|
||||
if database_url.startswith("sqlite"):
|
||||
kwargs["connect_args"] = {"check_same_thread": False}
|
||||
if ":memory:" in database_url:
|
||||
kwargs["poolclass"] = StaticPool
|
||||
|
||||
return create_async_engine(database_url, **kwargs)
|
||||
|
||||
|
||||
async def dispose_engine(database_url: str) -> None:
|
||||
engine = get_engine(database_url)
|
||||
try:
|
||||
await engine.dispose()
|
||||
finally:
|
||||
get_engine.cache_clear()
|
||||
|
||||
|
||||
async def refresh_engine(database_url: str) -> AsyncEngine:
|
||||
await dispose_engine(database_url)
|
||||
return get_engine(database_url)
|
||||
@@ -0,0 +1,406 @@
|
||||
"""SQLModel domain models for the V3 transcription system."""
|
||||
|
||||
from datetime import UTC
|
||||
from datetime import date
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import JsonValue
|
||||
from sqlalchemy import JSON
|
||||
from sqlalchemy import BigInteger
|
||||
from sqlalchemy import CheckConstraint
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy import LargeBinary
|
||||
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(none_as_null=True)
|
||||
|
||||
def load_dialect_impl(self, dialect):
|
||||
if dialect.name == "postgresql":
|
||||
return dialect.type_descriptor(JSONB(none_as_null=True))
|
||||
return dialect.type_descriptor(JSON(none_as_null=True))
|
||||
|
||||
|
||||
class JobStatus(StrEnum):
|
||||
QUEUED = "queued"
|
||||
PROCESSING = "processing"
|
||||
TRANSCRIBED = "transcribed"
|
||||
COMPLETED = "completed"
|
||||
PARTIAL_SUCCESS = "partial_success"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class DocumentPersonRole(StrEnum):
|
||||
AUTHOR = "author"
|
||||
RECIPIENT = "recipient"
|
||||
MENTIONED = "mentioned"
|
||||
|
||||
|
||||
class JobSourceStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
TRANSCRIBED = "transcribed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class DocumentType(SQLModel, table=True):
|
||||
"""Registry of allowed document types."""
|
||||
|
||||
__tablename__ = "document_type"
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
label: str
|
||||
normalized_label: str = Field(index=True, unique=True)
|
||||
is_active: bool = True
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
documents: list["Document"] = Relationship(
|
||||
back_populates="document_type_ref", sa_relationship_kwargs={"lazy": "selectin"}
|
||||
)
|
||||
|
||||
|
||||
class PersonRole(SQLModel, table=True):
|
||||
"""Registry of allowed document-person relationship roles."""
|
||||
|
||||
__tablename__ = "person_role"
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
code: str = Field(index=True, unique=True)
|
||||
label: str
|
||||
is_active: bool = 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="role_ref", sa_relationship_kwargs={"lazy": "selectin"}
|
||||
)
|
||||
|
||||
|
||||
class Document(SQLModel, table=True):
|
||||
"""An historical document."""
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
name: str
|
||||
document_type_id: UUID | None = Field(default=None, foreign_key="document_type.id")
|
||||
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))
|
||||
|
||||
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"}
|
||||
)
|
||||
document_type_ref: Optional["DocumentType"] = Relationship(
|
||||
back_populates="documents", sa_relationship_kwargs={"lazy": "selectin"}
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
family_search_id: str | None = Field(default=None, unique=True)
|
||||
metadata_: dict[str, JsonValue] | None = Field(
|
||||
default=None,
|
||||
sa_column=Column("metadata", JSONBCompat(), nullable=True),
|
||||
)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
document_people: list["DocumentPerson"] = Relationship(
|
||||
back_populates="person", sa_relationship_kwargs={"lazy": "selectin"}
|
||||
)
|
||||
|
||||
|
||||
class DocumentPerson(SQLModel, table=True):
|
||||
"""Associates documents with people in a given role."""
|
||||
|
||||
__tablename__ = "document_person"
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
document_id: UUID = Field(foreign_key="document.id")
|
||||
person_id: UUID = Field(foreign_key="person.id")
|
||||
role_id: UUID | None = Field(default=None, foreign_key="person_role.id")
|
||||
role: str = Field(default=DocumentPersonRole.AUTHOR.value, nullable=False)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("document_id", "person_id", "role_id", name="uq_document_person_role_id"),
|
||||
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"}
|
||||
)
|
||||
role_ref: Optional["PersonRole"] = Relationship(
|
||||
back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"}
|
||||
)
|
||||
|
||||
|
||||
class Job(SQLModel, table=True):
|
||||
"""A transcription job tied to a single document."""
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
document_id: UUID = Field(foreign_key="document.id")
|
||||
status: JobStatus = Field(
|
||||
default=JobStatus.QUEUED,
|
||||
sa_column=Column(
|
||||
SAEnum(
|
||||
JobStatus,
|
||||
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||
native_enum=False,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
retry_count: int = Field(default=0, ge=0)
|
||||
date_created: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
date_updated: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
prompt_name: str | None = None
|
||||
prompt_hash: str | None = None
|
||||
system_prompt: str | None = None
|
||||
user_prompt: str | None = None
|
||||
temperature: float | None = None
|
||||
top_p: float | None = None
|
||||
|
||||
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.job_sources:
|
||||
return "unknown"
|
||||
|
||||
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 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
|
||||
file_hash: str
|
||||
file_size_bytes: int = Field(sa_column=Column(BigInteger(), nullable=False))
|
||||
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"},
|
||||
)
|
||||
processing_artifacts: list["ProcessingArtifact"] = Relationship(
|
||||
back_populates="source",
|
||||
sa_relationship_kwargs={"lazy": "noload"},
|
||||
)
|
||||
|
||||
@property
|
||||
def latest_job_source(self) -> Optional["JobSource"]:
|
||||
"""Return the most recent job execution record for this source."""
|
||||
if not self.job_sources:
|
||||
return None
|
||||
return max(self.job_sources, key=lambda js: js.executed_at)
|
||||
|
||||
@property
|
||||
def latest_status(self) -> JobSourceStatus | None:
|
||||
"""Return the execution status of the latest job run."""
|
||||
latest = self.latest_job_source
|
||||
return latest.status if latest else None
|
||||
|
||||
@property
|
||||
def latest_error_detail(self) -> str | None:
|
||||
"""Return the error detail from the latest job run, if present."""
|
||||
latest = self.latest_job_source
|
||||
return latest.error_detail if latest else None
|
||||
|
||||
@property
|
||||
def document_name(self) -> str | None:
|
||||
"""Return the parent document name if loaded."""
|
||||
return self.document.name if self.document else None
|
||||
|
||||
|
||||
class JobSource(SQLModel, table=True):
|
||||
"""A single AI execution record for one source page."""
|
||||
|
||||
__tablename__ = "job_source"
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
job_id: UUID = Field(foreign_key="job.id")
|
||||
source_id: UUID = Field(foreign_key="source.id")
|
||||
status: JobSourceStatus = Field(
|
||||
default=JobSourceStatus.PENDING,
|
||||
sa_column=Column(
|
||||
SAEnum(
|
||||
JobSourceStatus,
|
||||
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||
native_enum=False,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
raw_transcription: str | None = None
|
||||
ai_metadata: dict[str, JsonValue] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
||||
raw_api_response: dict[str, JsonValue] | 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))
|
||||
|
||||
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"})
|
||||
execution_attempts: list["ExecutionAttempt"] = Relationship(
|
||||
back_populates="job_source",
|
||||
sa_relationship_kwargs={"lazy": "noload", "order_by": "ExecutionAttempt.attempt_number"},
|
||||
)
|
||||
|
||||
|
||||
class ExecutionAttempt(SQLModel, table=True):
|
||||
"""Immutable evidence for one provider call attempt."""
|
||||
|
||||
__tablename__ = "execution_attempt"
|
||||
__table_args__ = (UniqueConstraint("job_id", "source_id", "attempt_number", name="uq_execution_attempt_number"),)
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
job_source_id: UUID = Field(foreign_key="job_source.id", index=True)
|
||||
job_id: UUID = Field(foreign_key="job.id", index=True)
|
||||
source_id: UUID = Field(foreign_key="source.id", index=True)
|
||||
attempt_number: int = Field(ge=1)
|
||||
status: JobSourceStatus
|
||||
provider: str
|
||||
model: str | None = None
|
||||
request_manifest: dict[str, JsonValue] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
||||
request_manifest_sha256: str | None = None
|
||||
request_manifest_schema_version: str | None = None
|
||||
response_received: bool = False
|
||||
transport_status_code: int | None = None
|
||||
transport_body: bytes | None = Field(default=None, sa_column=Column(LargeBinary(), nullable=True))
|
||||
transport_content_type: str | None = None
|
||||
transport_content_encoding: str | None = None
|
||||
transport_safe_headers: dict[str, JsonValue] | None = Field(
|
||||
default=None, sa_column=Column(JSONBCompat(), nullable=True)
|
||||
)
|
||||
router_request_id: str | None = None
|
||||
router_generation_id: str | None = None
|
||||
sdk_response_snapshot: dict[str, JsonValue] | None = Field(
|
||||
default=None, sa_column=Column(JSONBCompat(), nullable=True)
|
||||
)
|
||||
normalized_metadata: dict[str, JsonValue] | None = Field(
|
||||
default=None, sa_column=Column(JSONBCompat(), nullable=True)
|
||||
)
|
||||
software_context: dict[str, JsonValue] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
||||
raw_transcription: str | None = None
|
||||
error_category: str | None = None
|
||||
error_detail: str | None = None
|
||||
failure_phase: str | None = None
|
||||
started_at: datetime
|
||||
finished_at: datetime
|
||||
duration_ms: int = Field(ge=0)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
job_source: Optional["JobSource"] = Relationship(back_populates="execution_attempts")
|
||||
artifacts: list["ProcessingArtifact"] = Relationship(
|
||||
back_populates="execution_attempt", sa_relationship_kwargs={"lazy": "noload"}
|
||||
)
|
||||
|
||||
|
||||
class ProcessingArtifact(SQLModel, table=True):
|
||||
"""Provider-neutral, versioned output derived from a Source."""
|
||||
|
||||
__tablename__ = "processing_artifact"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(inline_payload IS NOT NULL AND external_reference IS NULL) OR "
|
||||
"(inline_payload IS NULL AND external_reference IS NOT NULL)",
|
||||
name="ck_processing_artifact_one_content_location",
|
||||
),
|
||||
)
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
source_id: UUID = Field(foreign_key="source.id", index=True)
|
||||
execution_attempt_id: UUID | None = Field(default=None, foreign_key="execution_attempt.id", index=True)
|
||||
artifact_type: str
|
||||
media_type: str
|
||||
schema_name: str
|
||||
schema_version: str
|
||||
producer: str
|
||||
producer_version: str
|
||||
inline_payload: dict[str, JsonValue] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
||||
external_reference: str | None = None
|
||||
payload_sha256: str = Field(index=True)
|
||||
byte_size: int = Field(sa_column=Column(BigInteger(), nullable=False))
|
||||
coordinate_metadata: dict[str, JsonValue] | None = Field(
|
||||
default=None, sa_column=Column(JSONBCompat(), nullable=True)
|
||||
)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
execution_attempt: Optional["ExecutionAttempt"] = Relationship(back_populates="artifacts")
|
||||
source: Optional["Source"] = Relationship(back_populates="processing_artifacts")
|
||||
@@ -4,19 +4,185 @@ import logging
|
||||
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..models import Job
|
||||
from ..models import JobStatus
|
||||
from .runtime import get_engine
|
||||
from .engine import resolve_engine
|
||||
from .models import DocumentType
|
||||
from .models import Job
|
||||
from .models import JobStatus
|
||||
from .models import PersonRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_PERSON_ROLES: tuple[tuple[str, str], ...] = (
|
||||
("author", "Author"),
|
||||
("recipient", "Recipient"),
|
||||
("mentioned", "Mentioned"),
|
||||
)
|
||||
|
||||
DEFAULT_DOCUMENT_TYPES: tuple[str, ...] = (
|
||||
"Letter",
|
||||
"Record",
|
||||
"Memo",
|
||||
"Postcard",
|
||||
"Journal",
|
||||
"Note",
|
||||
)
|
||||
|
||||
|
||||
async def create_all(*, engine: AsyncEngine | None = None) -> None:
|
||||
"""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 _upgrade_document_type_uuid_identity(connection)
|
||||
await _upgrade_person_family_search_id(connection)
|
||||
await _upgrade_v42_evidence_tables(connection)
|
||||
await seed_registry_defaults(engine=active_engine)
|
||||
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
|
||||
|
||||
|
||||
async def upgrade_schema(*, engine: AsyncEngine | None = None) -> None:
|
||||
"""Apply non-destructive additive upgrades to an existing schema."""
|
||||
active_engine = engine or resolve_engine()
|
||||
async with active_engine.begin() as connection:
|
||||
await _upgrade_document_type_uuid_identity(connection)
|
||||
await _upgrade_person_family_search_id(connection)
|
||||
await _upgrade_v42_evidence_tables(connection)
|
||||
|
||||
|
||||
async def _upgrade_v42_evidence_tables(connection: AsyncConnection) -> None:
|
||||
"""Create the additive V4.2 evidence tables without rewriting historical rows."""
|
||||
|
||||
def create_tables(sync_connection) -> None:
|
||||
SQLModel.metadata.tables["execution_attempt"].create(sync_connection, checkfirst=True)
|
||||
SQLModel.metadata.tables["processing_artifact"].create(sync_connection, checkfirst=True)
|
||||
|
||||
await connection.run_sync(create_tables)
|
||||
|
||||
|
||||
async def _upgrade_document_type_uuid_identity(connection: AsyncConnection) -> None:
|
||||
"""Backfill UUID references and retire legacy Document Type code/order columns."""
|
||||
|
||||
def inspect_schema(sync_connection) -> tuple[set[str], set[str]]:
|
||||
database = inspect(sync_connection)
|
||||
tables = set(database.get_table_names())
|
||||
type_columns = (
|
||||
{column["name"] for column in database.get_columns("document_type")} if "document_type" in tables else set()
|
||||
)
|
||||
document_columns = (
|
||||
{column["name"] for column in database.get_columns("document")} if "document" in tables else set()
|
||||
)
|
||||
return type_columns, document_columns
|
||||
|
||||
type_columns, document_columns = await connection.run_sync(inspect_schema)
|
||||
if not type_columns:
|
||||
return
|
||||
|
||||
if "normalized_label" not in type_columns:
|
||||
await connection.execute(text("ALTER TABLE document_type ADD COLUMN normalized_label VARCHAR"))
|
||||
await connection.execute(
|
||||
text("UPDATE document_type SET normalized_label = lower(trim(label)) WHERE normalized_label IS NULL")
|
||||
)
|
||||
|
||||
duplicates = (
|
||||
await connection.execute(
|
||||
text("SELECT normalized_label FROM document_type GROUP BY normalized_label HAVING count(*) > 1")
|
||||
)
|
||||
).first()
|
||||
if duplicates is not None:
|
||||
raise RuntimeError(
|
||||
"Document Type migration requires unique labels ignoring case and whitespace; "
|
||||
f"duplicate normalized label: {duplicates[0]!r}"
|
||||
)
|
||||
|
||||
if "code" in type_columns and {"document_type", "document_type_id"}.issubset(document_columns):
|
||||
await connection.execute(
|
||||
text(
|
||||
"UPDATE document SET document_type_id = ("
|
||||
"SELECT id FROM document_type WHERE "
|
||||
"lower(trim(document_type.code)) = lower(trim(document.document_type))"
|
||||
") WHERE document_type_id IS NULL AND document_type IS NOT NULL"
|
||||
)
|
||||
)
|
||||
|
||||
if connection.dialect.name == "postgresql":
|
||||
await connection.execute(text("ALTER TABLE document_type ALTER COLUMN normalized_label SET NOT NULL"))
|
||||
if "code" in type_columns:
|
||||
await connection.execute(text("ALTER TABLE document_type DROP COLUMN code CASCADE"))
|
||||
if "sort_order" in type_columns:
|
||||
await connection.execute(text("ALTER TABLE document_type DROP COLUMN sort_order"))
|
||||
if "document_type" in document_columns:
|
||||
await connection.execute(text("ALTER TABLE document DROP COLUMN document_type"))
|
||||
elif connection.dialect.name == "sqlite":
|
||||
await connection.execute(text("DROP INDEX IF EXISTS ix_document_type_code"))
|
||||
if "code" in type_columns:
|
||||
await connection.execute(text("ALTER TABLE document_type DROP COLUMN code"))
|
||||
if "sort_order" in type_columns:
|
||||
await connection.execute(text("ALTER TABLE document_type DROP COLUMN sort_order"))
|
||||
if "document_type" in document_columns:
|
||||
await connection.execute(text("ALTER TABLE document DROP COLUMN document_type"))
|
||||
|
||||
await connection.execute(
|
||||
text("CREATE UNIQUE INDEX IF NOT EXISTS ix_document_type_normalized_label ON document_type (normalized_label)")
|
||||
)
|
||||
|
||||
|
||||
async def _upgrade_person_family_search_id(connection: AsyncConnection) -> None:
|
||||
"""Add the nullable V4.1 FamilySearch field to an existing database."""
|
||||
|
||||
def inspect_person(sync_connection) -> tuple[bool, bool]:
|
||||
database = inspect(sync_connection)
|
||||
if "person" not in database.get_table_names():
|
||||
return False, False
|
||||
columns = {column["name"] for column in database.get_columns("person")}
|
||||
indexes = database.get_indexes("person")
|
||||
constraints = database.get_unique_constraints("person")
|
||||
has_unique_id = any(entry.get("column_names") == ["family_search_id"] for entry in [*indexes, *constraints])
|
||||
return "family_search_id" in columns, has_unique_id
|
||||
|
||||
has_column, has_unique_id = await connection.run_sync(inspect_person)
|
||||
if not has_column and not await connection.run_sync(
|
||||
lambda sync_connection: "person" in inspect(sync_connection).get_table_names()
|
||||
):
|
||||
return
|
||||
if not has_column:
|
||||
await connection.execute(text("ALTER TABLE person ADD COLUMN family_search_id VARCHAR"))
|
||||
if not has_unique_id:
|
||||
await connection.execute(
|
||||
text("CREATE UNIQUE INDEX IF NOT EXISTS ix_person_family_search_id ON person (family_search_id)")
|
||||
)
|
||||
|
||||
|
||||
async def seed_registry_defaults(*, engine: AsyncEngine | None = None) -> None:
|
||||
"""Seed default registry rows for role and document type taxonomies."""
|
||||
active_engine = engine or resolve_engine()
|
||||
session_factory = async_sessionmaker(active_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with session_factory() as session:
|
||||
role_codes = set((await session.exec(select(PersonRole.code))).all())
|
||||
for code, label in DEFAULT_PERSON_ROLES:
|
||||
if code not in role_codes:
|
||||
session.add(PersonRole(code=code, label=label))
|
||||
|
||||
type_labels = set((await session.exec(select(DocumentType.normalized_label))).all())
|
||||
for label in DEFAULT_DOCUMENT_TYPES:
|
||||
normalized_label = label.casefold()
|
||||
if normalized_label not in type_labels:
|
||||
session.add(DocumentType(label=label, normalized_label=normalized_label))
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_next_queued_job(*, session: AsyncSession) -> Job | None:
|
||||
"""Get the next queued job, if any."""
|
||||
result = await session.exec(
|
||||
@@ -26,54 +192,3 @@ async def get_next_queued_job(*, session: AsyncSession) -> Job | None:
|
||||
.limit(1)
|
||||
) # fmt: skip
|
||||
return result.first()
|
||||
|
||||
|
||||
async def create_all(*, engine: AsyncEngine | None = None) -> None:
|
||||
"""Create all tables on the selected engine."""
|
||||
# Import models so SQLModel metadata is fully registered before bootstrap.
|
||||
from transcription import models as _models # noqa: F401
|
||||
|
||||
active_engine = engine or get_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)
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from sqlmodel.pool import StaticPool
|
||||
|
||||
from ..config import Settings
|
||||
from ..config import get_settings
|
||||
from .engine import get_database_url
|
||||
from .engine import get_engine
|
||||
from .session import get_session_factory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,79 +22,42 @@ class DatabaseRuntime:
|
||||
session_factory: async_sessionmaker[AsyncSession]
|
||||
|
||||
|
||||
_runtime: ContextVar[DatabaseRuntime | None] = ContextVar("database_runtime", default=None)
|
||||
_runtime: DatabaseRuntime | None = None
|
||||
|
||||
|
||||
def get_database_runtime() -> DatabaseRuntime | None:
|
||||
"""Return the process-owned database runtime."""
|
||||
return _runtime
|
||||
|
||||
|
||||
async def dispose_database_runtime() -> None:
|
||||
"""Dispose lifespan-owned async database resources."""
|
||||
runtime = _runtime.get()
|
||||
global _runtime
|
||||
runtime = _runtime
|
||||
if runtime is None:
|
||||
return
|
||||
await runtime.engine.dispose()
|
||||
_runtime.set(None)
|
||||
|
||||
|
||||
def _to_async_database_url(database_url: str) -> str:
|
||||
"""Normalize configured database URL to an async SQLAlchemy driver URL."""
|
||||
if database_url.startswith("sqlite://") and not database_url.startswith("sqlite+aiosqlite://"):
|
||||
return database_url.replace("sqlite://", "sqlite+aiosqlite://", 1)
|
||||
if database_url.startswith("postgresql://") and not database_url.startswith("postgresql+asyncpg://"):
|
||||
return database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
return database_url
|
||||
|
||||
|
||||
def _build_engine(settings: Settings) -> AsyncEngine:
|
||||
database_url = _to_async_database_url(settings.database_url)
|
||||
engine_factory = partial(
|
||||
create_async_engine,
|
||||
url=database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
if database_url.startswith("sqlite"):
|
||||
sqlite_connect_settings = {"check_same_thread": settings.sqlite_check_same_thread}
|
||||
engine_factory = partial(engine_factory, connect_args=sqlite_connect_settings)
|
||||
if ":memory:" in database_url:
|
||||
engine_factory = partial(engine_factory, poolclass=StaticPool)
|
||||
|
||||
return engine_factory()
|
||||
_runtime = None
|
||||
|
||||
|
||||
def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime:
|
||||
"""Initialize lifespan-owned async DB resources once per process."""
|
||||
runtime = _runtime.get()
|
||||
global _runtime
|
||||
active_settings = settings or get_settings()
|
||||
database_url = get_database_url(active_settings)
|
||||
runtime = _runtime
|
||||
if runtime is not None:
|
||||
runtime_url = runtime.engine.url.render_as_string(hide_password=False)
|
||||
if runtime_url != database_url:
|
||||
raise RuntimeError(
|
||||
"Database runtime is already initialized for a different database: "
|
||||
f"{runtime_url!r} != {database_url!r}"
|
||||
)
|
||||
return runtime
|
||||
|
||||
active_settings = settings or get_settings()
|
||||
engine = _build_engine(active_settings)
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
engine = get_engine(database_url)
|
||||
session_factory = get_session_factory(database_url)
|
||||
runtime = DatabaseRuntime(engine=engine, session_factory=session_factory)
|
||||
_runtime.set(runtime)
|
||||
_runtime = runtime
|
||||
logger.debug("Initialized async database runtime for database_url=%s", engine.url)
|
||||
return runtime
|
||||
|
||||
|
||||
def get_engine(settings: Settings | None = None) -> AsyncEngine:
|
||||
"""Return the current async SQLAlchemy engine."""
|
||||
runtime = _runtime.get() or initialize_database_runtime(settings=settings)
|
||||
return runtime.engine
|
||||
|
||||
|
||||
def get_session_factory(settings: Settings | None = None) -> async_sessionmaker[AsyncSession]:
|
||||
"""Return the shared async session factory."""
|
||||
runtime = _runtime.get() or initialize_database_runtime(settings=settings)
|
||||
return runtime.session_factory
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_session(
|
||||
*,
|
||||
settings: Settings | None = None,
|
||||
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||
) -> AsyncGenerator[AsyncSession]:
|
||||
"""Yield a database session and ensure cleanup."""
|
||||
active_session_factory = session_factory or get_session_factory(settings)
|
||||
async with active_session_factory() as session:
|
||||
yield session
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import cache
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
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
|
||||
from .engine import get_engine
|
||||
|
||||
type SessionFactory = async_sessionmaker[AsyncSession]
|
||||
|
||||
|
||||
@cache
|
||||
def get_session_factory(database_url: str) -> SessionFactory:
|
||||
return async_sessionmaker(
|
||||
bind=get_engine(database_url),
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
if settings is None:
|
||||
from .runtime import get_database_runtime
|
||||
|
||||
runtime = get_database_runtime()
|
||||
if runtime is not None:
|
||||
return runtime.session_factory
|
||||
return get_session_factory(get_database_url(settings or get_settings()))
|
||||
|
||||
|
||||
type SessionFactoryDep = Annotated[SessionFactory, Depends(resolve_session_factory)]
|
||||
|
||||
|
||||
async def dispose_session_factory(database_url: str) -> None:
|
||||
get_session_factory.cache_clear()
|
||||
await dispose_engine(database_url)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
active_session_factory = session_factory or resolve_session_factory(
|
||||
database_url,
|
||||
settings=settings,
|
||||
)
|
||||
async with active_session_factory() as owned_session:
|
||||
yield owned_session
|
||||
|
||||
|
||||
type SessionScopeDep = Annotated[AsyncSession, Depends(session_scope)]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction_scope(
|
||||
*,
|
||||
settings: Settings | None = None,
|
||||
database_url: str | None = None,
|
||||
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():
|
||||
raise RuntimeError("A supplied session must have an active transaction")
|
||||
yield async_session
|
||||
return
|
||||
case AsyncSessionTransaction() as async_transaction:
|
||||
yield async_transaction
|
||||
return
|
||||
|
||||
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[
|
||||
AsyncSession | AsyncSessionTransaction,
|
||||
Depends(transaction_scope),
|
||||
]
|
||||
@@ -1,110 +0,0 @@
|
||||
"""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)
|
||||
"""
|
||||
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlmodel import Field
|
||||
from sqlmodel import Relationship
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class JobStatus(StrEnum):
|
||||
QUEUED = "queued"
|
||||
PROCESSING = "processing"
|
||||
TRANSCRIBED = "transcribed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class Document(SQLModel, table=True):
|
||||
"""An historical document."""
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
name: str
|
||||
|
||||
# Relationships
|
||||
jobs: list["Job"] = Relationship(back_populates="document")
|
||||
sources: list["Source"] = Relationship(back_populates="document")
|
||||
|
||||
|
||||
class Source(SQLModel, table=True):
|
||||
"""A document source (image or PDF)."""
|
||||
|
||||
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))
|
||||
|
||||
# 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},
|
||||
)
|
||||
|
||||
|
||||
class Job(SQLModel, table=True):
|
||||
"""A transcription job tied to a single document."""
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
document_id: UUID = Field(foreign_key="document.id")
|
||||
status: JobStatus = Field(default=JobStatus.QUEUED)
|
||||
retry_count: int = Field(default=0, ge=0)
|
||||
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")
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
"""Return the filename of the associated source, when available."""
|
||||
if not self.sources:
|
||||
return "unknown"
|
||||
return self.sources[0].filename
|
||||
|
||||
|
||||
class Revision(SQLModel, table=True):
|
||||
"""A revision of a transcription text."""
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
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))
|
||||
|
||||
__table_args__ = (UniqueConstraint("source_id", name="uq_revision_source_id"),)
|
||||
|
||||
# Relationships
|
||||
source: Optional["Source"] = Relationship(back_populates="revision")
|
||||
@@ -6,8 +6,12 @@ from transcription.config import get_settings
|
||||
from transcription.providers.base import ProviderAuthError
|
||||
from transcription.providers.base import ProviderError
|
||||
from transcription.providers.base import ProviderResponseError
|
||||
from transcription.providers.base import TranscriptionMetadata
|
||||
from transcription.providers.base import TranscriptionProvider
|
||||
from transcription.providers.base import TranscriptionResult
|
||||
from transcription.providers.evidence import RequestManifest
|
||||
from transcription.providers.evidence import SourceEvidenceReference
|
||||
from transcription.providers.evidence import TransportEvidence
|
||||
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
|
||||
|
||||
|
||||
@@ -25,7 +29,11 @@ __all__ = [
|
||||
"ProviderAuthError",
|
||||
"ProviderError",
|
||||
"ProviderResponseError",
|
||||
"RequestManifest",
|
||||
"SourceEvidenceReference",
|
||||
"TranscriptionMetadata",
|
||||
"TranscriptionProvider",
|
||||
"TranscriptionResult",
|
||||
"TransportEvidence",
|
||||
"get_transcription_provider",
|
||||
]
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
"""Provider interfaces and shared types for transcription adapters."""
|
||||
"""Provider interfaces and validated shared contracts for transcription adapters."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import ConfigDict
|
||||
from pydantic import Field
|
||||
from pydantic import JsonValue
|
||||
|
||||
from transcription.providers.evidence import RequestManifest
|
||||
from transcription.providers.evidence import SourceEvidenceReference
|
||||
from transcription.providers.evidence import TransportEvidence
|
||||
|
||||
|
||||
class ProviderError(RuntimeError):
|
||||
"""Base error for provider failures."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
request_manifest: RequestManifest | None = None,
|
||||
transport_evidence: TransportEvidence | None = None,
|
||||
failure_phase: str = "provider_request",
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.request_manifest = request_manifest
|
||||
self.transport_evidence = transport_evidence
|
||||
self.failure_phase = failure_phase
|
||||
|
||||
|
||||
class ProviderAuthError(ProviderError):
|
||||
"""Raised when provider authentication fails."""
|
||||
@@ -16,23 +37,80 @@ class ProviderResponseError(ProviderError):
|
||||
"""Raised when provider responses are malformed or unusable."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TranscriptionResult:
|
||||
class ProviderUsage(BaseModel):
|
||||
"""Normalized provider token accounting."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
input_tokens: int | None = Field(default=None, ge=0)
|
||||
output_tokens: int | None = Field(default=None, ge=0)
|
||||
total_tokens: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class TranscriptionMetadata(BaseModel):
|
||||
"""Stable structured metadata persisted for one provider execution."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
finish_reason: str | None = Field(default=None, min_length=1)
|
||||
usage: ProviderUsage | None = None
|
||||
|
||||
def as_json_object(self) -> dict[str, JsonValue] | None:
|
||||
payload = self.model_dump(mode="json", exclude_none=True)
|
||||
return payload or None
|
||||
|
||||
|
||||
class TranscriptionResult(BaseModel):
|
||||
"""Normalized output returned by any transcription provider."""
|
||||
|
||||
text: str
|
||||
provider: str
|
||||
prompt_name: str
|
||||
model: str
|
||||
finish_reason: str | None = None
|
||||
usage_input_tokens: int | None = None
|
||||
usage_output_tokens: int | None = None
|
||||
usage_total_tokens: int | None = None
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True)
|
||||
|
||||
text: str = Field(min_length=1)
|
||||
provider: str = Field(min_length=1)
|
||||
model: str = Field(min_length=1)
|
||||
prompt_name: str | None = None
|
||||
prompt_hash: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
|
||||
system_prompt: str | None = None
|
||||
user_prompt: str | None = None
|
||||
temperature: float | None = Field(default=None, ge=0.0, le=2.0)
|
||||
top_p: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
metadata: TranscriptionMetadata = Field(default_factory=TranscriptionMetadata)
|
||||
raw_api_response: dict[str, JsonValue] | None = None
|
||||
request_manifest: RequestManifest | None = None
|
||||
transport_evidence: TransportEvidence | None = None
|
||||
|
||||
@property
|
||||
def finish_reason(self) -> str | None:
|
||||
return self.metadata.finish_reason
|
||||
|
||||
@property
|
||||
def usage_input_tokens(self) -> int | None:
|
||||
return self.metadata.usage.input_tokens if self.metadata.usage else None
|
||||
|
||||
@property
|
||||
def usage_output_tokens(self) -> int | None:
|
||||
return self.metadata.usage.output_tokens if self.metadata.usage else None
|
||||
|
||||
@property
|
||||
def usage_total_tokens(self) -> int | None:
|
||||
return self.metadata.usage.total_tokens if self.metadata.usage else None
|
||||
|
||||
def metadata_payload(self) -> dict[str, JsonValue] | None:
|
||||
return self.metadata.as_json_object()
|
||||
|
||||
|
||||
class TranscriptionProvider(Protocol):
|
||||
"""Contract every transcription provider adapter must satisfy."""
|
||||
|
||||
async def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
|
||||
async def transcribe(
|
||||
self,
|
||||
*,
|
||||
prompt_text: str,
|
||||
image_bytes: bytes,
|
||||
mime_type: str,
|
||||
temperature: float | None = None,
|
||||
top_p: float | None = None,
|
||||
source_reference: SourceEvidenceReference | None = None,
|
||||
) -> TranscriptionResult:
|
||||
"""Transcribe the provided image according to the prompt text."""
|
||||
...
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Versioned, provider-neutral contracts for processing evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from importlib.metadata import version
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import ConfigDict
|
||||
from pydantic import Field
|
||||
from pydantic import JsonValue
|
||||
|
||||
REQUEST_MANIFEST_SCHEMA = "transcription.request-manifest"
|
||||
REQUEST_MANIFEST_VERSION = "1"
|
||||
SOFTWARE_CONTEXT_SCHEMA = "transcription.software-context"
|
||||
SOFTWARE_CONTEXT_VERSION = "1"
|
||||
TRANSPORT_EVIDENCE_SCHEMA = "transcription.transport-evidence"
|
||||
TRANSPORT_EVIDENCE_VERSION = "1"
|
||||
CANONICAL_JSON_ALGORITHM = "transcription-canonical-json-v1"
|
||||
|
||||
SAFE_RESPONSE_HEADERS = frozenset(
|
||||
{
|
||||
"content-type",
|
||||
"content-encoding",
|
||||
"date",
|
||||
"retry-after",
|
||||
"x-request-id",
|
||||
"x-openrouter-generation-id",
|
||||
"x-ratelimit-limit",
|
||||
"x-ratelimit-remaining",
|
||||
"x-ratelimit-reset",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EvidenceModel(BaseModel):
|
||||
"""Strict immutable base for persisted evidence contracts."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class SourceEvidenceReference(EvidenceModel):
|
||||
"""Secret-safe identity for source content used by one execution."""
|
||||
|
||||
source_id: UUID
|
||||
digest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
byte_size: int = Field(ge=0)
|
||||
media_type: str = Field(min_length=1)
|
||||
page_number: int = Field(ge=1)
|
||||
width: int | None = Field(default=None, ge=1)
|
||||
height: int | None = Field(default=None, ge=1)
|
||||
derivative_id: UUID | None = None
|
||||
transformation: str | None = None
|
||||
|
||||
|
||||
class SoftwareContext(EvidenceModel):
|
||||
"""Versions needed to interpret a provider execution."""
|
||||
|
||||
schema_name: Literal["transcription.software-context"] = SOFTWARE_CONTEXT_SCHEMA
|
||||
schema_version: Literal["1"] = SOFTWARE_CONTEXT_VERSION
|
||||
application_version: str
|
||||
application_commit: str | None = None
|
||||
adapter_name: str
|
||||
adapter_version: str
|
||||
client_library: str
|
||||
client_library_version: str
|
||||
python_version: str
|
||||
|
||||
|
||||
class RequestManifest(EvidenceModel):
|
||||
"""Frozen, secret-safe representation of one concrete provider request."""
|
||||
|
||||
schema_name: Literal["transcription.request-manifest"] = REQUEST_MANIFEST_SCHEMA
|
||||
schema_version: Literal["1"] = REQUEST_MANIFEST_VERSION
|
||||
provider: str = Field(min_length=1)
|
||||
requested_model: str = Field(min_length=1)
|
||||
request: dict[str, JsonValue]
|
||||
source: SourceEvidenceReference
|
||||
explicitly_supplied_parameters: tuple[str, ...] = ()
|
||||
omitted_optional_parameters: tuple[str, ...] = ()
|
||||
optional_parameter_states: dict[str, Literal["omitted", "null", "value"]]
|
||||
prompt_content: str = Field(min_length=1)
|
||||
prompt_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
timeout_seconds: float = Field(gt=0)
|
||||
retry_policy: str = Field(min_length=1)
|
||||
software: SoftwareContext
|
||||
canonicalization: Literal["transcription-canonical-json-v1"] = CANONICAL_JSON_ALGORITHM
|
||||
|
||||
def canonical_bytes(self) -> bytes:
|
||||
return canonical_json_bytes(self.model_dump(mode="json"))
|
||||
|
||||
def digest(self) -> str:
|
||||
return hashlib.sha256(self.canonical_bytes()).hexdigest()
|
||||
|
||||
|
||||
class TransportEvidence(EvidenceModel):
|
||||
"""Exact response captured at the application/router HTTP boundary."""
|
||||
|
||||
schema_name: Literal["transcription.transport-evidence"] = TRANSPORT_EVIDENCE_SCHEMA
|
||||
schema_version: Literal["1"] = TRANSPORT_EVIDENCE_VERSION
|
||||
response_received: bool
|
||||
status_code: int | None = Field(default=None, ge=100, le=599)
|
||||
body: bytes | None = None
|
||||
safe_headers: dict[str, str] = Field(default_factory=dict)
|
||||
content_type: str | None = None
|
||||
content_encoding: str | None = None
|
||||
request_id: str | None = None
|
||||
generation_id: str | None = None
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
"""Serialize JSON deterministically for evidence integrity hashes."""
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def filter_safe_response_headers(headers: Any) -> dict[str, str]:
|
||||
"""Return only explicitly allowlisted response headers."""
|
||||
return {
|
||||
str(name).lower(): str(value) for name, value in headers.items() if str(name).lower() in SAFE_RESPONSE_HEADERS
|
||||
}
|
||||
|
||||
|
||||
def package_version(package: str) -> str:
|
||||
"""Return an installed package version without failing evidence capture."""
|
||||
try:
|
||||
return version(package)
|
||||
except PackageNotFoundError:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def build_software_context(*, adapter_name: str, adapter_version: str, client_library: str) -> SoftwareContext:
|
||||
"""Build the runtime software identity for an execution."""
|
||||
return SoftwareContext(
|
||||
application_version=package_version("transcription"),
|
||||
application_commit=os.environ.get("TRANSCRIPTION_COMMIT") or None,
|
||||
adapter_name=adapter_name,
|
||||
adapter_version=adapter_version,
|
||||
client_library=client_library,
|
||||
client_library_version=package_version(client_library),
|
||||
python_version=platform.python_version(),
|
||||
)
|
||||
@@ -3,172 +3,515 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import Callable
|
||||
from typing import Annotated
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from openrouter import OpenRouter
|
||||
from openrouter.components.chatmessages import ChatMessagesTypedDict
|
||||
from openrouter import errors as openrouter_errors
|
||||
from pydantic import BaseModel
|
||||
from pydantic import ConfigDict
|
||||
from pydantic import Field
|
||||
from pydantic import JsonValue
|
||||
from pydantic import TypeAdapter
|
||||
from pydantic import ValidationError
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.config import get_settings
|
||||
from transcription.providers.base import ProviderAuthError
|
||||
from transcription.providers.base import ProviderError
|
||||
from transcription.providers.base import ProviderResponseError
|
||||
from transcription.providers.base import ProviderUsage
|
||||
from transcription.providers.base import TranscriptionMetadata
|
||||
from transcription.providers.base import TranscriptionResult
|
||||
from transcription.providers.evidence import RequestManifest
|
||||
from transcription.providers.evidence import SourceEvidenceReference
|
||||
from transcription.providers.evidence import TransportEvidence
|
||||
from transcription.providers.evidence import build_software_context
|
||||
from transcription.providers.evidence import filter_safe_response_headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_OPENROUTER_MODEL = "google/gemini-2.5-flash"
|
||||
OPENROUTER_ADAPTER_VERSION = "2"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenRouterRequest:
|
||||
class _CapturingAsyncByteStream(httpx.AsyncByteStream):
|
||||
"""Copy streamed response bytes without changing what the SDK consumes."""
|
||||
|
||||
def __init__(self, stream: httpx.AsyncByteStream, on_complete: Callable[[bytes], None]):
|
||||
self._stream = stream
|
||||
self._on_complete = on_complete
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||
content = bytearray()
|
||||
async for chunk in self._stream:
|
||||
content.extend(chunk)
|
||||
yield chunk
|
||||
self._on_complete(bytes(content))
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._stream.aclose()
|
||||
|
||||
|
||||
class _CapturingAsyncClient:
|
||||
"""Delegate SDK HTTP calls while retaining the response before SDK parsing."""
|
||||
|
||||
def __init__(self, client: httpx.AsyncClient):
|
||||
self._client = client
|
||||
self.last_response: httpx.Response | None = None
|
||||
self.last_body: bytes | None = None
|
||||
|
||||
async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
|
||||
response = await self._client.send(request, **kwargs)
|
||||
self.last_response = response
|
||||
try:
|
||||
self.last_body = response.content
|
||||
except httpx.ResponseNotRead:
|
||||
response.stream = _CapturingAsyncByteStream(response.stream, self._capture_body)
|
||||
return response
|
||||
|
||||
def build_request(self, *args: Any, **kwargs: Any) -> httpx.Request:
|
||||
return self._client.build_request(*args, **kwargs)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
def reset(self) -> None:
|
||||
self.last_response = None
|
||||
self.last_body = None
|
||||
|
||||
def _capture_body(self, body: bytes) -> None:
|
||||
self.last_body = body
|
||||
|
||||
|
||||
class _ProviderModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class TextContent(_ProviderModel):
|
||||
type: Literal["text"] = "text"
|
||||
text: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ImageUrl(_ProviderModel):
|
||||
url: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ImageContent(_ProviderModel):
|
||||
type: Literal["image_url"] = "image_url"
|
||||
image_url: ImageUrl
|
||||
|
||||
|
||||
class FileData(_ProviderModel):
|
||||
filename: str = Field(min_length=1)
|
||||
file_data: str = Field(min_length=1)
|
||||
|
||||
|
||||
class FileContent(_ProviderModel):
|
||||
type: Literal["file"] = "file"
|
||||
file: FileData
|
||||
|
||||
|
||||
MessageContent = Annotated[TextContent | ImageContent | FileContent, Field(discriminator="type")]
|
||||
|
||||
|
||||
class UserMessage(_ProviderModel):
|
||||
role: Literal["user"] = "user"
|
||||
content: tuple[MessageContent, ...] = Field(min_length=2)
|
||||
|
||||
|
||||
class OpenRouterRequest(_ProviderModel):
|
||||
"""Normalized request payload fields for OpenRouter calls."""
|
||||
|
||||
model: str
|
||||
messages: list[dict[str, Any]]
|
||||
model: str = Field(min_length=1)
|
||||
messages: tuple[UserMessage, ...] = Field(min_length=1)
|
||||
http_referer: str | None
|
||||
x_open_router_title: str | None
|
||||
temperature: float | None = Field(ge=0.0, le=2.0)
|
||||
top_p: float | None = Field(ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class ResponseContentPart(BaseModel):
|
||||
model_config = ConfigDict(extra="allow", frozen=True)
|
||||
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class ResponseMessage(BaseModel):
|
||||
model_config = ConfigDict(extra="allow", frozen=True)
|
||||
|
||||
content: str | tuple[ResponseContentPart, ...] | None = None
|
||||
|
||||
|
||||
class ResponseChoice(BaseModel):
|
||||
model_config = ConfigDict(extra="allow", frozen=True)
|
||||
|
||||
message: ResponseMessage
|
||||
finish_reason: str | None = None
|
||||
|
||||
|
||||
class ResponseUsage(BaseModel):
|
||||
model_config = ConfigDict(extra="allow", frozen=True)
|
||||
|
||||
prompt_tokens: int | None = Field(default=None, ge=0)
|
||||
completion_tokens: int | None = Field(default=None, ge=0)
|
||||
total_tokens: int | None = Field(default=None, ge=0)
|
||||
input_tokens: int | None = Field(default=None, ge=0)
|
||||
output_tokens: int | None = Field(default=None, ge=0)
|
||||
total: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class OpenRouterResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow", frozen=True)
|
||||
|
||||
model: str | None = None
|
||||
choices: tuple[ResponseChoice, ...] = Field(min_length=1)
|
||||
usage: dict[str, JsonValue] | None = None
|
||||
|
||||
|
||||
JSON_OBJECT_ADAPTER = TypeAdapter(dict[str, JsonValue])
|
||||
|
||||
|
||||
class OpenRouterTranscriptionProvider:
|
||||
"""Adapter that performs image transcription through OpenRouter."""
|
||||
|
||||
def __init__(self, *, settings: Settings | None = None, client: OpenRouter | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings | None = None,
|
||||
client: OpenRouter | None = None,
|
||||
async_client: httpx.AsyncClient | None = None,
|
||||
):
|
||||
self._settings = settings or get_settings()
|
||||
self._model = self._settings.provider_model or DEFAULT_OPENROUTER_MODEL
|
||||
self._client = client or OpenRouter(api_key=self._settings.openrouter_api_key)
|
||||
self._capturing_client: _CapturingAsyncClient | None = None
|
||||
self._current_request_manifest: RequestManifest | None = None
|
||||
self._current_transport_evidence: TransportEvidence | None = None
|
||||
if client is None:
|
||||
self._capturing_client = _CapturingAsyncClient(async_client or httpx.AsyncClient(follow_redirects=True))
|
||||
client = OpenRouter(
|
||||
api_key=self._settings.openrouter_api_key.get_secret_value(),
|
||||
async_client=self._capturing_client,
|
||||
)
|
||||
self._client = client
|
||||
|
||||
@property
|
||||
def model(self) -> str:
|
||||
"""Return the resolved OpenRouter model slug."""
|
||||
return self._model
|
||||
|
||||
async def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
|
||||
@property
|
||||
def current_request_manifest(self) -> RequestManifest | None:
|
||||
return self._current_request_manifest
|
||||
|
||||
@property
|
||||
def current_transport_evidence(self) -> TransportEvidence | None:
|
||||
if self._current_transport_evidence is not None:
|
||||
return self._current_transport_evidence
|
||||
if self._current_request_manifest is None:
|
||||
return None
|
||||
return self._captured_transport_evidence()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._capturing_client is not None:
|
||||
await self._capturing_client.aclose()
|
||||
|
||||
async def transcribe(
|
||||
self,
|
||||
*,
|
||||
prompt_text: str,
|
||||
image_bytes: bytes,
|
||||
mime_type: str,
|
||||
temperature: float | None = None,
|
||||
top_p: float | None = None,
|
||||
source_reference: SourceEvidenceReference | None = None,
|
||||
) -> TranscriptionResult:
|
||||
"""Send prompt + image to OpenRouter and return normalized text output."""
|
||||
request = self._build_request(prompt_text=prompt_text, image_bytes=image_bytes, mime_type=mime_type)
|
||||
request = self._build_request(
|
||||
prompt_text=prompt_text,
|
||||
image_bytes=image_bytes,
|
||||
mime_type=mime_type,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
)
|
||||
manifest = self._build_request_manifest(
|
||||
request=request,
|
||||
prompt_text=prompt_text,
|
||||
source_reference=source_reference,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
)
|
||||
self._current_request_manifest = manifest
|
||||
self._current_transport_evidence = None
|
||||
if self._capturing_client is not None:
|
||||
self._capturing_client.reset()
|
||||
try:
|
||||
response = await self._client.chat.send_async(
|
||||
messages=cast(list[ChatMessagesTypedDict], request.messages),
|
||||
model=request.model,
|
||||
http_referer=request.http_referer,
|
||||
x_open_router_title=request.x_open_router_title,
|
||||
**request.model_dump(mode="json", exclude_none=True),
|
||||
retries=None,
|
||||
)
|
||||
except Exception as exc:
|
||||
message = str(exc).lower()
|
||||
if "401" in message or "auth" in message or "api key" in message:
|
||||
raise ProviderAuthError("OpenRouter authentication failed") from exc
|
||||
raise ProviderError("OpenRouter request failed") from exc
|
||||
transport = self._captured_transport_evidence()
|
||||
self._current_transport_evidence = transport
|
||||
if isinstance(exc, openrouter_errors.UnauthorizedResponseError):
|
||||
raise ProviderAuthError(
|
||||
"OpenRouter authentication failed",
|
||||
request_manifest=manifest,
|
||||
transport_evidence=transport,
|
||||
failure_phase="http_response" if transport.response_received else "connection",
|
||||
) from exc
|
||||
failure_phase = (
|
||||
"response_validation"
|
||||
if isinstance(exc, openrouter_errors.ResponseValidationError)
|
||||
else "http_response"
|
||||
if transport.response_received
|
||||
else "connection"
|
||||
)
|
||||
raise ProviderError(
|
||||
self._transport_error_message(transport),
|
||||
request_manifest=manifest,
|
||||
transport_evidence=transport,
|
||||
failure_phase=failure_phase,
|
||||
) from exc
|
||||
|
||||
text = self._extract_text(response)
|
||||
model = self._get_optional_attr(response, "model") or self.model
|
||||
finish_reason = self._extract_finish_reason(response)
|
||||
usage_input_tokens, usage_output_tokens, usage_total_tokens = self._extract_usage(response)
|
||||
transport = self._captured_transport_evidence()
|
||||
self._current_transport_evidence = transport
|
||||
raw_api_response = self._coerce_raw_response(response)
|
||||
try:
|
||||
validated_response = OpenRouterResponse.model_validate(raw_api_response)
|
||||
except ValidationError as exc:
|
||||
raise ProviderResponseError(
|
||||
"OpenRouter response failed schema validation",
|
||||
request_manifest=manifest,
|
||||
transport_evidence=transport,
|
||||
failure_phase="response_validation",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
text = self._extract_text(validated_response)
|
||||
except ProviderResponseError as exc:
|
||||
raise ProviderResponseError(
|
||||
str(exc),
|
||||
request_manifest=manifest,
|
||||
transport_evidence=transport,
|
||||
failure_phase="response_validation",
|
||||
) from exc
|
||||
model = validated_response.model or self.model
|
||||
metadata = self._build_metadata(validated_response)
|
||||
logger.info("OpenRouter transcription completed using model=%s", model)
|
||||
return TranscriptionResult(
|
||||
text=text,
|
||||
provider="openrouter",
|
||||
prompt_name="",
|
||||
prompt_name=None,
|
||||
prompt_hash=None,
|
||||
system_prompt=None,
|
||||
user_prompt=prompt_text,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
model=model,
|
||||
finish_reason=finish_reason,
|
||||
usage_input_tokens=usage_input_tokens,
|
||||
usage_output_tokens=usage_output_tokens,
|
||||
usage_total_tokens=usage_total_tokens,
|
||||
metadata=metadata,
|
||||
raw_api_response=raw_api_response,
|
||||
request_manifest=manifest,
|
||||
transport_evidence=transport,
|
||||
)
|
||||
|
||||
def _build_request(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> OpenRouterRequest:
|
||||
def _build_request_manifest(
|
||||
self,
|
||||
*,
|
||||
request: OpenRouterRequest,
|
||||
prompt_text: str,
|
||||
source_reference: SourceEvidenceReference | None,
|
||||
temperature: float | None,
|
||||
top_p: float | None,
|
||||
) -> RequestManifest | None:
|
||||
if source_reference is None:
|
||||
return None
|
||||
request_payload = request.model_dump(mode="json", exclude_none=True)
|
||||
sanitized_request = self._replace_embedded_media(request_payload, source_reference=source_reference)
|
||||
explicit = tuple(name for name, value in (("temperature", temperature), ("top_p", top_p)) if value is not None)
|
||||
omitted = tuple(name for name in ("temperature", "top_p") if name not in explicit)
|
||||
return RequestManifest(
|
||||
provider="openrouter",
|
||||
requested_model=self.model,
|
||||
request=JSON_OBJECT_ADAPTER.validate_python(sanitized_request),
|
||||
source=source_reference,
|
||||
explicitly_supplied_parameters=explicit,
|
||||
omitted_optional_parameters=omitted,
|
||||
optional_parameter_states={
|
||||
"temperature": "value" if temperature is not None else "omitted",
|
||||
"top_p": "value" if top_p is not None else "omitted",
|
||||
},
|
||||
prompt_content=prompt_text,
|
||||
prompt_sha256=hashlib.sha256(prompt_text.encode("utf-8")).hexdigest(),
|
||||
timeout_seconds=self._settings.worker_provider_timeout_seconds,
|
||||
retry_policy="application-bounded; sdk-retries=0",
|
||||
software=build_software_context(
|
||||
adapter_name="openrouter",
|
||||
adapter_version=OPENROUTER_ADAPTER_VERSION,
|
||||
client_library="openrouter",
|
||||
),
|
||||
)
|
||||
|
||||
def _replace_embedded_media(
|
||||
self,
|
||||
value: Any,
|
||||
*,
|
||||
source_reference: SourceEvidenceReference,
|
||||
) -> Any:
|
||||
if isinstance(value, str) and value.startswith("data:") and ";base64," in value:
|
||||
return {
|
||||
"source_reference": source_reference.model_dump(mode="json"),
|
||||
"embedded_media_omitted": True,
|
||||
}
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): self._replace_embedded_media(item, source_reference=source_reference)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list | tuple):
|
||||
return [self._replace_embedded_media(item, source_reference=source_reference) for item in value]
|
||||
return value
|
||||
|
||||
def _captured_transport_evidence(self) -> TransportEvidence:
|
||||
response = self._capturing_client.last_response if self._capturing_client is not None else None
|
||||
if response is None:
|
||||
return TransportEvidence(response_received=False)
|
||||
headers = filter_safe_response_headers(response.headers)
|
||||
body = self._capturing_client.last_body if self._capturing_client is not None else None
|
||||
return TransportEvidence(
|
||||
response_received=True,
|
||||
status_code=response.status_code,
|
||||
body=body,
|
||||
safe_headers=headers,
|
||||
content_type=headers.get("content-type"),
|
||||
content_encoding=headers.get("content-encoding"),
|
||||
request_id=headers.get("x-request-id"),
|
||||
generation_id=headers.get("x-openrouter-generation-id"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _transport_error_message(transport: TransportEvidence) -> str:
|
||||
message = "OpenRouter request failed"
|
||||
if transport.status_code is not None:
|
||||
message += f" with HTTP {transport.status_code}"
|
||||
if transport.body is None:
|
||||
return message
|
||||
try:
|
||||
payload = json.loads(transport.body)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return message
|
||||
if not isinstance(payload, dict):
|
||||
return message
|
||||
error = payload.get("error")
|
||||
detail = error.get("message") if isinstance(error, dict) else None
|
||||
if isinstance(detail, str) and detail.strip():
|
||||
return f"{message}: {detail.strip()[:500]}"
|
||||
return message
|
||||
|
||||
def _build_metadata(self, response: OpenRouterResponse) -> TranscriptionMetadata:
|
||||
choice = response.choices[0]
|
||||
finish_reason = choice.finish_reason.strip() if choice.finish_reason and choice.finish_reason.strip() else None
|
||||
normalized_usage = None
|
||||
if response.usage is not None:
|
||||
try:
|
||||
usage = ResponseUsage.model_validate(response.usage)
|
||||
except ValidationError as exc:
|
||||
logger.warning("Ignoring invalid OpenRouter usage metadata: %s", exc)
|
||||
else:
|
||||
normalized_usage = ProviderUsage(
|
||||
input_tokens=usage.prompt_tokens if usage.prompt_tokens is not None else usage.input_tokens,
|
||||
output_tokens=usage.completion_tokens
|
||||
if usage.completion_tokens is not None
|
||||
else usage.output_tokens,
|
||||
total_tokens=usage.total_tokens if usage.total_tokens is not None else usage.total,
|
||||
)
|
||||
if normalized_usage.model_dump(exclude_none=True) == {}:
|
||||
normalized_usage = None
|
||||
return TranscriptionMetadata(finish_reason=finish_reason, usage=normalized_usage)
|
||||
|
||||
def _coerce_raw_response(self, response: Any) -> dict[str, JsonValue]:
|
||||
payload = self._to_json_compatible(response)
|
||||
try:
|
||||
return JSON_OBJECT_ADAPTER.validate_python(payload)
|
||||
except ValidationError as exc:
|
||||
raise ProviderResponseError("OpenRouter response is not a JSON object") from exc
|
||||
|
||||
def _to_json_compatible(self, value: Any) -> Any:
|
||||
if value is None or isinstance(value, str | int | float | bool):
|
||||
return value
|
||||
|
||||
if isinstance(value, dict):
|
||||
return {str(key): self._to_json_compatible(item) for key, item in value.items()}
|
||||
|
||||
if isinstance(value, list | tuple | set):
|
||||
return [self._to_json_compatible(item) for item in value]
|
||||
|
||||
for method_name in ("model_dump", "to_dict"):
|
||||
serializer = getattr(value, method_name, None)
|
||||
if callable(serializer):
|
||||
try:
|
||||
serialized = serializer(mode="json") if method_name == "model_dump" else serializer()
|
||||
return self._to_json_compatible(serialized)
|
||||
except (TypeError, ValueError) as exc:
|
||||
logger.debug("OpenRouter response serializer %s failed: %s", method_name, exc)
|
||||
continue
|
||||
|
||||
object_dict = getattr(value, "__dict__", None)
|
||||
if isinstance(object_dict, dict):
|
||||
return {
|
||||
str(key): self._to_json_compatible(item)
|
||||
for key, item in object_dict.items()
|
||||
if not str(key).startswith("_")
|
||||
}
|
||||
|
||||
raise ProviderResponseError(f"OpenRouter response contains unsupported value type: {type(value).__name__}")
|
||||
|
||||
def _build_request(
|
||||
self,
|
||||
*,
|
||||
prompt_text: str,
|
||||
image_bytes: bytes,
|
||||
mime_type: str,
|
||||
temperature: float | None,
|
||||
top_p: float | None,
|
||||
) -> OpenRouterRequest:
|
||||
image_b64 = base64.b64encode(image_bytes).decode("ascii")
|
||||
data_url = f"data:{mime_type};base64,{image_b64}"
|
||||
|
||||
messages: list[dict[str, Any]] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt_text},
|
||||
{"type": "image_url", "image_url": {"url": data_url}},
|
||||
],
|
||||
}
|
||||
]
|
||||
media_content: ImageContent | FileContent
|
||||
if mime_type == "application/pdf":
|
||||
media_content = FileContent(file=FileData(filename="source.pdf", file_data=data_url))
|
||||
else:
|
||||
media_content = ImageContent(image_url=ImageUrl(url=data_url))
|
||||
|
||||
return OpenRouterRequest(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
messages=(UserMessage(content=(TextContent(text=prompt_text), media_content)),),
|
||||
http_referer=self._settings.openrouter_http_referer,
|
||||
x_open_router_title=self._settings.openrouter_app_title,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
)
|
||||
|
||||
def _extract_text(self, response: Any) -> str:
|
||||
choices = self._get_optional_attr(response, "choices")
|
||||
if not choices:
|
||||
raise ProviderResponseError("OpenRouter response missing choices")
|
||||
|
||||
first_choice = choices[0]
|
||||
message = self._get_optional_attr(first_choice, "message")
|
||||
if message is None:
|
||||
raise ProviderResponseError("OpenRouter response missing assistant message")
|
||||
|
||||
content = self._get_optional_attr(message, "content")
|
||||
def _extract_text(self, response: OpenRouterResponse) -> str:
|
||||
content = response.choices[0].message.content
|
||||
text = self._normalize_content(content)
|
||||
if not text:
|
||||
raise ProviderResponseError("OpenRouter response contained no transcription text")
|
||||
return text
|
||||
|
||||
def _extract_finish_reason(self, response: Any) -> str | None:
|
||||
choices = self._get_optional_attr(response, "choices")
|
||||
if not choices:
|
||||
return None
|
||||
first_choice = choices[0]
|
||||
finish_reason = self._get_optional_attr(first_choice, "finish_reason")
|
||||
if isinstance(finish_reason, str) and finish_reason.strip():
|
||||
return finish_reason.strip()
|
||||
return None
|
||||
|
||||
def _extract_usage(self, response: Any) -> tuple[int | None, int | None, int | None]:
|
||||
usage = self._get_optional_attr(response, "usage")
|
||||
if usage is None:
|
||||
return None, None, None
|
||||
|
||||
input_tokens = self._as_int(self._get_optional_attr(usage, "prompt_tokens"))
|
||||
output_tokens = self._as_int(self._get_optional_attr(usage, "completion_tokens"))
|
||||
total_tokens = self._as_int(self._get_optional_attr(usage, "total_tokens"))
|
||||
|
||||
if input_tokens is None:
|
||||
input_tokens = self._as_int(self._get_optional_attr(usage, "input_tokens"))
|
||||
if output_tokens is None:
|
||||
output_tokens = self._as_int(self._get_optional_attr(usage, "output_tokens"))
|
||||
if total_tokens is None:
|
||||
total_tokens = self._as_int(self._get_optional_attr(usage, "total"))
|
||||
|
||||
return input_tokens, output_tokens, total_tokens
|
||||
|
||||
def _normalize_content(self, content: Any) -> str:
|
||||
def _normalize_content(self, content: str | tuple[ResponseContentPart, ...] | None) -> str:
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
text_part = None
|
||||
text_part = item.get("text") if isinstance(item, dict) else self._get_optional_attr(item, "text")
|
||||
|
||||
if isinstance(text_part, str) and text_part.strip():
|
||||
parts.append(text_part.strip())
|
||||
if isinstance(content, tuple):
|
||||
parts = [item.text.strip() for item in content if item.text and item.text.strip()]
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _get_optional_attr(obj: Any, key: str) -> Any:
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key)
|
||||
return getattr(obj, key, None)
|
||||
|
||||
@staticmethod
|
||||
def _as_int(value: Any) -> int | None:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
return None
|
||||
|
||||
@@ -5,9 +5,11 @@ from dataclasses import field
|
||||
|
||||
from .documents import DocumentService
|
||||
from .jobs import JobService
|
||||
from .transcription import TranscriptionService
|
||||
from .people import PeopleService
|
||||
from .prompts import PromptStore
|
||||
from .sources import SourceService
|
||||
|
||||
__all__ = ["DocumentService", "JobService", "ServiceBundle", "TranscriptionService"]
|
||||
__all__ = ["DocumentService", "JobService", "PeopleService", "PromptStore", "ServiceBundle", "SourceService"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -15,5 +17,6 @@ class ServiceBundle:
|
||||
"""Container for all service instances."""
|
||||
|
||||
documents: DocumentService = field(default_factory=DocumentService)
|
||||
sources: SourceService = field(default_factory=SourceService)
|
||||
jobs: JobService = field(default_factory=JobService)
|
||||
transcriptions: TranscriptionService = field(default_factory=TranscriptionService)
|
||||
people: PeopleService = field(default_factory=PeopleService)
|
||||
|
||||
@@ -8,7 +8,8 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..config import Settings
|
||||
from ..config import get_settings
|
||||
from ..db.runtime import get_session_factory
|
||||
from ..db.session import resolve_session_factory
|
||||
from ..db.session import session_scope
|
||||
|
||||
|
||||
class ServiceBase(ABC):
|
||||
@@ -22,21 +23,20 @@ class ServiceBase(ABC):
|
||||
self,
|
||||
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||
queue: asyncio.Queue | None = None,
|
||||
settings: Settings | None = None,
|
||||
):
|
||||
self.settings = get_settings()
|
||||
self.session_factory = session_factory or get_session_factory()
|
||||
self.settings = settings or get_settings()
|
||||
self.session_factory = session_factory or resolve_session_factory(settings=self.settings)
|
||||
self.queue = queue or asyncio.Queue()
|
||||
|
||||
@asynccontextmanager
|
||||
async def _session_scope(self, session: AsyncSession | None = None):
|
||||
"""Provide a transactional scope around a series of operations."""
|
||||
if session is not None:
|
||||
# Reuse the provided session if one is passed in
|
||||
yield session
|
||||
else:
|
||||
# Otherwise, create a new session for this scope
|
||||
async with self.session_factory() as new_session:
|
||||
yield new_session
|
||||
async with session_scope(
|
||||
session_factory=self.session_factory,
|
||||
session=session,
|
||||
) as active_session:
|
||||
yield active_session
|
||||
|
||||
async def _finalize(
|
||||
self,
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import logging
|
||||
import shutil
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import col
|
||||
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 DocumentType
|
||||
from ..errors import AppError
|
||||
from ..errors import ErrorCategory
|
||||
from ..models import Document
|
||||
from .base import ServiceBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -25,27 +31,68 @@ class MissingSourceError(DocumentError):
|
||||
"""Raised when a document has no associated sources."""
|
||||
|
||||
|
||||
class UploadError(DocumentError):
|
||||
"""Raised when uploaded content cannot be persisted safely."""
|
||||
|
||||
|
||||
class DocumentAlreadyExistsError(DocumentError):
|
||||
"""Raised when a document with the same name already exists in the database."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UploadJobResult:
|
||||
"""Summary of created upload records."""
|
||||
class DocumentDeleteBlockedError(DocumentError):
|
||||
"""Raised when a document delete is blocked by dependent records."""
|
||||
|
||||
document_id: UUID
|
||||
job_id: UUID
|
||||
stored_path: Path
|
||||
original_filename: str
|
||||
|
||||
class DocumentTypeError(DocumentError):
|
||||
"""Raised when Document Type maintenance fails."""
|
||||
|
||||
|
||||
def _normalize_registry_label(label: str) -> str:
|
||||
normalized = label.strip()
|
||||
if not normalized:
|
||||
raise DocumentTypeError(
|
||||
"Document Type label is required",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Enter a user-facing label and retry.",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _document_type_label_key(label: str) -> str:
|
||||
return _normalize_registry_label(label).casefold()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentTypeSummary:
|
||||
"""Settings read model for a Document Type and its usage count."""
|
||||
|
||||
id: UUID
|
||||
label: str
|
||||
is_active: bool
|
||||
document_count: int
|
||||
|
||||
|
||||
class DocumentService(ServiceBase):
|
||||
"""Thin service class for managing documents in the database."""
|
||||
|
||||
async def _validate_document_type(self, *, session: AsyncSession, document: Document) -> None:
|
||||
"""Validate the UUID-backed Document Type reference."""
|
||||
if document.document_type_id is None:
|
||||
return
|
||||
if await session.get(DocumentType, document.document_type_id) is None:
|
||||
raise DocumentError(
|
||||
f"Document type with id {document.document_type_id} not found",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Select a valid document type and retry.",
|
||||
)
|
||||
|
||||
async def _get_document_or_raise(self, *, session: AsyncSession, document_id: UUID) -> Document:
|
||||
"""Get a document by id or raise a not-found service error."""
|
||||
document = await session.get(Document, document_id)
|
||||
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
|
||||
|
||||
#
|
||||
# CRUD Operations
|
||||
#
|
||||
@@ -58,6 +105,7 @@ class DocumentService(ServiceBase):
|
||||
) -> Document:
|
||||
"""Create a new document in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
await self._validate_document_type(session=_session, document=document)
|
||||
_session.add(document)
|
||||
try:
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(document,))
|
||||
@@ -100,16 +148,66 @@ 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:
|
||||
await self._validate_document_type(session=_session, document=document)
|
||||
document.updated_at = datetime.now(UTC)
|
||||
merged = await _session.merge(document)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||
return merged
|
||||
|
||||
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
|
||||
"""Delete a document from the database."""
|
||||
document_id = document.id
|
||||
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]
|
||||
selectinload(Document.document_people), # 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.",
|
||||
)
|
||||
|
||||
for link in list(existing.document_people):
|
||||
await _session.delete(link)
|
||||
|
||||
await _session.delete(existing)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
self._delete_document_storage_folder(document_id=document_id)
|
||||
|
||||
def _delete_document_storage_folder(self, *, document_id: UUID) -> None:
|
||||
"""Best-effort cleanup for document-scoped source storage."""
|
||||
document_dir = self.settings.upload_dir / "documents" / str(document_id)
|
||||
if not document_dir.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
shutil.rmtree(document_dir)
|
||||
logger.info("Deleted document storage folder: %s", document_dir)
|
||||
except OSError:
|
||||
logger.warning("Failed to delete document storage folder: %s", document_dir)
|
||||
|
||||
# Query Operations
|
||||
|
||||
async def query_documents(
|
||||
@@ -124,7 +222,219 @@ class DocumentService(ServiceBase):
|
||||
return result.all()
|
||||
|
||||
async def list_documents(self, *, session: AsyncSession | None = None) -> Sequence[Document]:
|
||||
"""List all documents in the database."""
|
||||
"""List documents with relations needed by the archival table."""
|
||||
async with self._session_scope(session) as _session:
|
||||
result = await _session.exec(select(Document))
|
||||
query = select(Document).options(
|
||||
selectinload(Document.document_people).selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
|
||||
selectinload(Document.document_people).selectinload(DocumentPerson.role_ref), # pyright: ignore[reportArgumentType]
|
||||
selectinload(Document.document_type_ref), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
result = await _session.exec(query)
|
||||
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]
|
||||
selectinload(Document.document_people).selectinload(DocumentPerson.role_ref), # pyright: ignore[reportArgumentType]
|
||||
selectinload(Document.document_type_ref), # 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_document_types(
|
||||
self,
|
||||
*,
|
||||
active_only: bool = True,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Sequence[DocumentType]:
|
||||
"""List configured document types."""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = select(DocumentType)
|
||||
if active_only:
|
||||
query = query.where(col(DocumentType.is_active).is_(True))
|
||||
query = query.order_by(col(DocumentType.normalized_label), col(DocumentType.id))
|
||||
result = await _session.exec(query)
|
||||
return result.all()
|
||||
|
||||
async def list_document_type_summaries(
|
||||
self,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Sequence[DocumentTypeSummary]:
|
||||
"""List Document Types alphabetically with current usage counts."""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = (
|
||||
select(DocumentType, func.count(col(Document.id)))
|
||||
.outerjoin(Document, col(Document.document_type_id) == col(DocumentType.id))
|
||||
.group_by(col(DocumentType.id))
|
||||
.order_by(col(DocumentType.normalized_label), col(DocumentType.id))
|
||||
)
|
||||
rows = (await _session.exec(query)).all()
|
||||
return [
|
||||
DocumentTypeSummary(
|
||||
id=document_type.id,
|
||||
label=document_type.label,
|
||||
is_active=document_type.is_active,
|
||||
document_count=int(document_count),
|
||||
)
|
||||
for document_type, document_count in rows
|
||||
]
|
||||
|
||||
async def create_document_type(
|
||||
self,
|
||||
*,
|
||||
label: str,
|
||||
is_active: bool = True,
|
||||
session: AsyncSession | None = None,
|
||||
) -> DocumentType:
|
||||
"""Create a UUID-identified Document Type with a unique label."""
|
||||
document_type = DocumentType(
|
||||
label=_normalize_registry_label(label),
|
||||
normalized_label=_document_type_label_key(label),
|
||||
is_active=is_active,
|
||||
)
|
||||
async with self._session_scope(session) as _session:
|
||||
_session.add(document_type)
|
||||
try:
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(document_type,))
|
||||
except IntegrityError as exc:
|
||||
raise DocumentTypeError(
|
||||
f"Document Type label {document_type.label!r} already exists",
|
||||
category=ErrorCategory.CONFLICT,
|
||||
suggestion="Choose a different label or edit the existing type.",
|
||||
) from exc
|
||||
return document_type
|
||||
|
||||
async def read_document_type(
|
||||
self,
|
||||
document_type_id: UUID,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> DocumentType:
|
||||
"""Read a Document Type by id."""
|
||||
async with self._session_scope(session) as _session:
|
||||
document_type = await _session.get(DocumentType, document_type_id)
|
||||
if document_type is None:
|
||||
raise DocumentTypeError(
|
||||
f"Document Type with id {document_type_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Refresh Settings and select an available Document Type.",
|
||||
)
|
||||
return document_type
|
||||
|
||||
async def update_document_type(
|
||||
self,
|
||||
document_type_id: UUID,
|
||||
*,
|
||||
label: str,
|
||||
is_active: bool,
|
||||
session: AsyncSession | None = None,
|
||||
) -> DocumentType:
|
||||
"""Update a Document Type label and active state."""
|
||||
async with self._session_scope(session) as _session:
|
||||
document_type = await _session.get(DocumentType, document_type_id)
|
||||
if document_type is None:
|
||||
raise DocumentTypeError(
|
||||
f"Document Type with id {document_type_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Refresh Settings and select an available Document Type.",
|
||||
)
|
||||
document_type.label = _normalize_registry_label(label)
|
||||
document_type.normalized_label = _document_type_label_key(label)
|
||||
document_type.is_active = is_active
|
||||
document_type.updated_at = datetime.now(UTC)
|
||||
try:
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(document_type,))
|
||||
except IntegrityError as exc:
|
||||
raise DocumentTypeError(
|
||||
f"Document Type label {document_type.label!r} already exists",
|
||||
category=ErrorCategory.CONFLICT,
|
||||
suggestion="Choose a different label or edit the existing type.",
|
||||
) from exc
|
||||
return document_type
|
||||
|
||||
async def delete_document_type(
|
||||
self,
|
||||
document_type_id: UUID,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Delete an unreferenced Document Type without cascade behavior."""
|
||||
async with self._session_scope(session) as _session:
|
||||
document_type = await _session.get(DocumentType, document_type_id)
|
||||
if document_type is None:
|
||||
raise DocumentTypeError(
|
||||
f"Document Type with id {document_type_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Refresh Settings and select an available Document Type.",
|
||||
)
|
||||
if await self._document_type_is_referenced(session=_session, document_type=document_type):
|
||||
raise DocumentTypeError(
|
||||
f"Document Type {document_type.label!r} is referenced and cannot be deleted",
|
||||
category=ErrorCategory.CONFLICT,
|
||||
suggestion="Deactivate the type instead; historical Documents will retain it.",
|
||||
)
|
||||
await _session.delete(document_type)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
async def is_document_type_referenced(
|
||||
self,
|
||||
document_type_id: UUID,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> bool:
|
||||
"""Return whether a Document references a Document Type."""
|
||||
async with self._session_scope(session) as _session:
|
||||
document_type = await _session.get(DocumentType, document_type_id)
|
||||
if document_type is None:
|
||||
raise DocumentTypeError(
|
||||
f"Document Type with id {document_type_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Refresh Settings and select an available Document Type.",
|
||||
)
|
||||
return await self._document_type_is_referenced(
|
||||
session=_session,
|
||||
document_type=document_type,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _document_type_is_referenced(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
document_type: DocumentType,
|
||||
) -> bool:
|
||||
reference = (
|
||||
await session.exec(select(Document.id).where(Document.document_type_id == document_type.id))
|
||||
).first()
|
||||
return reference is not None
|
||||
|
||||
async def set_document_type(
|
||||
self,
|
||||
*,
|
||||
document_id: UUID,
|
||||
document_type_id: UUID,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Document:
|
||||
"""Set a Document Type by UUID."""
|
||||
async with self._session_scope(session) as _session:
|
||||
document = await self._get_document_or_raise(session=_session, document_id=document_id)
|
||||
document.document_type_id = document_type_id
|
||||
await self._validate_document_type(session=_session, document=document)
|
||||
document.updated_at = datetime.now(UTC)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(document,))
|
||||
return document
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..models import Job
|
||||
from ..models import JobStatus
|
||||
from ..models import Source
|
||||
from ..db.models import ExecutionAttempt
|
||||
from ..db.models import Job
|
||||
from ..db.models import JobSource
|
||||
from ..db.models import JobSourceStatus
|
||||
from ..db.models import JobStatus
|
||||
from ..db.models import ProcessingArtifact
|
||||
from ..db.models import Source
|
||||
from ..errors import AppError
|
||||
from ..errors import ErrorCategory
|
||||
from .base import ServiceBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JobDeleteBlockedError(AppError):
|
||||
"""Raised when a job delete operation is blocked by lifecycle policy."""
|
||||
|
||||
|
||||
class JobCancelBlockedError(AppError):
|
||||
"""Raised when a job cancel operation is blocked by lifecycle policy."""
|
||||
|
||||
|
||||
class JobResubmitBlockedError(AppError):
|
||||
"""Raised when a job resubmit operation is blocked by lifecycle policy."""
|
||||
|
||||
|
||||
class JobNotFoundError(AppError):
|
||||
"""Raised when a requested Job does not exist."""
|
||||
|
||||
|
||||
class JobService(ServiceBase):
|
||||
"""Thin service class for managing jobs in the database."""
|
||||
@@ -38,14 +65,14 @@ 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)
|
||||
)
|
||||
job = (await _session.exec(query)).first()
|
||||
if job is None:
|
||||
raise ValueError(f"Job with id {job_id} not found")
|
||||
raise self._not_found(job_id)
|
||||
return job
|
||||
|
||||
async def update_job(self, job: Job, session: AsyncSession | None = None) -> Job:
|
||||
@@ -74,12 +101,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 +121,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()
|
||||
@@ -132,7 +159,7 @@ class JobService(ServiceBase):
|
||||
)
|
||||
job = (await _session.exec(query)).first()
|
||||
if job is None:
|
||||
raise ValueError(f"Job with id {job_id} not found")
|
||||
raise self._not_found(job_id)
|
||||
job.status = status
|
||||
if retry_count_increment:
|
||||
job.retry_count += retry_count_increment
|
||||
@@ -151,10 +178,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()
|
||||
|
||||
@@ -170,11 +198,7 @@ class JobService(ServiceBase):
|
||||
``stale_before`` are considered stale and re-queued.
|
||||
"""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = (
|
||||
select(Job)
|
||||
.where(Job.status == JobStatus.PROCESSING)
|
||||
.where(Job.date_updated < stale_before)
|
||||
)
|
||||
query = select(Job).where(Job.status == JobStatus.PROCESSING).where(Job.date_updated < stale_before)
|
||||
stale_jobs = (await _session.exec(query)).all()
|
||||
if not stale_jobs:
|
||||
return 0
|
||||
@@ -186,3 +210,213 @@ 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))
|
||||
.where(Job.id == job_id)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
job = (await _session.exec(query)).first()
|
||||
if job is None:
|
||||
raise self._not_found(job_id)
|
||||
|
||||
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.",
|
||||
)
|
||||
attempt_count = (
|
||||
await _session.exec(
|
||||
select(func.count())
|
||||
.select_from(ExecutionAttempt)
|
||||
.where(ExecutionAttempt.job_id == job_id)
|
||||
)
|
||||
).one()
|
||||
if attempt_count:
|
||||
raise JobDeleteBlockedError(
|
||||
"Job delete blocked because immutable execution evidence exists",
|
||||
category=ErrorCategory.CONFLICT,
|
||||
suggestion=(
|
||||
"Retain the Job as processing history. Evidence deletion requires "
|
||||
"an explicit retention workflow."
|
||||
),
|
||||
)
|
||||
|
||||
for job_source in list(job.job_sources):
|
||||
await _session.delete(job_source)
|
||||
|
||||
await _session.delete(job)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
async def delete_job_and_evidence(self, *, job_id: UUID) -> None:
|
||||
"""Explicitly delete a terminal job and all evidence owned by its attempts."""
|
||||
external_references: list[str] = []
|
||||
async with self._session_scope() as session:
|
||||
job = (
|
||||
await session.exec(
|
||||
select(Job)
|
||||
.options(selectinload(Job.job_sources))
|
||||
.where(Job.id == job_id)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
).first()
|
||||
if job is None:
|
||||
raise self._not_found(job_id)
|
||||
if job.status == JobStatus.PROCESSING:
|
||||
raise JobDeleteBlockedError(
|
||||
"Job delete blocked while status is processing",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Wait for processing to complete, or cancel it before deleting evidence.",
|
||||
)
|
||||
|
||||
attempts = list(
|
||||
(
|
||||
await session.exec(
|
||||
select(ExecutionAttempt).where(ExecutionAttempt.job_id == job_id)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if attempts:
|
||||
attempt_ids = [attempt.id for attempt in attempts]
|
||||
artifacts = list(
|
||||
(
|
||||
await session.exec(
|
||||
select(ProcessingArtifact).where(
|
||||
ProcessingArtifact.execution_attempt_id.in_(attempt_ids)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
external_references = [
|
||||
artifact.external_reference
|
||||
for artifact in artifacts
|
||||
if artifact.external_reference is not None
|
||||
]
|
||||
for artifact in artifacts:
|
||||
await session.delete(artifact)
|
||||
await session.flush()
|
||||
for attempt in attempts:
|
||||
await session.delete(attempt)
|
||||
await session.flush()
|
||||
|
||||
for job_source in list(job.job_sources):
|
||||
await session.delete(job_source)
|
||||
await session.flush()
|
||||
await session.delete(job)
|
||||
await self._finalize(session=session, caller_session=None)
|
||||
|
||||
for external_reference in external_references:
|
||||
self._delete_external_artifact(external_reference)
|
||||
|
||||
def _delete_external_artifact(self, external_reference: str) -> None:
|
||||
relative_path = Path(external_reference)
|
||||
if relative_path.is_absolute() or ".." in relative_path.parts:
|
||||
logger.warning("Skipped unsafe external artifact reference during job deletion: %s", external_reference)
|
||||
return
|
||||
artifact_root = self.settings.artifact_dir.resolve()
|
||||
artifact_path = (artifact_root / relative_path).resolve()
|
||||
if artifact_root not in artifact_path.parents:
|
||||
logger.warning("Skipped external artifact outside configured root: %s", external_reference)
|
||||
return
|
||||
try:
|
||||
artifact_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.warning("Failed to delete external artifact: %s", artifact_path)
|
||||
|
||||
async def cancel_job(self, *, job_id: UUID, session: AsyncSession | None = None) -> Job:
|
||||
"""Cancel a queued/processing job and stop remaining source work."""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = (
|
||||
select(Job)
|
||||
.options(
|
||||
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
.where(Job.id == job_id)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
job = (await _session.exec(query)).first()
|
||||
if job is None:
|
||||
raise self._not_found(job_id)
|
||||
|
||||
if job.status in {JobStatus.TRANSCRIBED, JobStatus.COMPLETED}:
|
||||
raise JobCancelBlockedError(
|
||||
"Job cancel is not allowed for transcribed/completed jobs",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Use resubmit for reprocessing needs, or leave the terminal job unchanged.",
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
job.status = JobStatus.FAILED
|
||||
job.date_updated = now
|
||||
|
||||
for job_source in job.job_sources:
|
||||
if job_source.status == JobSourceStatus.TRANSCRIBED:
|
||||
continue
|
||||
job_source.status = JobSourceStatus.FAILED
|
||||
job_source.raw_transcription = None
|
||||
job_source.error_detail = "Cancelled by user"
|
||||
job_source.executed_at = now
|
||||
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||
return job
|
||||
|
||||
async def resubmit_failed_sources(self, *, job_id: UUID, session: AsyncSession | None = None) -> int:
|
||||
"""Reset failed source executions and queue the job for reprocessing."""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = (
|
||||
select(Job)
|
||||
.options(
|
||||
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
.where(Job.id == job_id)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
job = (await _session.exec(query)).first()
|
||||
if job is None:
|
||||
raise self._not_found(job_id)
|
||||
|
||||
if job.status == JobStatus.PROCESSING:
|
||||
raise JobResubmitBlockedError(
|
||||
"Job resubmit is blocked while processing is active",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Cancel processing first, then resubmit remaining sources.",
|
||||
)
|
||||
|
||||
candidates = [job_source for job_source in job.job_sources if job_source.status == JobSourceStatus.FAILED]
|
||||
if not candidates:
|
||||
raise JobResubmitBlockedError(
|
||||
"Job has no failed sources to resubmit",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Only failed sources can be resubmitted.",
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
for job_source in candidates:
|
||||
job_source.status = JobSourceStatus.PENDING
|
||||
job_source.raw_transcription = None
|
||||
job_source.error_detail = None
|
||||
job_source.executed_at = now
|
||||
|
||||
job.status = JobStatus.QUEUED
|
||||
job.date_updated = now
|
||||
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||
return len(candidates)
|
||||
|
||||
@staticmethod
|
||||
def _not_found(job_id: UUID) -> JobNotFoundError:
|
||||
return JobNotFoundError(
|
||||
f"Job with id {job_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the Job id and retry.",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
"""People, relationship role, and document-person link services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..config import Settings
|
||||
from ..config import get_settings
|
||||
from ..db.models import Document
|
||||
from ..db.models import DocumentPerson
|
||||
from ..db.models import DocumentPersonRole
|
||||
from ..db.models import Person
|
||||
from ..db.models import PersonRole
|
||||
from ..errors import AppError
|
||||
from ..errors import ErrorCategory
|
||||
from .base import ServiceBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PORTRAIT_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"})
|
||||
FAMILY_SEARCH_ID_PATTERN = re.compile(r"^[A-Z0-9]{4}-[A-Z0-9]{3}$")
|
||||
|
||||
|
||||
class PeopleError(AppError):
|
||||
"""Raised when a Person or document-person relationship operation fails."""
|
||||
|
||||
|
||||
class PersonMediaError(PeopleError):
|
||||
"""Raised when Person portrait media cannot be validated or persisted."""
|
||||
|
||||
|
||||
class PersonRoleError(PeopleError):
|
||||
"""Raised when Person Role maintenance fails."""
|
||||
|
||||
|
||||
REGISTRY_CODE_PATTERN = re.compile(r"^[a-z0-9_]+$")
|
||||
|
||||
|
||||
def _normalize_role_code(code: str) -> str:
|
||||
normalized = code.strip().lower()
|
||||
if not normalized or not REGISTRY_CODE_PATTERN.fullmatch(normalized):
|
||||
raise PersonRoleError(
|
||||
"Person Role code must contain only lowercase letters, numbers, and underscores",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Enter a stable code such as witness or record_keeper.",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_role_label(label: str) -> str:
|
||||
normalized = label.strip()
|
||||
if not normalized:
|
||||
raise PersonRoleError(
|
||||
"Person Role label is required",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Enter a user-facing label and retry.",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_family_search_id(value: str | None) -> str | None:
|
||||
"""Normalize and validate a FamilySearch tree person identifier."""
|
||||
normalized = (value or "").strip().upper()
|
||||
if not normalized:
|
||||
return None
|
||||
if not FAMILY_SEARCH_ID_PATTERN.fullmatch(normalized):
|
||||
raise PeopleError(
|
||||
"FamilySearch ID must use the format XXXX-XXX",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Enter the seven-character FamilySearch person ID, including its hyphen.",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
class PeopleService(ServiceBase):
|
||||
"""Manage People, relationship roles, and document-person links."""
|
||||
|
||||
async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
|
||||
async with self._session_scope(session) as _session:
|
||||
person.family_search_id = normalize_family_search_id(person.family_search_id)
|
||||
_session.add(person)
|
||||
try:
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(person,))
|
||||
except IntegrityError as exc:
|
||||
raise self._family_search_conflict(person.family_search_id) from exc
|
||||
return person
|
||||
|
||||
async def read_person(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
|
||||
async with self._session_scope(session) as _session:
|
||||
person = await _session.get(Person, person_id)
|
||||
if person is None:
|
||||
raise self._not_found(f"Person with id {person_id} not found")
|
||||
return person
|
||||
|
||||
async def update_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
|
||||
async with self._session_scope(session) as _session:
|
||||
person.family_search_id = normalize_family_search_id(person.family_search_id)
|
||||
person.updated_at = datetime.now(UTC)
|
||||
merged = await _session.merge(person)
|
||||
try:
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||
except IntegrityError as exc:
|
||||
raise self._family_search_conflict(person.family_search_id) from exc
|
||||
return merged
|
||||
|
||||
async def delete_person(self, person: Person, *, session: AsyncSession | None = None) -> None:
|
||||
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 self._not_found(f"Person with id {person.id} not found")
|
||||
for link in list(existing.document_people):
|
||||
await _session.delete(link)
|
||||
await _session.delete(existing)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
async def create_document_person(
|
||||
self,
|
||||
document_person: DocumentPerson,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> DocumentPerson:
|
||||
async with self._session_scope(session) as _session:
|
||||
await self._sync_role_fields(session=_session, link=document_person)
|
||||
_session.add(document_person)
|
||||
return await self._finalize_link(session=_session, caller_session=session, link=document_person)
|
||||
|
||||
async def read_document_person(
|
||||
self,
|
||||
document_person_id: UUID,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> DocumentPerson:
|
||||
async with self._session_scope(session) as _session:
|
||||
link = await _session.get(DocumentPerson, document_person_id)
|
||||
if link is None:
|
||||
raise self._not_found(f"DocumentPerson with id {document_person_id} not found")
|
||||
return link
|
||||
|
||||
async def update_document_person(
|
||||
self,
|
||||
document_person: DocumentPerson,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> DocumentPerson:
|
||||
async with self._session_scope(session) as _session:
|
||||
await self._sync_role_fields(session=_session, link=document_person)
|
||||
document_person.updated_at = datetime.now(UTC)
|
||||
merged = await _session.merge(document_person)
|
||||
return await self._finalize_link(session=_session, caller_session=session, link=merged)
|
||||
|
||||
async def delete_document_person(
|
||||
self,
|
||||
document_person: DocumentPerson,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
async with self._session_scope(session) as _session:
|
||||
await _session.delete(document_person)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
async def read_person_detail(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
|
||||
async with self._session_scope(session) as _session:
|
||||
query = (
|
||||
select(Person)
|
||||
.options(
|
||||
selectinload(Person.document_people).selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
|
||||
selectinload(Person.document_people).selectinload(DocumentPerson.role_ref), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
.where(Person.id == person_id)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
person = (await _session.exec(query)).first()
|
||||
if person is None:
|
||||
raise self._not_found(f"Person with id {person_id} not found")
|
||||
return person
|
||||
|
||||
async def list_people(self, *, session: AsyncSession | None = None) -> Sequence[Person]:
|
||||
async with self._session_scope(session) as _session:
|
||||
return (await _session.exec(select(Person))).all()
|
||||
|
||||
async def list_person_roles(
|
||||
self,
|
||||
*,
|
||||
active_only: bool = True,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Sequence[PersonRole]:
|
||||
async with self._session_scope(session) as _session:
|
||||
query = select(PersonRole)
|
||||
if active_only:
|
||||
query = query.where(PersonRole.is_active.is_(True))
|
||||
return (await _session.exec(query.order_by(PersonRole.label, PersonRole.code))).all()
|
||||
|
||||
async def create_person_role(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
label: str,
|
||||
is_active: bool = True,
|
||||
session: AsyncSession | None = None,
|
||||
) -> PersonRole:
|
||||
"""Create a Person Role with an immutable normalized code."""
|
||||
role = PersonRole(
|
||||
code=_normalize_role_code(code),
|
||||
label=_normalize_role_label(label),
|
||||
is_active=is_active,
|
||||
)
|
||||
async with self._session_scope(session) as _session:
|
||||
_session.add(role)
|
||||
try:
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(role,))
|
||||
except IntegrityError as exc:
|
||||
raise PersonRoleError(
|
||||
f"Person Role code {role.code!r} already exists",
|
||||
category=ErrorCategory.CONFLICT,
|
||||
suggestion="Choose a different stable code or edit the existing role.",
|
||||
) from exc
|
||||
return role
|
||||
|
||||
async def read_person_role(
|
||||
self,
|
||||
person_role_id: UUID,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> PersonRole:
|
||||
"""Read a Person Role by id."""
|
||||
async with self._session_scope(session) as _session:
|
||||
role = await _session.get(PersonRole, person_role_id)
|
||||
if role is None:
|
||||
raise PersonRoleError(
|
||||
f"Person Role with id {person_role_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Refresh Settings and select an available Person Role.",
|
||||
)
|
||||
return role
|
||||
|
||||
async def update_person_role(
|
||||
self,
|
||||
person_role_id: UUID,
|
||||
*,
|
||||
label: str,
|
||||
is_active: bool,
|
||||
session: AsyncSession | None = None,
|
||||
) -> PersonRole:
|
||||
"""Update mutable Person Role fields without changing its code."""
|
||||
async with self._session_scope(session) as _session:
|
||||
role = await _session.get(PersonRole, person_role_id)
|
||||
if role is None:
|
||||
raise PersonRoleError(
|
||||
f"Person Role with id {person_role_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Refresh Settings and select an available Person Role.",
|
||||
)
|
||||
role.label = _normalize_role_label(label)
|
||||
role.is_active = is_active
|
||||
role.updated_at = datetime.now(UTC)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(role,))
|
||||
return role
|
||||
|
||||
async def delete_person_role(
|
||||
self,
|
||||
person_role_id: UUID,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Delete an unreferenced Person Role without cascade behavior."""
|
||||
async with self._session_scope(session) as _session:
|
||||
role = await _session.get(PersonRole, person_role_id)
|
||||
if role is None:
|
||||
raise PersonRoleError(
|
||||
f"Person Role with id {person_role_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Refresh Settings and select an available Person Role.",
|
||||
)
|
||||
if await self._person_role_is_referenced(session=_session, role=role):
|
||||
raise PersonRoleError(
|
||||
f"Person Role {role.label!r} is referenced and cannot be deleted",
|
||||
category=ErrorCategory.CONFLICT,
|
||||
suggestion="Deactivate the role instead; historical relationships will retain it.",
|
||||
)
|
||||
await _session.delete(role)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
async def is_person_role_referenced(
|
||||
self,
|
||||
person_role_id: UUID,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> bool:
|
||||
"""Return whether canonical or compatibility data references a Person Role."""
|
||||
async with self._session_scope(session) as _session:
|
||||
role = await _session.get(PersonRole, person_role_id)
|
||||
if role is None:
|
||||
raise PersonRoleError(
|
||||
f"Person Role with id {person_role_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Refresh Settings and select an available Person Role.",
|
||||
)
|
||||
return await self._person_role_is_referenced(session=_session, role=role)
|
||||
|
||||
@staticmethod
|
||||
async def _person_role_is_referenced(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
role: PersonRole,
|
||||
) -> bool:
|
||||
reference = (
|
||||
await session.exec(
|
||||
select(DocumentPerson.id).where(
|
||||
(DocumentPerson.role_id == role.id) | (DocumentPerson.role == role.code)
|
||||
)
|
||||
)
|
||||
).first()
|
||||
return reference is not None
|
||||
|
||||
async def list_document_people(
|
||||
self,
|
||||
*,
|
||||
document_id: UUID | None = None,
|
||||
person_id: UUID | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Sequence[DocumentPerson]:
|
||||
async with self._session_scope(session) as _session:
|
||||
query = select(DocumentPerson).options(
|
||||
selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
|
||||
selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
|
||||
selectinload(DocumentPerson.role_ref), # 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)
|
||||
return (await _session.exec(query)).all()
|
||||
|
||||
async def add_document_person_link(
|
||||
self,
|
||||
*,
|
||||
document_id: UUID,
|
||||
person_id: UUID,
|
||||
role_id: UUID | None = None,
|
||||
role_code: str | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> DocumentPerson:
|
||||
async with self._session_scope(session) as _session:
|
||||
await self._require_document(session=_session, document_id=document_id)
|
||||
if await _session.get(Person, person_id) is None:
|
||||
raise self._not_found(f"Person with id {person_id} not found")
|
||||
|
||||
link = DocumentPerson(
|
||||
document_id=document_id,
|
||||
person_id=person_id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
role_id=role_id,
|
||||
)
|
||||
if role_code and role_code.strip():
|
||||
link.role = self._legacy_role(role_code)
|
||||
await self._sync_role_fields(session=_session, link=link)
|
||||
_session.add(link)
|
||||
return await self._finalize_link(session=_session, caller_session=session, link=link)
|
||||
|
||||
async def set_document_person_role(
|
||||
self,
|
||||
*,
|
||||
document_person_id: UUID,
|
||||
role_id: UUID | None = None,
|
||||
role_code: str | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> DocumentPerson:
|
||||
async with self._session_scope(session) as _session:
|
||||
link = await _session.get(DocumentPerson, document_person_id)
|
||||
if link is None:
|
||||
raise self._not_found(f"DocumentPerson with id {document_person_id} not found")
|
||||
if role_id is None and not (role_code or "").strip():
|
||||
raise PeopleError(
|
||||
"Either role_id or role_code is required",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Provide a valid role id or code and retry.",
|
||||
)
|
||||
if role_id is not None and (role_code or "").strip():
|
||||
raise PeopleError(
|
||||
"Provide role_id or role_code, not both",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Send only one relationship role selector and retry.",
|
||||
)
|
||||
|
||||
link.role_id = role_id
|
||||
if role_code and role_code.strip():
|
||||
link.role = self._legacy_role(role_code)
|
||||
await self._sync_role_fields(session=_session, link=link)
|
||||
link.updated_at = datetime.now(UTC)
|
||||
return await self._finalize_link(session=_session, caller_session=session, link=link)
|
||||
|
||||
async def remove_document_person_link(
|
||||
self,
|
||||
*,
|
||||
document_person_id: UUID,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
async with self._session_scope(session) as _session:
|
||||
link = await _session.get(DocumentPerson, document_person_id)
|
||||
if link is None:
|
||||
raise self._not_found(f"DocumentPerson with id {document_person_id} not found")
|
||||
await _session.delete(link)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
async def _resolve_or_create_role(
|
||||
self,
|
||||
*,
|
||||
session: AsyncSession,
|
||||
role_id: UUID | None,
|
||||
role_code: str,
|
||||
) -> PersonRole:
|
||||
if role_id is not None:
|
||||
role = await session.get(PersonRole, role_id)
|
||||
if role is None:
|
||||
raise PeopleError(
|
||||
f"Person role with id {role_id} not found",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Select a valid relationship role and retry.",
|
||||
)
|
||||
return role
|
||||
|
||||
normalized_code = role_code.strip().lower() or DocumentPersonRole.AUTHOR.value
|
||||
role = (await session.exec(select(PersonRole).where(PersonRole.code == normalized_code))).first()
|
||||
if role is None:
|
||||
role = PersonRole(code=normalized_code, label=normalized_code.replace("_", " ").title())
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
return role
|
||||
|
||||
async def _sync_role_fields(self, *, session: AsyncSession, link: DocumentPerson) -> None:
|
||||
role_code = link.role.value if isinstance(link.role, DocumentPersonRole) else str(link.role)
|
||||
role = await self._resolve_or_create_role(session=session, role_id=link.role_id, role_code=role_code)
|
||||
link.role_id = role.id
|
||||
link.role = self._legacy_role(role.code)
|
||||
|
||||
async def _finalize_link(
|
||||
self,
|
||||
*,
|
||||
session: AsyncSession,
|
||||
caller_session: AsyncSession | None,
|
||||
link: DocumentPerson,
|
||||
) -> DocumentPerson:
|
||||
try:
|
||||
await self._finalize(session=session, caller_session=caller_session, refresh=(link,))
|
||||
except IntegrityError as exc:
|
||||
raise PeopleError(
|
||||
"Duplicate relationship link for document/person/role",
|
||||
category=ErrorCategory.CONFLICT,
|
||||
suggestion="Remove the existing relationship link or choose a different role.",
|
||||
) from exc
|
||||
return link
|
||||
|
||||
async def _require_document(self, *, session: AsyncSession, document_id: UUID) -> None:
|
||||
if await session.get(Document, document_id) is None:
|
||||
raise self._not_found(f"Document with id {document_id} not found")
|
||||
|
||||
@staticmethod
|
||||
def _family_search_conflict(family_search_id: str | None) -> PeopleError:
|
||||
return PeopleError(
|
||||
f"FamilySearch ID {family_search_id} is already assigned to another person",
|
||||
category=ErrorCategory.CONFLICT,
|
||||
suggestion="Open the existing person record or enter a different FamilySearch ID.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _legacy_role(role_code: str) -> str:
|
||||
return _normalize_role_code(role_code)
|
||||
|
||||
@staticmethod
|
||||
def _not_found(message: str) -> PeopleError:
|
||||
return PeopleError(
|
||||
message,
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the requested Person or relationship id and retry.",
|
||||
)
|
||||
|
||||
|
||||
def store_person_portrait(
|
||||
*,
|
||||
person_id: UUID,
|
||||
filename: str,
|
||||
file_bytes: bytes,
|
||||
settings: Settings | None = None,
|
||||
) -> Path:
|
||||
"""Persist Person portrait media under persons/<person_id>."""
|
||||
if not file_bytes:
|
||||
raise PersonMediaError(
|
||||
"Person portrait content is empty",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Select a non-empty portrait file and retry.",
|
||||
)
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in PORTRAIT_EXTENSIONS:
|
||||
raise PersonMediaError(
|
||||
f"Unsupported portrait format: {suffix or '<none>'}",
|
||||
category=ErrorCategory.USER_INPUT,
|
||||
suggestion="Use JPG, JPEG, PNG, GIF, WEBP, BMP, or TIFF portrait media.",
|
||||
)
|
||||
|
||||
runtime_settings = settings or get_settings()
|
||||
target_dir = runtime_settings.upload_dir / "persons" / str(person_id)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
stored_path = target_dir / f"{uuid4()}{suffix}"
|
||||
try:
|
||||
stored_path.write_bytes(file_bytes)
|
||||
except OSError as exc:
|
||||
raise PersonMediaError(
|
||||
"Failed to persist Person portrait media",
|
||||
category=ErrorCategory.INFRA_PERSISTENT,
|
||||
suggestion="Check media directory permissions and available disk space, then retry.",
|
||||
) from exc
|
||||
logger.info("Stored Person portrait media: %s", stored_path)
|
||||
return stored_path
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Constrained storage for mutable prompt Markdown artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from ..config import Settings
|
||||
from ..config import get_settings
|
||||
from ..errors import AppError
|
||||
from ..errors import ErrorCategory
|
||||
|
||||
PROMPT_EXTENSION = ".md"
|
||||
BACKUP_SUFFIX = ".bak"
|
||||
|
||||
|
||||
class PromptStoreError(AppError):
|
||||
"""Raised when prompt storage validation or persistence fails."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PromptSummary:
|
||||
"""Read model for one editable prompt artifact."""
|
||||
|
||||
name: str
|
||||
is_default: bool
|
||||
has_backup: bool
|
||||
|
||||
|
||||
class PromptStore:
|
||||
"""List, read, atomically update, and recover existing prompt files."""
|
||||
|
||||
def __init__(self, settings: Settings | None = None) -> None:
|
||||
self.settings = settings or get_settings()
|
||||
|
||||
def list_prompts(self) -> tuple[PromptSummary, ...]:
|
||||
"""List editable direct-child Markdown prompts by filename."""
|
||||
root = self._prompt_root()
|
||||
try:
|
||||
candidates = tuple(root.iterdir())
|
||||
except OSError as exc:
|
||||
raise self._filesystem_error("Prompt directory could not be read", exc) from exc
|
||||
|
||||
summaries: list[PromptSummary] = []
|
||||
for candidate in candidates:
|
||||
if candidate.suffix.lower() != PROMPT_EXTENSION or not candidate.is_file():
|
||||
continue
|
||||
resolved = candidate.resolve()
|
||||
if resolved.parent != root:
|
||||
continue
|
||||
summaries.append(
|
||||
PromptSummary(
|
||||
name=candidate.name,
|
||||
is_default=candidate.name == self.settings.default_prompt_name,
|
||||
has_backup=self._backup_path(candidate).is_file(),
|
||||
)
|
||||
)
|
||||
return tuple(sorted(summaries, key=lambda item: item.name.casefold()))
|
||||
|
||||
def read_prompt(self, name: str) -> str:
|
||||
"""Read one existing UTF-8 prompt."""
|
||||
path = self._resolve_existing_prompt(name)
|
||||
return self._read_nonempty_text(path, description="Prompt")
|
||||
|
||||
def write_prompt(self, name: str, content: str) -> None:
|
||||
"""Atomically replace an existing prompt and retain one prior version."""
|
||||
path = self._resolve_existing_prompt(name)
|
||||
normalized_content = content.strip()
|
||||
if not normalized_content:
|
||||
raise PromptStoreError(
|
||||
"Prompt content cannot be empty",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Enter prompt text before saving.",
|
||||
)
|
||||
self._atomic_write(path=path, content=f"{normalized_content}\n", preserve_current=True)
|
||||
|
||||
def recover_prompt(self, name: str) -> None:
|
||||
"""Restore the sole previous-version backup as an explicit operation."""
|
||||
path = self._resolve_existing_prompt(name)
|
||||
backup_path = self._backup_path(path)
|
||||
if not backup_path.is_file():
|
||||
raise PromptStoreError(
|
||||
f"No previous version is available for {path.name}",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Save a prompt edit before attempting recovery.",
|
||||
)
|
||||
backup_content = self._read_nonempty_text(backup_path, description="Prompt backup")
|
||||
self._atomic_write(path=path, content=backup_content, preserve_current=True)
|
||||
|
||||
def _prompt_root(self) -> Path:
|
||||
try:
|
||||
root = self.settings.prompt_dir.resolve()
|
||||
except OSError as exc:
|
||||
raise self._filesystem_error("Prompt directory could not be resolved", exc) from exc
|
||||
if not root.is_dir():
|
||||
raise PromptStoreError(
|
||||
f"Prompt directory is unavailable: {root}",
|
||||
category=ErrorCategory.INFRA_PERSISTENT,
|
||||
suggestion="Restore the configured prompt directory and its permissions.",
|
||||
)
|
||||
return root
|
||||
|
||||
def _resolve_existing_prompt(self, name: str) -> Path:
|
||||
normalized_name = name.strip()
|
||||
if (
|
||||
not normalized_name
|
||||
or Path(normalized_name).name != normalized_name
|
||||
or Path(normalized_name).suffix.lower() != PROMPT_EXTENSION
|
||||
):
|
||||
raise PromptStoreError(
|
||||
"Prompt name must be a direct-child Markdown filename",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Select an existing .md prompt from Settings.",
|
||||
)
|
||||
root = self._prompt_root()
|
||||
try:
|
||||
path = (root / normalized_name).resolve()
|
||||
except OSError as exc:
|
||||
raise self._filesystem_error("Prompt path could not be resolved", exc) from exc
|
||||
if path.parent != root:
|
||||
raise PromptStoreError(
|
||||
"Prompt path must remain inside the configured prompt directory",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Select an existing prompt from Settings.",
|
||||
)
|
||||
if not path.is_file():
|
||||
raise PromptStoreError(
|
||||
f"Prompt file not found: {normalized_name}",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Refresh Settings and select an existing prompt.",
|
||||
)
|
||||
return path
|
||||
|
||||
def _read_nonempty_text(self, path: Path, *, description: str) -> str:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except UnicodeError as exc:
|
||||
raise PromptStoreError(
|
||||
f"{description} is not valid UTF-8: {path.name}",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Restore a valid UTF-8 Markdown prompt.",
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise self._filesystem_error(f"{description} could not be read", exc) from exc
|
||||
if not content.strip():
|
||||
raise PromptStoreError(
|
||||
f"{description} is empty: {path.name}",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Restore non-empty prompt content.",
|
||||
)
|
||||
return content
|
||||
|
||||
def _atomic_write(self, *, path: Path, content: str, preserve_current: bool) -> None:
|
||||
temporary_path = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
|
||||
backup_path = self._backup_path(path)
|
||||
backup_temporary_path = backup_path.with_name(f".{backup_path.name}.{uuid4().hex}.tmp")
|
||||
try:
|
||||
self._write_synced(temporary_path, content.encode("utf-8"))
|
||||
if preserve_current:
|
||||
self._write_synced(backup_temporary_path, path.read_bytes())
|
||||
backup_temporary_path.replace(backup_path)
|
||||
temporary_path.replace(path)
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise self._filesystem_error(f"Prompt {path.name} could not be saved", exc) from exc
|
||||
finally:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
backup_temporary_path.unlink(missing_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def _write_synced(path: Path, content: bytes) -> None:
|
||||
with path.open("wb") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
@staticmethod
|
||||
def _backup_path(path: Path) -> Path:
|
||||
return path.with_name(f"{path.name}{BACKUP_SUFFIX}")
|
||||
|
||||
@staticmethod
|
||||
def _filesystem_error(message: str, exc: Exception) -> PromptStoreError:
|
||||
return PromptStoreError(
|
||||
f"{message}: {exc}",
|
||||
category=ErrorCategory.INFRA_PERSISTENT,
|
||||
suggestion="Check prompt directory permissions and available disk space, then retry.",
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
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
|
||||
@@ -11,51 +16,97 @@ from transcription.config import get_settings
|
||||
from transcription.errors import AppError
|
||||
from transcription.errors import ErrorCategory
|
||||
|
||||
from ..models import Document
|
||||
from ..models import Job
|
||||
from ..models import Source
|
||||
from .documents import UploadJobResult
|
||||
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 .sources import TranscriptionError
|
||||
from .sources import validate_source_content
|
||||
from .transcription import build_prompt_execution
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
|
||||
|
||||
class SourceStorageError(AppError):
|
||||
"""Raised when Source content cannot be validated or persisted safely."""
|
||||
|
||||
|
||||
class UploadError(AppError):
|
||||
"""Raised when uploaded content cannot be persisted safely."""
|
||||
UploadError = SourceStorageError
|
||||
|
||||
|
||||
async def create_upload_job(
|
||||
@dataclass(frozen=True)
|
||||
class JobCreateResult:
|
||||
"""Summary of explicit Job create records."""
|
||||
|
||||
document_id: UUID
|
||||
job_id: UUID
|
||||
source_ids: tuple[UUID, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DocumentJobResult:
|
||||
"""Summary of a Document, Source, and Job created together."""
|
||||
|
||||
document_id: UUID
|
||||
job_id: UUID
|
||||
stored_path: Path
|
||||
original_filename: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingStoredSource:
|
||||
"""Pre-staged Source artifact tied to a Source id."""
|
||||
|
||||
source_id: UUID
|
||||
original_filename: str
|
||||
stored_path: Path
|
||||
file_hash: str
|
||||
file_size_bytes: int
|
||||
|
||||
|
||||
async def create_document_job(
|
||||
*,
|
||||
filename: str,
|
||||
file_bytes: bytes,
|
||||
session: AsyncSession,
|
||||
settings: Settings | None = None,
|
||||
) -> UploadJobResult:
|
||||
"""Create upload-backed document and queued job records."""
|
||||
) -> DocumentJobResult:
|
||||
"""Create a Document, its first Source, and a queued Job."""
|
||||
runtime_settings = settings or get_settings()
|
||||
stored_path = store_file(
|
||||
prompt_execution = build_prompt_execution(settings=runtime_settings)
|
||||
document_id = uuid4()
|
||||
source_id = uuid4()
|
||||
stored_path = store_source_file(
|
||||
filename=filename,
|
||||
file_bytes=file_bytes,
|
||||
settings=runtime_settings,
|
||||
relative_directory=Path("documents") / str(document_id),
|
||||
filename_stem=str(source_id),
|
||||
)
|
||||
file_hash, file_size_bytes = _compute_file_metadata(file_bytes)
|
||||
try:
|
||||
document, job = await _create_upload_records(
|
||||
document, job = await _create_document_job_records(
|
||||
session=session,
|
||||
document_id=document_id,
|
||||
source_id=source_id,
|
||||
original_filename=filename,
|
||||
stored_path=stored_path,
|
||||
file_hash=file_hash,
|
||||
file_size_bytes=file_size_bytes,
|
||||
prompt_execution=prompt_execution,
|
||||
)
|
||||
except Exception as exc:
|
||||
_best_effort_delete(stored_path)
|
||||
raise UploadError(
|
||||
"Failed to create upload database records",
|
||||
raise SourceStorageError(
|
||||
"Failed to create Document, Source, and Job records",
|
||||
category=ErrorCategory.INFRA_TRANSIENT,
|
||||
suggestion="Retry upload. If this keeps happening, verify database availability.",
|
||||
retriable=True,
|
||||
) from exc
|
||||
|
||||
logger.info("Created upload job document_id=%s job_id=%s", document.id, job.id)
|
||||
return UploadJobResult(
|
||||
logger.info("Created document job document_id=%s job_id=%s", document.id, job.id)
|
||||
return DocumentJobResult(
|
||||
document_id=document.id,
|
||||
job_id=job.id,
|
||||
stored_path=stored_path,
|
||||
@@ -63,30 +114,122 @@ async def create_upload_job(
|
||||
)
|
||||
|
||||
|
||||
async def _create_upload_records(
|
||||
async def create_job_for_document(
|
||||
*,
|
||||
document_id: UUID,
|
||||
source_files: Sequence[tuple[str, bytes]],
|
||||
session: AsyncSession,
|
||||
provider: str | None = None,
|
||||
model: str | None = None,
|
||||
settings: Settings | None = None,
|
||||
) -> JobCreateResult:
|
||||
"""Create a queued Job for an existing Document with one or more Sources."""
|
||||
if not source_files:
|
||||
raise SourceStorageError(
|
||||
"At least one Source file is required to create a Job",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Upload one or more files and try again.",
|
||||
)
|
||||
|
||||
runtime_settings = settings or get_settings()
|
||||
prompt_execution = build_prompt_execution(settings=runtime_settings)
|
||||
sorted_source_files = sorted(source_files, key=lambda item: Path(item[0]).name.casefold())
|
||||
stored_sources: list[PendingStoredSource] = []
|
||||
for filename, file_bytes in sorted_source_files:
|
||||
source_id = uuid4()
|
||||
stored_sources.append(
|
||||
PendingStoredSource(
|
||||
source_id=source_id,
|
||||
original_filename=filename,
|
||||
stored_path=store_source_file(
|
||||
filename=filename,
|
||||
file_bytes=file_bytes,
|
||||
settings=runtime_settings,
|
||||
relative_directory=Path("documents") / str(document_id),
|
||||
filename_stem=str(source_id),
|
||||
),
|
||||
file_hash=_compute_file_hash(file_bytes),
|
||||
file_size_bytes=len(file_bytes),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
job, source_ids = await _create_job_for_document_records(
|
||||
session=session,
|
||||
document_id=document_id,
|
||||
stored_sources=stored_sources,
|
||||
provider=provider,
|
||||
model=model,
|
||||
prompt_execution=prompt_execution,
|
||||
)
|
||||
except Exception as exc:
|
||||
for source in stored_sources:
|
||||
_best_effort_delete(source.stored_path)
|
||||
raise SourceStorageError(
|
||||
"Failed to create Job records from Source files",
|
||||
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_document_job_records(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
document_id: UUID,
|
||||
source_id: UUID,
|
||||
original_filename: str,
|
||||
stored_path: Path,
|
||||
file_hash: str,
|
||||
file_size_bytes: int,
|
||||
prompt_execution,
|
||||
) -> tuple[Document, Job]:
|
||||
document = Document(
|
||||
id=document_id,
|
||||
name=Path(original_filename).name,
|
||||
)
|
||||
session.add(document)
|
||||
await session.flush()
|
||||
|
||||
job = Job(document_id=document.id)
|
||||
job = Job(
|
||||
document_id=document.id,
|
||||
prompt_name=prompt_execution.prompt_name,
|
||||
prompt_hash=prompt_execution.prompt_hash,
|
||||
system_prompt=prompt_execution.system_prompt,
|
||||
user_prompt=prompt_execution.user_prompt,
|
||||
temperature=prompt_execution.temperature,
|
||||
top_p=prompt_execution.top_p,
|
||||
)
|
||||
session.add(job)
|
||||
await session.flush()
|
||||
|
||||
source = Source(
|
||||
id=source_id,
|
||||
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),
|
||||
file_hash=file_hash,
|
||||
file_size_bytes=file_size_bytes,
|
||||
)
|
||||
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,63 +237,147 @@ async def _create_upload_records(
|
||||
return document, job
|
||||
|
||||
|
||||
async def _create_job_for_document_records(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
document_id: UUID,
|
||||
stored_sources: Sequence[PendingStoredSource],
|
||||
provider: str | None,
|
||||
model: str | None,
|
||||
prompt_execution,
|
||||
) -> tuple[Job, list[UUID]]:
|
||||
document = await session.get(Document, document_id)
|
||||
if document is None:
|
||||
raise SourceStorageError(
|
||||
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_execution.prompt_name,
|
||||
prompt_hash=prompt_execution.prompt_hash,
|
||||
system_prompt=prompt_execution.system_prompt,
|
||||
user_prompt=prompt_execution.user_prompt,
|
||||
temperature=prompt_execution.temperature,
|
||||
top_p=prompt_execution.top_p,
|
||||
)
|
||||
session.add(job)
|
||||
await session.flush()
|
||||
|
||||
source_ids: list[UUID] = []
|
||||
for page_offset, stored_source in enumerate(stored_sources):
|
||||
source = Source(
|
||||
id=stored_source.source_id,
|
||||
document_id=document_id,
|
||||
page_number=next_page_number + page_offset,
|
||||
upload_name=Path(stored_source.original_filename).name,
|
||||
filename=stored_source.stored_path.name,
|
||||
file_path=str(stored_source.stored_path),
|
||||
file_hash=stored_source.file_hash,
|
||||
file_size_bytes=stored_source.file_size_bytes,
|
||||
)
|
||||
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():
|
||||
path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to clean up upload file after DB error: %s", path)
|
||||
logger.warning("Failed to clean up Source file after database error: %s", path)
|
||||
|
||||
|
||||
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
|
||||
"""Persist an uploaded file to the configured upload directory."""
|
||||
def _compute_file_hash(file_bytes: bytes) -> str:
|
||||
return hashlib.sha256(file_bytes).hexdigest()
|
||||
|
||||
|
||||
def _compute_file_metadata(file_bytes: bytes) -> tuple[str, int]:
|
||||
return _compute_file_hash(file_bytes), len(file_bytes)
|
||||
|
||||
|
||||
def store_source_file(
|
||||
*,
|
||||
filename: str,
|
||||
file_bytes: bytes,
|
||||
settings: Settings | None = None,
|
||||
relative_directory: Path | None = None,
|
||||
filename_stem: str | None = None,
|
||||
) -> Path:
|
||||
"""Validate and persist a Source file to configured media storage."""
|
||||
runtime_settings = settings or get_settings()
|
||||
_validate_upload(filename=filename, file_bytes=file_bytes)
|
||||
try:
|
||||
validate_source_content(filename=filename, content=file_bytes)
|
||||
except TranscriptionError as exc:
|
||||
raise SourceStorageError(
|
||||
exc.message,
|
||||
category=exc.category,
|
||||
suggestion=exc.suggestion,
|
||||
retriable=exc.retriable,
|
||||
) from exc
|
||||
return _store_file_bytes(
|
||||
filename=filename,
|
||||
file_bytes=file_bytes,
|
||||
settings=runtime_settings,
|
||||
relative_directory=relative_directory,
|
||||
filename_stem=filename_stem,
|
||||
)
|
||||
|
||||
upload_dir = runtime_settings.upload_dir
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stored_name = _build_stored_filename(filename)
|
||||
stored_path = upload_dir / stored_name
|
||||
def _store_file_bytes(
|
||||
*,
|
||||
filename: str,
|
||||
file_bytes: bytes,
|
||||
settings: Settings,
|
||||
relative_directory: Path | None = None,
|
||||
filename_stem: str | None = None,
|
||||
) -> Path:
|
||||
upload_dir = settings.upload_dir
|
||||
target_dir = upload_dir if relative_directory is None else upload_dir / relative_directory
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stored_name = _build_stored_filename(filename=filename, filename_stem=filename_stem)
|
||||
stored_path = target_dir / stored_name
|
||||
|
||||
try:
|
||||
stored_path.write_bytes(file_bytes)
|
||||
except OSError as exc:
|
||||
raise UploadError(
|
||||
"Failed to persist upload file",
|
||||
raise SourceStorageError(
|
||||
"Failed to persist Source file",
|
||||
category=ErrorCategory.INFRA_PERSISTENT,
|
||||
suggestion="Check upload directory permissions and available disk space, then retry.",
|
||||
) from exc
|
||||
|
||||
logger.info("Stored uploaded file: %s", stored_path)
|
||||
logger.info("Stored Source file: %s", stored_path)
|
||||
return stored_path
|
||||
|
||||
|
||||
def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
|
||||
if not file_bytes:
|
||||
raise UploadError(
|
||||
"Upload payload is empty",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Select a non-empty file and try again.",
|
||||
)
|
||||
|
||||
def _build_stored_filename(*, filename: str, filename_stem: str | None = None) -> str:
|
||||
safe_name = Path(filename).name
|
||||
if not safe_name:
|
||||
raise UploadError(
|
||||
"Upload filename is required",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Choose a file with a valid filename and retry.",
|
||||
)
|
||||
|
||||
suffix = Path(safe_name).suffix.lower()
|
||||
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS:
|
||||
raise UploadError(
|
||||
f"Unsupported upload extension: {suffix}",
|
||||
category=ErrorCategory.USER_INPUT,
|
||||
suggestion="Upload JPG, JPEG, PNG, TIFF, or PDF files only.",
|
||||
)
|
||||
stem = filename_stem or str(uuid4())
|
||||
return f"{stem}{suffix}"
|
||||
|
||||
|
||||
def _build_stored_filename(filename: str) -> str:
|
||||
safe_name = Path(filename).name
|
||||
return f"{uuid4()}_{safe_name}"
|
||||
create_upload_job = create_document_job
|
||||
store_file = store_source_file
|
||||
|
||||
@@ -1,341 +1,44 @@
|
||||
"""Prompt loading and provider-backed transcription service."""
|
||||
"""Compatibility exports for the Source-owned transcription implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
from .sources import DEFAULT_PROMPT_FILE
|
||||
from .sources import SOURCE_EXTENSIONS
|
||||
from .sources import SOURCE_MIME_TYPES
|
||||
from .sources import PromptExecution
|
||||
from .sources import PromptLoadError
|
||||
from .sources import SourceDeleteBlockedError
|
||||
from .sources import SourceService
|
||||
from .sources import TranscriptionError
|
||||
from .sources import TranscriptionNotFoundError
|
||||
from .sources import build_prompt_execution
|
||||
from .sources import handle_transcription_errors
|
||||
from .sources import load_prompt_text
|
||||
from .sources import load_source_payload
|
||||
from .sources import source_mime_type
|
||||
from .sources import transcribe_document_image
|
||||
from .sources import validate_source_content
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
from collections.abc import Sequence
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
TranscriptionService = SourceService
|
||||
SUPPORTED_EXTENSIONS = SOURCE_EXTENSIONS
|
||||
load_image_payload = load_source_payload
|
||||
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.config import get_settings
|
||||
from transcription.errors import AppError
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.models import Job
|
||||
from transcription.models import Revision
|
||||
from transcription.models import Source
|
||||
from transcription.providers import ProviderAuthError
|
||||
from transcription.providers import ProviderError
|
||||
from transcription.providers import ProviderResponseError
|
||||
from transcription.providers import TranscriptionProvider
|
||||
from transcription.providers import TranscriptionResult
|
||||
from transcription.providers import get_transcription_provider
|
||||
|
||||
from .base import ServiceBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PROMPT_FILE = "transcribe_document.md"
|
||||
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
|
||||
|
||||
|
||||
class PromptLoadError(AppError):
|
||||
"""Raised when prompt artifacts cannot be loaded safely."""
|
||||
|
||||
|
||||
class TranscriptionError(AppError):
|
||||
"""Raised when transcription execution fails."""
|
||||
|
||||
|
||||
class TranscriptionNotFoundError(TranscriptionError):
|
||||
"""Raised when a transcription-related resource is not found."""
|
||||
|
||||
|
||||
class TranscriptionService(ServiceBase):
|
||||
"""Service class for job transcription output and optional source revisions."""
|
||||
|
||||
provider: TranscriptionProvider
|
||||
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession] | None = None):
|
||||
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 with self._session_scope(session) as _session:
|
||||
_session.add(revision)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(revision,))
|
||||
return revision
|
||||
|
||||
async def read_revision(self, revision_id: UUID, *, session: AsyncSession | None = None) -> Revision:
|
||||
"""Read an existing revision from the database."""
|
||||
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:
|
||||
raise TranscriptionNotFoundError(
|
||||
f"Revision with id {revision_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the revision id and retry.",
|
||||
)
|
||||
return revision
|
||||
|
||||
async def update_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> Revision:
|
||||
"""Update an existing revision in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
merged = await _session.merge(revision)
|
||||
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 with self._session_scope(session) as _session:
|
||||
await _session.delete(revision)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
# Temporary compatibility methods for callers still using transcript naming.
|
||||
|
||||
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 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 transcribe_document(
|
||||
self,
|
||||
image_path: str | Path,
|
||||
job_id: UUID,
|
||||
*,
|
||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Transcribe a local image using the configured prompt and provider."""
|
||||
result = await transcribe_document_image(
|
||||
image_path=image_path,
|
||||
prompt_name=prompt_name,
|
||||
settings=self.settings,
|
||||
provider=self.provider,
|
||||
)
|
||||
await self.update_job_transcription(
|
||||
job_id=job_id,
|
||||
text=result.text,
|
||||
error_detail=None,
|
||||
provider=result.provider,
|
||||
model=result.model,
|
||||
prompt_name=result.prompt_name,
|
||||
session=session,
|
||||
)
|
||||
|
||||
async def update_job_transcription(
|
||||
self,
|
||||
*,
|
||||
job_id: UUID,
|
||||
text: str | None,
|
||||
error_detail: str | None = None,
|
||||
provider: str | None = None,
|
||||
model: str | None = None,
|
||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Job:
|
||||
"""Persist original transcription output fields on a job."""
|
||||
async with self._session_scope(session) as _session:
|
||||
job = await _session.get(Job, job_id)
|
||||
if job is None:
|
||||
raise TranscriptionNotFoundError(
|
||||
f"Job with id {job_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the job id and retry.",
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||
return job
|
||||
|
||||
async def upsert_revision_for_source(
|
||||
self,
|
||||
*,
|
||||
source_id: UUID,
|
||||
text: str,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Revision:
|
||||
"""Create or replace the single optional revision for a source."""
|
||||
async with self._session_scope(session) as _session:
|
||||
source = await _session.get(Source, source_id)
|
||||
if source is None:
|
||||
raise TranscriptionNotFoundError(
|
||||
f"Source with id {source_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the source id and retry.",
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
async def read_revision_by_source(
|
||||
self,
|
||||
source_id: UUID,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Revision | None:
|
||||
"""Read the single optional revision for a source."""
|
||||
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()
|
||||
|
||||
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."""
|
||||
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]
|
||||
)
|
||||
result = await _session.exec(query)
|
||||
return result.all()
|
||||
|
||||
|
||||
def _resolve_transcript_model(*, provider: TranscriptionProvider, settings: Settings) -> str:
|
||||
provider_model = getattr(provider, "model", None)
|
||||
if isinstance(provider_model, str) and provider_model.strip():
|
||||
return provider_model
|
||||
|
||||
if settings.provider_model and settings.provider_model.strip():
|
||||
return settings.provider_model
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
async def transcribe_document_image(
|
||||
image_path: str | Path,
|
||||
*,
|
||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
||||
settings: Settings | None = None,
|
||||
provider: TranscriptionProvider | None = None,
|
||||
) -> TranscriptionResult:
|
||||
"""Transcribe a local image using the configured prompt and provider."""
|
||||
runtime_settings = settings or get_settings()
|
||||
prompt_text = load_prompt_text(prompt_name=prompt_name, settings=runtime_settings)
|
||||
image_bytes, mime_type = load_image_payload(image_path)
|
||||
|
||||
adapter = provider or get_transcription_provider(settings=runtime_settings)
|
||||
logger.info("Starting transcription for image=%s mime_type=%s", image_path, mime_type)
|
||||
|
||||
with handle_transcription_errors():
|
||||
result = await adapter.transcribe(
|
||||
prompt_text=prompt_text,
|
||||
image_bytes=image_bytes,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
|
||||
return result
|
||||
|
||||
|
||||
def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settings | None = None) -> str:
|
||||
"""Load and validate prompt text from PROMPT_DIR."""
|
||||
runtime_settings = settings or get_settings()
|
||||
prompt_path = runtime_settings.prompt_dir / prompt_name
|
||||
|
||||
if not prompt_path.exists() or not prompt_path.is_file():
|
||||
raise PromptLoadError(
|
||||
f"Prompt file not found: {prompt_path}",
|
||||
category=ErrorCategory.INFRA_PERSISTENT,
|
||||
suggestion="Verify PROMPT_DIR and prompt file configuration, then retry.",
|
||||
)
|
||||
|
||||
prompt_text = prompt_path.read_text(encoding="utf-8").strip()
|
||||
if not prompt_text:
|
||||
raise PromptLoadError(
|
||||
f"Prompt file is empty: {prompt_path}",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Populate the prompt file with valid instructions and retry.",
|
||||
)
|
||||
|
||||
logger.info("Loaded prompt artifact: %s", prompt_path)
|
||||
return prompt_text
|
||||
|
||||
|
||||
def load_image_payload(image_path: str | Path) -> tuple[bytes, str]:
|
||||
"""Read image bytes and detect mime type for supported uploads."""
|
||||
path = Path(image_path)
|
||||
|
||||
if not path.exists() or not path.is_file():
|
||||
raise TranscriptionError(
|
||||
f"Image file not found: {path}",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the uploaded file exists and retry from the jobs page.",
|
||||
)
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
if suffix not in SUPPORTED_EXTENSIONS:
|
||||
raise TranscriptionError(
|
||||
f"Unsupported file type: {suffix}",
|
||||
category=ErrorCategory.USER_INPUT,
|
||||
suggestion="Use JPG, JPEG, PNG, TIFF, or PDF files.",
|
||||
)
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(path.name)
|
||||
if suffix in {".tif", ".tiff"}:
|
||||
mime_type = "image/tiff"
|
||||
if not mime_type:
|
||||
raise TranscriptionError(
|
||||
f"Unable to determine MIME type for: {path}",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Re-save the file in a supported format and retry.",
|
||||
)
|
||||
|
||||
return path.read_bytes(), mime_type
|
||||
|
||||
|
||||
@contextmanager
|
||||
def handle_transcription_errors():
|
||||
"""Context manager to handle transcription errors."""
|
||||
try:
|
||||
yield
|
||||
except ProviderAuthError as exc:
|
||||
raise TranscriptionError(
|
||||
"Provider authentication failed",
|
||||
category=ErrorCategory.INFRA_PERSISTENT,
|
||||
suggestion="Verify provider API credentials and retry.",
|
||||
) from exc
|
||||
except ProviderResponseError as exc:
|
||||
raise TranscriptionError(
|
||||
"Provider returned an invalid response",
|
||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||
suggestion="Retry once. If it persists, try a different provider model or inspect provider status.",
|
||||
retriable=True,
|
||||
) from exc
|
||||
except ProviderError as exc:
|
||||
raise TranscriptionError(
|
||||
"Provider transcription failed",
|
||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||
suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
|
||||
retriable=True,
|
||||
) from exc
|
||||
__all__ = [
|
||||
"DEFAULT_PROMPT_FILE",
|
||||
"SOURCE_EXTENSIONS",
|
||||
"SOURCE_MIME_TYPES",
|
||||
"SUPPORTED_EXTENSIONS",
|
||||
"PromptExecution",
|
||||
"PromptLoadError",
|
||||
"SourceDeleteBlockedError",
|
||||
"SourceService",
|
||||
"TranscriptionError",
|
||||
"TranscriptionNotFoundError",
|
||||
"TranscriptionService",
|
||||
"build_prompt_execution",
|
||||
"handle_transcription_errors",
|
||||
"load_image_payload",
|
||||
"load_prompt_text",
|
||||
"load_source_payload",
|
||||
"source_mime_type",
|
||||
"transcribe_document_image",
|
||||
"validate_source_content",
|
||||
]
|
||||
|
||||
@@ -1,25 +1,63 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..config import Settings
|
||||
from ..config import get_settings
|
||||
from ..db.models import Job
|
||||
from ..db.models import JobSourceStatus
|
||||
from ..db.models import JobStatus
|
||||
from ..db.models import Source
|
||||
from ..errors import AppError
|
||||
from ..errors import ErrorCategory
|
||||
from ..errors import classify_unexpected_error
|
||||
from ..errors import format_error_detail
|
||||
from ..models import Job
|
||||
from ..models import JobStatus
|
||||
from ..models import Source
|
||||
from ..providers import ProviderError
|
||||
from ..providers import RequestManifest
|
||||
from ..providers import SourceEvidenceReference
|
||||
from ..providers import TranscriptionProvider
|
||||
from ..providers import TranscriptionResult
|
||||
from ..providers import TransportEvidence
|
||||
from . import ServiceBundle
|
||||
from .transcription import DEFAULT_PROMPT_FILE
|
||||
from .transcription import transcribe_document_image
|
||||
from .sources import PromptExecution
|
||||
from .sources import build_prompt_execution
|
||||
from .sources import hash_prompt_text
|
||||
from .sources import source_mime_type
|
||||
from .sources import transcribe_document_image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SuccessfulPage:
|
||||
source: Source
|
||||
result: TranscriptionResult
|
||||
started_at: datetime
|
||||
finished_at: datetime
|
||||
duration_ms: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _FailedPage:
|
||||
source: Source
|
||||
error: AppError
|
||||
started_at: datetime
|
||||
finished_at: datetime
|
||||
duration_ms: int
|
||||
request_manifest: RequestManifest | None = None
|
||||
transport_evidence: TransportEvidence | None = None
|
||||
failure_phase: str | None = None
|
||||
sdk_response_snapshot: dict | None = None
|
||||
normalized_metadata: dict | None = None
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
|
||||
|
||||
async def advance_job(
|
||||
job: Job,
|
||||
services: ServiceBundle,
|
||||
@@ -28,9 +66,13 @@ async def advance_job(
|
||||
) -> Job | None:
|
||||
"""Advance a single job by lifecycle status."""
|
||||
settings = settings or get_settings()
|
||||
match job.status:
|
||||
current_status = _coerce_job_status(job.status)
|
||||
match current_status:
|
||||
case JobStatus.QUEUED:
|
||||
return await process_queued_job(job=job, services=services, settings=settings, session=session)
|
||||
case JobStatus.PROCESSING:
|
||||
# Recover mid-flight jobs by continuing the queued processing path.
|
||||
return await process_queued_job(job=job, services=services, settings=settings, session=session)
|
||||
case JobStatus.FAILED:
|
||||
if job.retry_count < settings.worker_max_retries:
|
||||
return await services.jobs.update_job_state(
|
||||
@@ -46,7 +88,7 @@ async def advance_job(
|
||||
return
|
||||
|
||||
|
||||
async def process_queued_job(
|
||||
async def process_queued_job( # noqa: PLR0915
|
||||
*,
|
||||
job: Job,
|
||||
services: ServiceBundle,
|
||||
@@ -55,92 +97,218 @@ async def process_queued_job(
|
||||
) -> Job | None:
|
||||
"""Process one complete transcription attempt for a queued job."""
|
||||
runtime_settings = settings or get_settings()
|
||||
if job.status != JobStatus.QUEUED:
|
||||
current_status = _coerce_job_status(job.status)
|
||||
if current_status not in {JobStatus.QUEUED, JobStatus.PROCESSING}:
|
||||
logger.warning(f"Job {job.id} is not queued. Current status: {job.status}")
|
||||
return
|
||||
|
||||
# Transaction A: claim job for processing.
|
||||
if session is None:
|
||||
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING)
|
||||
else:
|
||||
# If we're sharing the session, need to make sure setting the Job to PROCESSING is committed before we start
|
||||
# the transcription, otherwise other workers may see the job as still QUEUED and try to process it.
|
||||
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING, session=session)
|
||||
await session.commit()
|
||||
if current_status == JobStatus.QUEUED:
|
||||
if session is None:
|
||||
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING)
|
||||
else:
|
||||
# If we're sharing the session, need to make sure setting the Job to PROCESSING is committed before we start
|
||||
# the transcription, otherwise other workers may see the job as still QUEUED and try to process it.
|
||||
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING, session=session)
|
||||
await session.commit()
|
||||
|
||||
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."
|
||||
started_at = asyncio.get_running_loop().time()
|
||||
sources = _resolve_job_sources(source_job)
|
||||
if not sources and not source_job.job_sources:
|
||||
candidate_sources = await services.sources.list_sources(document_id=job.document_id, session=session)
|
||||
sources = sorted(candidate_sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
transcribe_document_image(source.file_path),
|
||||
timeout=runtime_settings.worker_provider_timeout_seconds,
|
||||
)
|
||||
elapsed_seconds = asyncio.get_running_loop().time() - started_at
|
||||
logger.info(
|
||||
"Provider response diagnostics operation=worker.provider_response "
|
||||
"job_id=%s document_id=%s source_id=%s provider=%s model=%s "
|
||||
"finish_reason=%s usage_input_tokens=%s usage_output_tokens=%s usage_total_tokens=%s "
|
||||
"latency_seconds=%.3f text_chars=%s text_lines=%s",
|
||||
job.id,
|
||||
job.document_id,
|
||||
source.id,
|
||||
result.provider,
|
||||
result.model,
|
||||
result.finish_reason or "unknown",
|
||||
result.usage_input_tokens,
|
||||
result.usage_output_tokens,
|
||||
result.usage_total_tokens,
|
||||
elapsed_seconds,
|
||||
len(result.text),
|
||||
_line_count(result.text),
|
||||
if not sources:
|
||||
return await services.jobs.mark_job_status(job.id, JobStatus.TRANSCRIBED, session=session)
|
||||
|
||||
successful_pages: list[_SuccessfulPage] = []
|
||||
failed_pages: list[_FailedPage] = []
|
||||
externally_stopped = False
|
||||
|
||||
prompt_execution = _resolve_job_prompt_execution(source_job=source_job, settings=runtime_settings)
|
||||
|
||||
for source in sources:
|
||||
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
|
||||
externally_stopped = True
|
||||
break
|
||||
|
||||
started_at = datetime.now(UTC)
|
||||
monotonic_started_at = asyncio.get_running_loop().time()
|
||||
result: TranscriptionResult | None = None
|
||||
page_outcome: _SuccessfulPage | _FailedPage
|
||||
try:
|
||||
source_reference = SourceEvidenceReference(
|
||||
source_id=source.id,
|
||||
digest_sha256=source.file_hash.lower(),
|
||||
byte_size=source.file_size_bytes,
|
||||
media_type=source_mime_type(source.file_path),
|
||||
page_number=source.page_number,
|
||||
)
|
||||
result = await asyncio.wait_for(
|
||||
_call_transcriber(
|
||||
source=source,
|
||||
prompt_execution=prompt_execution,
|
||||
settings=runtime_settings,
|
||||
provider=services.sources.provider,
|
||||
source_reference=source_reference,
|
||||
),
|
||||
timeout=runtime_settings.worker_provider_timeout_seconds,
|
||||
)
|
||||
elapsed_seconds = asyncio.get_running_loop().time() - monotonic_started_at
|
||||
logger.info(
|
||||
"Provider response diagnostics operation=worker.provider_response "
|
||||
"job_id=%s document_id=%s source_id=%s provider=%s model=%s "
|
||||
"finish_reason=%s usage_input_tokens=%s usage_output_tokens=%s usage_total_tokens=%s "
|
||||
"latency_seconds=%.3f text_chars=%s text_lines=%s",
|
||||
job.id,
|
||||
job.document_id,
|
||||
source.id,
|
||||
result.provider,
|
||||
result.model,
|
||||
result.finish_reason or "unknown",
|
||||
result.usage_input_tokens,
|
||||
result.usage_output_tokens,
|
||||
result.usage_total_tokens,
|
||||
elapsed_seconds,
|
||||
len(result.text),
|
||||
_line_count(result.text),
|
||||
)
|
||||
|
||||
_validate_transcription_quality(result=result, settings=runtime_settings)
|
||||
finished_at = datetime.now(UTC)
|
||||
page_outcome = _SuccessfulPage(
|
||||
source=source,
|
||||
result=result,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
duration_ms=max(0, int(elapsed_seconds * 1000)),
|
||||
)
|
||||
successful_pages.append(page_outcome)
|
||||
except TimeoutError:
|
||||
error = AppError(
|
||||
f"Provider call timed out after {runtime_settings.worker_provider_timeout_seconds:.1f}s",
|
||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||
suggestion="Retry the job. If this repeats, verify provider latency and request payload size.",
|
||||
retriable=True,
|
||||
)
|
||||
finished_at = datetime.now(UTC)
|
||||
page_outcome = _FailedPage(
|
||||
source=source,
|
||||
error=error,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
duration_ms=max(
|
||||
0,
|
||||
int((asyncio.get_running_loop().time() - monotonic_started_at) * 1000),
|
||||
),
|
||||
request_manifest=getattr(services.sources.provider, "current_request_manifest", None),
|
||||
transport_evidence=getattr(
|
||||
services.sources.provider,
|
||||
"current_transport_evidence",
|
||||
None,
|
||||
),
|
||||
failure_phase="local_timeout",
|
||||
)
|
||||
failed_pages.append(page_outcome)
|
||||
logger.error(
|
||||
"Source failed operation=worker.process_job job_id=%s document_id=%s "
|
||||
"source_id=%s error_id=%s category=%s",
|
||||
job.id,
|
||||
job.document_id,
|
||||
source.id,
|
||||
error.error_id,
|
||||
error.category.value,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
match exc:
|
||||
case AppError() as error:
|
||||
pass
|
||||
case _:
|
||||
error = classify_unexpected_error(exc, operation="worker.process_job")
|
||||
|
||||
finished_at = datetime.now(UTC)
|
||||
provider_error = _find_provider_error(exc)
|
||||
page_outcome = _FailedPage(
|
||||
source=source,
|
||||
error=error,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
duration_ms=max(
|
||||
0,
|
||||
int((asyncio.get_running_loop().time() - monotonic_started_at) * 1000),
|
||||
),
|
||||
request_manifest=(
|
||||
result.request_manifest
|
||||
if result is not None
|
||||
else provider_error.request_manifest
|
||||
if provider_error is not None
|
||||
else None
|
||||
),
|
||||
transport_evidence=(
|
||||
result.transport_evidence
|
||||
if result is not None
|
||||
else provider_error.transport_evidence
|
||||
if provider_error is not None
|
||||
else None
|
||||
),
|
||||
failure_phase=(
|
||||
"transcription_quality"
|
||||
if result is not None
|
||||
else provider_error.failure_phase
|
||||
if provider_error is not None
|
||||
else "application"
|
||||
),
|
||||
sdk_response_snapshot=result.raw_api_response if result is not None else None,
|
||||
normalized_metadata=result.metadata_payload() if result is not None else None,
|
||||
provider=result.provider if result is not None else None,
|
||||
model=result.model if result is not None else None,
|
||||
)
|
||||
failed_pages.append(page_outcome)
|
||||
logger.error(
|
||||
"Source failed operation=worker.process_job job_id=%s document_id=%s "
|
||||
"source_id=%s error_id=%s category=%s",
|
||||
job.id,
|
||||
job.document_id,
|
||||
source.id,
|
||||
error.error_id,
|
||||
error.category.value,
|
||||
)
|
||||
|
||||
await _persist_page_outcome_durably(
|
||||
job=job,
|
||||
services=services,
|
||||
page=page_outcome,
|
||||
session=session,
|
||||
)
|
||||
|
||||
_validate_transcription_quality(result=result, settings=runtime_settings)
|
||||
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
|
||||
externally_stopped = True
|
||||
break
|
||||
|
||||
job = await _finalize_transcribed(job=job, services=services, result=result, session=session)
|
||||
logger.info(
|
||||
"Job transcribed operation=worker.process_job job_id=%s document_id=%s source_id=%s provider=%s",
|
||||
job.id,
|
||||
job.document_id,
|
||||
source.id,
|
||||
result.provider,
|
||||
)
|
||||
except TimeoutError:
|
||||
error = AppError(
|
||||
f"Provider call timed out after {runtime_settings.worker_provider_timeout_seconds:.1f}s",
|
||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||
suggestion="Retry the job. If this repeats, verify provider latency and request payload size.",
|
||||
retriable=True,
|
||||
)
|
||||
job = await _finalize_failed(job=job, services=services, error=error, session=session)
|
||||
logger.error(
|
||||
"Job failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
|
||||
job.id,
|
||||
job.document_id,
|
||||
source.id,
|
||||
error.error_id,
|
||||
error.category.value,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
match exc:
|
||||
case AppError() as error:
|
||||
pass
|
||||
case _:
|
||||
error = classify_unexpected_error(exc, operation="worker.process_job")
|
||||
terminal_status = JobStatus.TRANSCRIBED
|
||||
if externally_stopped:
|
||||
terminal_status = JobStatus.FAILED
|
||||
elif failed_pages and successful_pages:
|
||||
terminal_status = JobStatus.PARTIAL_SUCCESS
|
||||
elif failed_pages and not successful_pages:
|
||||
terminal_status = JobStatus.FAILED
|
||||
|
||||
job = await _finalize_failed(job=job, services=services, error=error, session=session)
|
||||
logger.error(
|
||||
"Job failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
|
||||
job.id,
|
||||
job.document_id,
|
||||
source.id,
|
||||
error.error_id,
|
||||
error.category.value,
|
||||
)
|
||||
return job
|
||||
updated_job = await _finalize_batch_outcome(
|
||||
job=job,
|
||||
services=services,
|
||||
status=terminal_status,
|
||||
session=session,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Job finished operation=worker.process_job job_id=%s document_id=%s status=%s success_pages=%s failed_pages=%s",
|
||||
updated_job.id,
|
||||
updated_job.document_id,
|
||||
updated_job.status.value,
|
||||
len(successful_pages),
|
||||
len(failed_pages),
|
||||
)
|
||||
return updated_job
|
||||
|
||||
|
||||
async def process_next_queued_job(
|
||||
@@ -151,6 +319,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
|
||||
|
||||
@@ -158,142 +327,144 @@ async def process_next_queued_job(
|
||||
return True
|
||||
|
||||
|
||||
async def _finalize_transcribed(
|
||||
def _resolve_job_sources(job: Job) -> list[Source]:
|
||||
"""Resolve non-transcribed linked sources for a job in deterministic page order."""
|
||||
if not job.job_sources:
|
||||
return []
|
||||
|
||||
sources = [
|
||||
job_source.source
|
||||
for job_source in job.job_sources
|
||||
if job_source.source is not None and job_source.status != JobSourceStatus.TRANSCRIBED
|
||||
]
|
||||
return sorted(sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
|
||||
|
||||
|
||||
def _resolve_job_prompt_execution(*, source_job: Job, settings: Settings) -> PromptExecution:
|
||||
if source_job.user_prompt and source_job.prompt_name:
|
||||
return PromptExecution(
|
||||
prompt_name=source_job.prompt_name,
|
||||
prompt_hash=source_job.prompt_hash or hash_prompt_text(source_job.user_prompt),
|
||||
system_prompt=source_job.system_prompt,
|
||||
user_prompt=source_job.user_prompt,
|
||||
temperature=source_job.temperature,
|
||||
top_p=source_job.top_p,
|
||||
)
|
||||
|
||||
return build_prompt_execution(settings=settings)
|
||||
|
||||
|
||||
async def _job_no_longer_processing(
|
||||
*,
|
||||
job_id,
|
||||
services: ServiceBundle,
|
||||
session: AsyncSession | None = None,
|
||||
) -> bool:
|
||||
"""Return True when job status changed externally from PROCESSING."""
|
||||
latest_job = await services.jobs.read_job(job_id=job_id, session=session)
|
||||
return latest_job.status != JobStatus.PROCESSING
|
||||
|
||||
|
||||
async def _finalize_batch_outcome(
|
||||
*,
|
||||
job: Job,
|
||||
services: ServiceBundle,
|
||||
result: TranscriptionResult,
|
||||
status: JobStatus,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Job:
|
||||
"""Transaction B: job transcription output + TRANSCRIBED in one commit."""
|
||||
"""Persist the terminal aggregate status after all page outcomes are durable."""
|
||||
if session is None:
|
||||
async with services.jobs._session_scope() as local_session:
|
||||
await services.transcriptions.update_job_transcription(
|
||||
job_id=job.id,
|
||||
text=result.text,
|
||||
error_detail=None,
|
||||
provider=result.provider,
|
||||
model=result.model,
|
||||
prompt_name=result.prompt_name,
|
||||
session=local_session,
|
||||
)
|
||||
updated_job = await services.jobs.mark_job_status(
|
||||
job.id,
|
||||
JobStatus.TRANSCRIBED,
|
||||
session=local_session,
|
||||
)
|
||||
updated_job = await services.jobs.mark_job_status(job.id, status, session=local_session)
|
||||
await local_session.commit()
|
||||
return updated_job
|
||||
|
||||
await services.transcriptions.update_job_transcription(
|
||||
job_id=job.id,
|
||||
text=result.text,
|
||||
error_detail=None,
|
||||
provider=result.provider,
|
||||
model=result.model,
|
||||
prompt_name=result.prompt_name,
|
||||
session=session,
|
||||
)
|
||||
updated_job = await services.jobs.mark_job_status(
|
||||
job.id,
|
||||
JobStatus.TRANSCRIBED,
|
||||
session=session,
|
||||
)
|
||||
updated_job = await services.jobs.mark_job_status(job.id, status, session=session)
|
||||
await session.commit()
|
||||
return updated_job
|
||||
|
||||
|
||||
async def _finalize_retry(
|
||||
async def _persist_page_outcome_durably(
|
||||
*,
|
||||
job: Job,
|
||||
services: ServiceBundle,
|
||||
error: AppError,
|
||||
settings: Settings,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Job:
|
||||
"""Transaction C: job error detail + QUEUED + retry increment in one commit."""
|
||||
if session is None:
|
||||
async with services.jobs._session_scope() as local_session:
|
||||
await services.transcriptions.update_job_transcription(
|
||||
job_id=job.id,
|
||||
text=None,
|
||||
error_detail=format_error_detail(error),
|
||||
prompt_name=DEFAULT_PROMPT_FILE,
|
||||
session=local_session,
|
||||
)
|
||||
updated_job = await services.jobs.update_job_state(
|
||||
job_id=job.id,
|
||||
status=JobStatus.QUEUED,
|
||||
retry_count_increment=1,
|
||||
session=local_session,
|
||||
)
|
||||
await local_session.commit()
|
||||
else:
|
||||
await services.transcriptions.update_job_transcription(
|
||||
job_id=job.id,
|
||||
text=None,
|
||||
error_detail=format_error_detail(error),
|
||||
prompt_name=DEFAULT_PROMPT_FILE,
|
||||
session=session,
|
||||
)
|
||||
updated_job = await services.jobs.update_job_state(
|
||||
job_id=job.id,
|
||||
status=JobStatus.QUEUED,
|
||||
retry_count_increment=1,
|
||||
session=session,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if settings.worker_retry_backoff_seconds > 0:
|
||||
await asyncio.sleep(settings.worker_retry_backoff_seconds)
|
||||
return updated_job
|
||||
page: _SuccessfulPage | _FailedPage,
|
||||
session: AsyncSession | None,
|
||||
) -> None:
|
||||
"""Commit one completed provider call before processing the next source."""
|
||||
task = asyncio.create_task(
|
||||
_persist_page_outcome(job=job, services=services, page=page, session=session)
|
||||
)
|
||||
try:
|
||||
await asyncio.shield(task)
|
||||
except asyncio.CancelledError:
|
||||
await task
|
||||
raise
|
||||
|
||||
|
||||
async def _finalize_failed(
|
||||
async def _persist_page_outcome(
|
||||
*,
|
||||
job: Job,
|
||||
services: ServiceBundle,
|
||||
error: AppError,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Job:
|
||||
"""Transaction B: job error detail + FAILED in one commit."""
|
||||
page: _SuccessfulPage | _FailedPage,
|
||||
session: AsyncSession | None,
|
||||
) -> None:
|
||||
if session is None:
|
||||
async with services.jobs._session_scope() as local_session:
|
||||
await services.transcriptions.update_job_transcription(
|
||||
job_id=job.id,
|
||||
text=None,
|
||||
error_detail=format_error_detail(error),
|
||||
prompt_name=DEFAULT_PROMPT_FILE,
|
||||
session=local_session,
|
||||
)
|
||||
updated_job = await services.jobs.mark_job_status(
|
||||
job.id,
|
||||
JobStatus.FAILED,
|
||||
session=local_session,
|
||||
)
|
||||
async with services.sources._session_scope() as local_session:
|
||||
await _write_page_outcome(job=job, services=services, page=page, session=local_session)
|
||||
await local_session.commit()
|
||||
return updated_job
|
||||
return
|
||||
|
||||
await services.transcriptions.update_job_transcription(
|
||||
await _write_page_outcome(job=job, services=services, page=page, session=session)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _write_page_outcome(
|
||||
*,
|
||||
job: Job,
|
||||
services: ServiceBundle,
|
||||
page: _SuccessfulPage | _FailedPage,
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
if isinstance(page, _SuccessfulPage):
|
||||
source = page.source
|
||||
result = page.result
|
||||
await services.sources.update_job_source_transcription(
|
||||
job_id=job.id,
|
||||
source_id=source.id,
|
||||
text=result.text,
|
||||
error_detail=None,
|
||||
ai_metadata=result.metadata_payload(),
|
||||
raw_api_response=result.raw_api_response,
|
||||
provider=result.provider,
|
||||
model=result.model,
|
||||
request_manifest=result.request_manifest,
|
||||
transport_evidence=result.transport_evidence,
|
||||
started_at=page.started_at,
|
||||
finished_at=page.finished_at,
|
||||
duration_ms=page.duration_ms,
|
||||
session=session,
|
||||
)
|
||||
return
|
||||
|
||||
await services.sources.update_job_source_transcription(
|
||||
job_id=job.id,
|
||||
source_id=page.source.id,
|
||||
text=None,
|
||||
error_detail=format_error_detail(error),
|
||||
prompt_name=DEFAULT_PROMPT_FILE,
|
||||
error_detail=format_error_detail(page.error),
|
||||
ai_metadata=page.normalized_metadata,
|
||||
raw_api_response=page.sdk_response_snapshot,
|
||||
provider=page.provider,
|
||||
model=page.model,
|
||||
request_manifest=page.request_manifest,
|
||||
transport_evidence=page.transport_evidence,
|
||||
failure_phase=page.failure_phase,
|
||||
error_category=page.error.category.value,
|
||||
started_at=page.started_at,
|
||||
finished_at=page.finished_at,
|
||||
duration_ms=page.duration_ms,
|
||||
session=session,
|
||||
)
|
||||
updated_job = await services.jobs.mark_job_status(
|
||||
job.id,
|
||||
JobStatus.FAILED,
|
||||
session=session,
|
||||
)
|
||||
await session.commit()
|
||||
return updated_job
|
||||
|
||||
|
||||
def _resolve_primary_source(job: Job) -> Source | None:
|
||||
if not job.sources:
|
||||
return None
|
||||
return job.sources[0]
|
||||
|
||||
|
||||
def _validate_transcription_quality(*, result: TranscriptionResult, settings: Settings) -> None:
|
||||
@@ -343,3 +514,57 @@ def _line_count(text: str) -> int:
|
||||
if not stripped:
|
||||
return 0
|
||||
return sum(1 for line in stripped.splitlines() if line.strip())
|
||||
|
||||
|
||||
def _coerce_job_status(value: object) -> JobStatus | None:
|
||||
if isinstance(value, JobStatus):
|
||||
return value
|
||||
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
for member in JobStatus:
|
||||
if lowered in {member.value.lower(), member.name.lower()}:
|
||||
return member
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_provider_error(exc: BaseException) -> ProviderError | None:
|
||||
"""Find provider evidence carried through application error translation."""
|
||||
current: BaseException | None = exc
|
||||
while current is not None:
|
||||
if isinstance(current, ProviderError):
|
||||
return current
|
||||
current = current.__cause__ or current.__context__
|
||||
return None
|
||||
|
||||
|
||||
async def _call_transcriber(
|
||||
*,
|
||||
source: Source,
|
||||
prompt_execution: PromptExecution,
|
||||
settings: Settings,
|
||||
provider: TranscriptionProvider,
|
||||
source_reference: SourceEvidenceReference,
|
||||
) -> TranscriptionResult:
|
||||
"""Call the current transcriber while supporting legacy injected test doubles."""
|
||||
if "source_reference" in inspect.signature(transcribe_document_image).parameters:
|
||||
return await transcribe_document_image(
|
||||
source.file_path,
|
||||
prompt_name=prompt_execution.prompt_name,
|
||||
prompt_text=prompt_execution.user_prompt,
|
||||
temperature=prompt_execution.temperature,
|
||||
top_p=prompt_execution.top_p,
|
||||
settings=settings,
|
||||
provider=provider,
|
||||
source_reference=source_reference,
|
||||
)
|
||||
return await transcribe_document_image(
|
||||
source.file_path,
|
||||
prompt_name=prompt_execution.prompt_name,
|
||||
prompt_text=prompt_execution.user_prompt,
|
||||
temperature=prompt_execution.temperature,
|
||||
top_p=prompt_execution.top_p,
|
||||
settings=settings,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
@@ -1,38 +1,32 @@
|
||||
"""UI page registration exports."""
|
||||
|
||||
from pathlib import Path
|
||||
from contextlib import suppress
|
||||
|
||||
from fastapi import FastAPI
|
||||
from nicegui import app as nicegui_app
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.config import get_settings
|
||||
from transcription.ui.pages.documents_page import register_page as register_documents_page
|
||||
from transcription.ui.pages.home_page import register_page as register_home_page
|
||||
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
|
||||
from transcription.ui.pages.people_page import register_page as register_people_page
|
||||
from transcription.ui.pages.settings_page import register_page as register_settings_page
|
||||
from transcription.ui.pages.sources_page import register_page as register_sources_page
|
||||
from transcription.ui.resources import read_css
|
||||
from transcription.ui.theme import apply_archival_theme
|
||||
|
||||
_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)
|
||||
apply_archival_theme()
|
||||
with suppress(RuntimeError):
|
||||
ui.add_css(read_css("theme.css"), shared=True)
|
||||
# NiceGUI shared style registration can raise when the app is constructed
|
||||
# outside an active client context, which happens in unit tests.
|
||||
|
||||
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
|
||||
|
||||
@@ -40,6 +34,10 @@ def _register_global_styles(app: FastAPI) -> None:
|
||||
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_home_page()
|
||||
register_documents_page()
|
||||
register_people_page()
|
||||
register_sources_page()
|
||||
register_jobs_page()
|
||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=True)
|
||||
register_settings_page(settings=getattr(app.state, "settings", None) or get_settings())
|
||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
|
||||
|
||||
@@ -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,69 @@ from __future__ import annotations
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.ui.theme import VIBESCRIBE_LOGO_SVG
|
||||
|
||||
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||
("Upload", "/upload", "upload_file"),
|
||||
("Documents", "/documents", "description"),
|
||||
("People", "/people", "group"),
|
||||
("Sources", "/sources", "folder"),
|
||||
("Jobs", "/jobs", "work_history"),
|
||||
("Settings", "/settings", "settings"),
|
||||
)
|
||||
|
||||
|
||||
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/")
|
||||
if item_path == "/settings":
|
||||
return current_path == "/settings" or current_path.startswith("/settings/")
|
||||
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 "/homepage"
|
||||
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."""
|
||||
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.element("a").props('href="/ui/homepage"').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).classes("text-[10px] ui-badge-secondary")
|
||||
@@ -10,7 +10,7 @@ from uuid import uuid4
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.config import get_settings
|
||||
from transcription.models import Source
|
||||
from transcription.db.models import Source
|
||||
|
||||
PANGOZOOM_CDN_URL = "https://unpkg.com/@panzoom/[email protected]/dist/panzoom.min.js"
|
||||
UPLOADS_URL_PREFIX = "/uploads"
|
||||
@@ -24,17 +24,12 @@ 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 ui-card-surface"):
|
||||
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(
|
||||
"max-width: 60%; text-align: right;"
|
||||
)
|
||||
ui.label(source.filename).classes("text-caption ui-text-muted ellipsis document-panzoom-filename")
|
||||
|
||||
with (
|
||||
ui.element("div").classes("w-full document-panzoom-host rounded-borders q-mt-md")
|
||||
# .style(f"height: {height};")
|
||||
) as host:
|
||||
with ui.element("div").classes("w-full document-panzoom-host q-mt-md") as host:
|
||||
host.props(f"id={host_id}")
|
||||
with ui.element("div").classes("document-panzoom-surface"):
|
||||
if document_kind == "pdf":
|
||||
@@ -59,43 +54,6 @@ def _register_panzoom_assets() -> None:
|
||||
f'<script src="{PANGOZOOM_CDN_URL}"></script>',
|
||||
shared=True,
|
||||
)
|
||||
ui.add_head_html(
|
||||
"""
|
||||
<style>
|
||||
.document-panzoom-host {
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.document-panzoom-surface {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.document-panzoom-media {
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.document-panzoom-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
pointer-events: none;
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
""",
|
||||
shared=True,
|
||||
)
|
||||
|
||||
|
||||
def _document_url(source: Source) -> str:
|
||||
|
||||
@@ -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("ui-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")
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Presentation-only formatting shared by archival UI surfaces."""
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
from transcription.db.models import Person
|
||||
|
||||
YEAR_PATTERN = re.compile(r"\b[12]\d{3}\b")
|
||||
|
||||
|
||||
def compact_date(exact: date | None, approximate: str | None) -> str:
|
||||
"""Prefer an exact date, then an approximate value, then an unknown marker."""
|
||||
if exact is not None:
|
||||
return exact.isoformat()
|
||||
return (approximate or "").strip() or "Unknown"
|
||||
|
||||
|
||||
def person_selector_label(person: Person) -> str:
|
||||
"""Build a readable selector label without treating names as identity."""
|
||||
preferred = (person.display_name or "").strip()
|
||||
full_name = person.full_name.strip()
|
||||
label = preferred if not preferred or preferred == full_name else f"{preferred} - {full_name}"
|
||||
if not label:
|
||||
label = full_name
|
||||
if person.birth_date is not None:
|
||||
return f"{label} ({person.birth_date.year})"
|
||||
approximate_year = YEAR_PATTERN.search(person.birth_date_raw or "")
|
||||
if approximate_year is not None:
|
||||
return f"{label} ({approximate_year.group(0)})"
|
||||
return label
|
||||
|
||||
|
||||
def family_search_url(family_search_id: str) -> str:
|
||||
"""Build the fixed FamilySearch details URL for a validated identifier."""
|
||||
return f"https://www.familysearch.org/tree/person/details/{family_search_id}"
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Reusable job detail rendering helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.models import Job
|
||||
from transcription.models import Revision
|
||||
from transcription.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
|
||||
@@ -11,19 +11,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_row_id(args: Any) -> str | None:
|
||||
if isinstance(args, dict):
|
||||
if isinstance(args.get("row"), dict):
|
||||
row_id = args["row"].get("id")
|
||||
return str(row_id) if row_id is not None else None
|
||||
row_id = args.get("id")
|
||||
return str(row_id) if row_id is not None else None
|
||||
if isinstance(args, list) and len(args) > 1 and isinstance(args[1], dict):
|
||||
return str(args[1].get("id")) if args[1].get("id") is not None else None
|
||||
|
||||
if isinstance(args, list):
|
||||
for value in args:
|
||||
if isinstance(value, dict):
|
||||
row_id = value.get("id")
|
||||
if row_id is not None:
|
||||
return str(row_id)
|
||||
if isinstance(args, dict):
|
||||
row = args.get("row")
|
||||
if isinstance(row, dict) and "id" in row:
|
||||
return str(row["id"])
|
||||
if "id" in args:
|
||||
return str(args["id"])
|
||||
|
||||
return None
|
||||
|
||||
@@ -40,7 +36,6 @@ def _bind_row_click_handler(
|
||||
on_row_click_id(row_id)
|
||||
|
||||
table.on("rowClick", handle_row_click)
|
||||
logger.debug("Row click handler bound to table")
|
||||
|
||||
|
||||
def build_table(
|
||||
@@ -49,25 +44,47 @@ def build_table(
|
||||
*,
|
||||
default_sort_by: str | None = None,
|
||||
default_descending: bool = False,
|
||||
classes: str = "app-table",
|
||||
classes: str = "",
|
||||
show_search: bool = True,
|
||||
search_placeholder: str = "Search records...",
|
||||
on_row_click_id: Callable[[str], None] | None = None,
|
||||
) -> Any:
|
||||
"""Build a styled Quasar table widget with optional client-side filtering and row-click handlers."""
|
||||
pagination: dict[str, Any] = {"rowsPerPage": 25}
|
||||
if default_sort_by is not None:
|
||||
pagination["sortBy"] = default_sort_by
|
||||
pagination["descending"] = default_descending
|
||||
|
||||
table = (
|
||||
ui.table(
|
||||
rows=rows,
|
||||
columns=columns,
|
||||
row_key="id",
|
||||
pagination=pagination,
|
||||
# Use a parent container to hold both the search bar and the table seamlessly
|
||||
with ui.column().classes("w-full gap-2"):
|
||||
if show_search:
|
||||
with ui.row().classes("w-full items-center justify-end"):
|
||||
search_input = (
|
||||
ui.input(placeholder=search_placeholder)
|
||||
.props("dense outlined clearable icon=search")
|
||||
.classes("w-64 text-xs ui-form-surface")
|
||||
)
|
||||
|
||||
table = (
|
||||
ui.table(
|
||||
rows=rows,
|
||||
columns=columns,
|
||||
row_key="id",
|
||||
pagination=pagination,
|
||||
)
|
||||
.classes(f"w-full ui-table {classes}".strip())
|
||||
.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"'
|
||||
)
|
||||
)
|
||||
.classes(classes)
|
||||
.props('table-style="table-layout: fixed; width: 100%;"')
|
||||
)
|
||||
logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
|
||||
|
||||
# Bind client-side text filter if search input is active
|
||||
if show_search:
|
||||
table.bind_filter_from(search_input, "value")
|
||||
|
||||
if on_row_click_id is not None:
|
||||
_bind_row_click_handler(table, on_row_click_id=on_row_click_id)
|
||||
|
||||
return table
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""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
|
||||
authors: str
|
||||
document_date: str
|
||||
archive_identifier: 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",
|
||||
"authors": row.authors or "Not set",
|
||||
"document_date": row.document_date,
|
||||
"archive_identifier": row.archive_identifier or "N/A",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
|
||||
"""Render documents table with search filtering and custom type chips."""
|
||||
if not rows:
|
||||
with archival_card(extra_classes="p-8 text-center"):
|
||||
render_empty_state("No documents found in repository.")
|
||||
return
|
||||
|
||||
table = build_table(
|
||||
rows=_serialize_rows(rows),
|
||||
columns=[
|
||||
{
|
||||
"name": "name",
|
||||
"label": "Document Title",
|
||||
"field": "name",
|
||||
"sortable": True,
|
||||
"classes": "font-serif font-semibold text-left ui-table-cell-wrap",
|
||||
"align": "left",
|
||||
"style": "width: 30%;",
|
||||
},
|
||||
{
|
||||
"name": "document_type",
|
||||
"label": "Type",
|
||||
"field": "document_type",
|
||||
"sortable": True,
|
||||
"classes": "ui-table-cell-wrap",
|
||||
"align": "center",
|
||||
"style": "width: 14%;",
|
||||
},
|
||||
{
|
||||
"name": "authors",
|
||||
"label": "Author",
|
||||
"field": "authors",
|
||||
"sortable": True,
|
||||
"classes": "ui-table-cell-wrap",
|
||||
"align": "center",
|
||||
"style": "width: 22%;",
|
||||
},
|
||||
{
|
||||
"name": "document_date",
|
||||
"label": "Document Date",
|
||||
"field": "document_date",
|
||||
"sortable": True,
|
||||
"classes": "font-mono text-xs",
|
||||
"align": "center",
|
||||
"style": "width: 14%;",
|
||||
},
|
||||
{
|
||||
"name": "archive_identifier",
|
||||
"label": "Archive Ref",
|
||||
"field": "archive_identifier",
|
||||
"sortable": True,
|
||||
"classes": "font-mono text-xs",
|
||||
"align": "center",
|
||||
"style": "width: 20%;",
|
||||
},
|
||||
],
|
||||
default_sort_by="name",
|
||||
search_placeholder="Search documents by title, type, or reference...",
|
||||
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"),
|
||||
)
|
||||
|
||||
# Render document type using a subtle Quasar badge
|
||||
table.add_slot(
|
||||
"body-cell-document_type",
|
||||
r"""
|
||||
<q-td :props="props">
|
||||
<q-chip
|
||||
dense
|
||||
square
|
||||
size="sm"
|
||||
class="ui-chip-primary"
|
||||
>
|
||||
{{ props.value }}
|
||||
</q-chip>
|
||||
</q-td>
|
||||
""",
|
||||
)
|
||||
@@ -11,6 +11,9 @@ 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
|
||||
|
||||
|
||||
@@ -27,7 +30,7 @@ class JobTableRow:
|
||||
|
||||
|
||||
def _format_timestamp(value: str) -> str:
|
||||
"""Return a friendly UTC timestamp for table display."""
|
||||
"""Return a friendly local timestamp for table display."""
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
@@ -40,7 +43,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": str(row.id),
|
||||
"status": row.status,
|
||||
"status": row.status.lower(),
|
||||
"filename": row.filename,
|
||||
"retry_count": row.retry_count,
|
||||
"date_created": _format_timestamp(row.date_created),
|
||||
@@ -53,23 +56,74 @@ 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."""
|
||||
"""Render jobs table with search filtering and custom status chips."""
|
||||
if not rows:
|
||||
ui.label("No jobs yet.")
|
||||
with archival_card(extra_classes="p-8 text-center"):
|
||||
render_empty_state("No job records found in repository.")
|
||||
return
|
||||
|
||||
build_table(
|
||||
table = 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": "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},
|
||||
{
|
||||
"name": "id",
|
||||
"label": "Job ID",
|
||||
"field": "id",
|
||||
"sortable": True,
|
||||
"classes": "font-mono text-xs",
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"label": "Status",
|
||||
"field": "status",
|
||||
"sortable": True,
|
||||
"classes": "font-mono",
|
||||
},
|
||||
{
|
||||
"name": "filename",
|
||||
"label": "Source Filename",
|
||||
"field": "filename",
|
||||
"sortable": True,
|
||||
"classes": "font-mono text-xs",
|
||||
},
|
||||
{
|
||||
"name": "retry_count",
|
||||
"label": "Retries",
|
||||
"field": "retry_count",
|
||||
"sortable": True,
|
||||
},
|
||||
{
|
||||
"name": "date_created",
|
||||
"label": "Created",
|
||||
"field": "created_sort",
|
||||
"sortable": True,
|
||||
},
|
||||
{
|
||||
"name": "date_updated",
|
||||
"label": "Updated",
|
||||
"field": "updated_sort",
|
||||
"sortable": True,
|
||||
},
|
||||
],
|
||||
default_sort_by="created_sort",
|
||||
default_descending=True,
|
||||
classes="app-table w-full",
|
||||
search_placeholder="Search jobs by ID, filename, or status...",
|
||||
on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"),
|
||||
)
|
||||
|
||||
# Render job execution status using themed Quasar chips
|
||||
table.add_slot(
|
||||
"body-cell-status",
|
||||
r"""
|
||||
<q-td :props="props">
|
||||
<q-chip
|
||||
dense
|
||||
square
|
||||
size="sm"
|
||||
:class="`ui-status ui-status--${props.value}`"
|
||||
>
|
||||
{{ props.value.toUpperCase() }}
|
||||
</q-chip>
|
||||
</q-td>
|
||||
""",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""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
|
||||
death_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",
|
||||
"death_date": row.death_date or "Unknown",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def render_people_table(rows: Sequence[PersonTableRow]) -> None:
|
||||
"""Render people table with search filtering and custom typography."""
|
||||
if not rows:
|
||||
with archival_card(extra_classes="p-8 text-center"):
|
||||
render_empty_state("No person records found in repository.")
|
||||
return
|
||||
|
||||
table = build_table(
|
||||
rows=_serialize_rows(rows),
|
||||
columns=[
|
||||
{
|
||||
"name": "full_name",
|
||||
"label": "Full Name",
|
||||
"field": "full_name",
|
||||
"sortable": True,
|
||||
"classes": "font-serif font-semibold text-left ui-table-cell-wrap",
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"label": "Display Name",
|
||||
"field": "display_name",
|
||||
"sortable": True,
|
||||
"classes": "ui-table-cell-wrap",
|
||||
"align": "center",
|
||||
},
|
||||
{
|
||||
"name": "maiden_name",
|
||||
"label": "Maiden Name",
|
||||
"field": "maiden_name",
|
||||
"sortable": True,
|
||||
"classes": "ui-table-cell-wrap",
|
||||
"align": "center",
|
||||
},
|
||||
{
|
||||
"name": "birth_date",
|
||||
"label": "Birth Date",
|
||||
"field": "birth_date",
|
||||
"sortable": True,
|
||||
"classes": "font-mono text-xs",
|
||||
"align": "center",
|
||||
},
|
||||
{
|
||||
"name": "death_date",
|
||||
"label": "Death Date",
|
||||
"field": "death_date",
|
||||
"sortable": True,
|
||||
"classes": "font-mono text-xs",
|
||||
"align": "center",
|
||||
},
|
||||
],
|
||||
default_sort_by="full_name",
|
||||
search_placeholder="Search people by name, birth date, or death date...",
|
||||
on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"),
|
||||
)
|
||||
|
||||
# Custom column template adding an archival entity icon next to person's name
|
||||
table.add_slot(
|
||||
"body-cell-full_name",
|
||||
r"""
|
||||
<q-td :props="props">
|
||||
<div class="row items-center q-gutter-x-xs">
|
||||
<q-icon name="person" size="xs" color="primary" />
|
||||
<span class="font-serif font-semibold">{{ props.value }}</span>
|
||||
</div>
|
||||
</q-td>
|
||||
""",
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""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
|
||||
document_id: UUID
|
||||
document_name: str | None = None
|
||||
job_source_status: str | None = None
|
||||
job_source_error_detail: str | None = None
|
||||
|
||||
|
||||
def _serialize_rows(rows: Sequence[SourceTableRow]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": str(row.id),
|
||||
"page_number": row.page_number,
|
||||
"upload_name": row.upload_name,
|
||||
"document_id": str(row.document_id),
|
||||
"document_name": row.document_name or "-",
|
||||
"job_source_status": row.job_source_status or "-",
|
||||
"job_source_error_detail": row.job_source_error_detail or "-",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
|
||||
"""Render sources table and open detail page when clicking a row."""
|
||||
if not rows:
|
||||
with archival_card(extra_classes="p-8 text-center"):
|
||||
render_empty_state("No source asset records found in repository.")
|
||||
return
|
||||
|
||||
table = build_table(
|
||||
rows=_serialize_rows(rows),
|
||||
columns=[
|
||||
{
|
||||
"name": "document_name",
|
||||
"label": "Document Name",
|
||||
"field": "document_name",
|
||||
"sortable": True,
|
||||
"classes": "font-serif text-left ui-table-cell-wrap",
|
||||
"align": "left",
|
||||
"style": "width: 27%;",
|
||||
},
|
||||
{
|
||||
"name": "page_number",
|
||||
"label": "Page Number",
|
||||
"field": "page_number",
|
||||
"sortable": True,
|
||||
"style": "width: 10%;",
|
||||
},
|
||||
{
|
||||
"name": "upload_name",
|
||||
"label": "Upload Title",
|
||||
"field": "upload_name",
|
||||
"sortable": True,
|
||||
"classes": "font-serif text-left ui-table-cell-wrap",
|
||||
"align": "left",
|
||||
"style": "width: 25%;",
|
||||
},
|
||||
{
|
||||
"name": "job_source_status",
|
||||
"label": "Status",
|
||||
"field": "job_source_status",
|
||||
"sortable": True,
|
||||
"classes": "font-mono",
|
||||
"align": "center",
|
||||
"style": "width: 14%;",
|
||||
},
|
||||
{
|
||||
"name": "job_source_error_detail",
|
||||
"label": "Error Detail",
|
||||
"field": "job_source_error_detail",
|
||||
"sortable": False,
|
||||
"classes": "font-mono text-xs text-left ui-text-muted ui-table-cell-wrap",
|
||||
"align": "left",
|
||||
"style": "width: 24%;",
|
||||
},
|
||||
],
|
||||
default_sort_by="page_number",
|
||||
on_row_click_id=lambda source_id: ui.navigate.to(f"/sources/{source_id}"),
|
||||
)
|
||||
|
||||
# Render job execution status using themed Quasar chips
|
||||
table.add_slot(
|
||||
"body-cell-job_source_status",
|
||||
r"""
|
||||
<q-td :props="props">
|
||||
<q-chip
|
||||
dense
|
||||
square
|
||||
size="sm"
|
||||
:class="`ui-status ui-status--${props.value}`"
|
||||
>
|
||||
{{ props.value }}
|
||||
</q-chip>
|
||||
</q-td>
|
||||
""",
|
||||
)
|
||||
@@ -9,63 +9,67 @@ from typing import Any
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.models import Job
|
||||
from transcription.models import Revision
|
||||
from transcription.db.models import Job
|
||||
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 ui-card-surface")
|
||||
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")
|
||||
_metadata_row(label="Prompt", value=job.prompt_name or "unknown")
|
||||
ui.label(caption).classes("text-caption ui-text-muted")
|
||||
_metadata_row(label="Prompt", value=_latest_job_prompt(job) 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-card-surface"):
|
||||
ui.markdown(latest_transcription)
|
||||
|
||||
if latest_error_detail:
|
||||
with ui.card().classes("w-full ui-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} ui-card-surface")
|
||||
|
||||
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 ui-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")
|
||||
with ui.dialog() as delete_dialog, ui.card().classes("q-pa-md ui-card-surface"):
|
||||
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 +90,36 @@ 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:
|
||||
with ui.card().classes("w-full q-pa-sm"):
|
||||
ui.markdown(revision.text)
|
||||
if revision.revised_text:
|
||||
with ui.card().classes("w-full q-pa-sm ui-card-surface"):
|
||||
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 _latest_job_prompt(job: Job) -> str | None:
|
||||
for job_source in sorted(job.job_sources, key=lambda item: item.executed_at, reverse=True):
|
||||
if job_source.job and job_source.job.prompt_name:
|
||||
return job_source.job.prompt_name
|
||||
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 +127,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 ui-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"')
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user