generated from john/python-template
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00ed176ac1 | ||
|
|
99a128e981 | ||
|
|
aa94f34de4 | ||
|
|
661e2b1bec | ||
|
|
d75083a666 | ||
|
|
d0a3ca0289 | ||
|
|
209c48987c | ||
|
|
4ed1f43eda | ||
|
|
ce8fcce6b0 | ||
|
|
4ae8e5be4f | ||
|
|
6b5b0500b3 | ||
|
|
bbf7fe28c2 | ||
|
|
c4d25c1be8 | ||
|
|
1fa5eb1127 | ||
|
|
3eefc36239 | ||
|
|
ec6617a1c4 | ||
|
|
9eb0f40c08 | ||
|
|
f769d29da1 | ||
|
|
8afc462a6d | ||
|
|
3d6daec561 | ||
|
|
1cc2f319d5 | ||
|
|
d2b793ea69 | ||
|
|
a975ca299a | ||
|
|
a3b3bab571 | ||
|
|
bc21a97019 | ||
|
|
0973311d9f | ||
|
|
eaf9805121 | ||
|
|
ec61013b47 | ||
|
|
97cb7055d4 | ||
|
|
f975e25093 | ||
|
|
90ba8fefdd | ||
|
|
b8998025e2 | ||
|
|
002eb572e9 | ||
|
|
d44c7de684 | ||
|
|
282b0fb967 | ||
|
|
a9a47c3906 | ||
|
|
9ada09accf | ||
|
|
67b0980664 | ||
|
|
e35a8ec060 | ||
|
|
3a141bd4cc | ||
|
|
8129f5a9e8 | ||
|
|
58b4c381a4 | ||
|
|
5719debbaa | ||
|
|
e7c7ab71b4 | ||
|
|
ca5c9f787f | ||
|
|
8064821503 | ||
|
|
593388ef3a | ||
|
|
83ee7b31e0 | ||
|
|
455a01d7c4 |
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
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'
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI Conceptual Boundaries
|
||||||
|
|
||||||
|
Keep dependencies flowing in this direction:
|
||||||
|
|
||||||
|
`ui/__init__.py` -> `pages` -> `components`
|
||||||
|
|
||||||
|
Pages may depend on application services and framework-provided dependencies. Components may depend on smaller components and shared presentation helpers. Services and domain modules must never depend on the UI.
|
||||||
|
|
||||||
|
## Package Root
|
||||||
|
|
||||||
|
- Keep `ui/__init__.py` as the UI composition root: register global assets, register pages, and mount NiceGUI on FastAPI.
|
||||||
|
- Do not put feature rendering, service calls, persistence, or route-specific state in the package root.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
- Pages own route registration and route-level orchestration.
|
||||||
|
- Resolve request or application dependencies, call [services](../../src/transcription/services/), adapt returned data for presentation when needed, and coordinate refresh, navigation, and notifications here.
|
||||||
|
- Do not query, mutate, commit, or roll back the database from a page. Do not import database engines, sessions, operations, or query-building APIs. Persistence belongs to services or workflow functions.
|
||||||
|
- Framework dependency types may cross into page handlers only to construct or invoke services; do not pass sessions or session factories into components.
|
||||||
|
- Keep business rules, lifecycle transitions, transaction boundaries, and cross-service workflows out of page callbacks.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- Components own reusable rendering, widget-local state, input normalization, and presentation-only formatting.
|
||||||
|
- Expose user actions through typed callback parameters. The calling page decides which service or workflow runs and what refresh or navigation follows.
|
||||||
|
- Do not register routes, resolve request/app state, instantiate services, or access persistence from components.
|
||||||
|
- Components may accept ORM models returned by services as read-only snapshots. Only use fields and relationships that the service loaded eagerly; never mutate models, trigger lazy loading, or expose session behavior.
|
||||||
|
- A component may compose lower-level components, but it must not import from `pages`.
|
||||||
|
|
||||||
|
## Shared UI Infrastructure
|
||||||
|
|
||||||
|
- Keep app-wide navigation and layout primitives in `components/app_shell.py`.
|
||||||
|
- Keep generic table/event adaptation in `components/table/common.py`; feature-specific columns, row read models, and formatting belong in the feature table module.
|
||||||
|
- Keep exception normalization and user-facing error display in `components/error_presenter.py`; preserve `AppError` details and operation identifiers at page/component boundaries.
|
||||||
|
|
||||||
|
## CSS Assets
|
||||||
|
|
||||||
|
- Keep CSS under `ui/static` and split it into manageable, feature-oriented files. Do not grow a monolithic stylesheet or embed substantial style blocks in Python components.
|
||||||
|
- Load each stylesheet from the page, component, or composition root that needs it with `ui.add_css(...)`. Use shared registration only for genuinely application-wide styles.
|
||||||
|
- Read stylesheet text through `importlib.resources.files(...)` so loading works from installed packages and is independent of the working directory.
|
||||||
|
- Centralize CSS reading in one typed helper cached by relative resource path with `functools.cache` or an equivalent unbounded `lru_cache`. Cache the immutable stylesheet text to avoid repeated resource I/O during component renders; keep NiceGUI registration decisions at the caller.
|
||||||
|
- Do not encode application behavior in CSS or other static assets.
|
||||||
|
|
||||||
|
## State and Side Effects
|
||||||
|
|
||||||
|
- Limit component state to ephemeral interaction state such as loading flags, form values, dialogs, and expansion state.
|
||||||
|
- Application and worker state must be resolved at the page or application boundary and passed through narrow interfaces such as callbacks or notifier protocols.
|
||||||
|
- Keep filesystem, network, provider, and worker orchestration behind application services or dedicated adapters. UI code may trigger those operations but must not implement them.
|
||||||
@@ -14,3 +14,6 @@ wheels/
|
|||||||
|
|
||||||
# SQLite database
|
# SQLite database
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
# Document images
|
||||||
|
uploads/*
|
||||||
|
|||||||
Vendored
+4
-8
@@ -8,14 +8,10 @@
|
|||||||
"module": "debugpy",
|
"module": "debugpy",
|
||||||
"args": [
|
"args": [
|
||||||
"-m",
|
"-m",
|
||||||
"uvicorn",
|
"transcription",
|
||||||
"transcription.app:create_app",
|
"--host", "127.0.0.1",
|
||||||
"--factory",
|
"--port", "9999",
|
||||||
"--host",
|
"--database.driver", "sqlite"
|
||||||
// "127.0.0.1",
|
|
||||||
"0.0.0.0",
|
|
||||||
"--port",
|
|
||||||
"8080"
|
|
||||||
],
|
],
|
||||||
"justMyCode": true,
|
"justMyCode": true,
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ The app lets you upload a document image/PDF, queues a background transcription
|
|||||||
- Persist document + job records in SQLite
|
- Persist document + job records in SQLite
|
||||||
- Process jobs in a background worker (`queued -> processing -> transcribed/failed`)
|
- Process jobs in a background worker (`queued -> processing -> transcribed/failed`)
|
||||||
- Store transcript text (or failure detail)
|
- Store transcript text (or failure detail)
|
||||||
- Track transcript revisions (AI-generated and manual updates)
|
|
||||||
- Show status and results in the NiceGUI interface
|
- Show status and results in the NiceGUI interface
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
@@ -23,30 +22,102 @@ uv sync
|
|||||||
|
|
||||||
### 2) Configure environment
|
### 2) Configure environment
|
||||||
|
|
||||||
Create a `.env` file in the project root (minimum required setting shown):
|
Create a `.env` file in the project root with the required OpenRouter API key:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
OPENROUTER_API_KEY=your_openrouter_api_key
|
OPENROUTER_API_KEY=your_openrouter_api_key
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional settings (defaults shown):
|
Settings are read from CLI arguments first, then environment variables, then `.env`, then the defaults below.
|
||||||
|
|
||||||
|
### Configuration Source Precedence
|
||||||
|
|
||||||
|
When the same setting is provided in multiple places, the value is chosen in this order (highest priority first):
|
||||||
|
|
||||||
|
1. CLI arguments (for example `--port 9999`)
|
||||||
|
2. Settings constructor arguments (used mainly in tests)
|
||||||
|
3. Environment variables
|
||||||
|
4. `.env` file values
|
||||||
|
5. Model defaults in code
|
||||||
|
|
||||||
|
Practical examples:
|
||||||
|
|
||||||
|
- `--port 9999` overrides both `PORT=8000` in the shell and `PORT=7000` in `.env`.
|
||||||
|
- `DATABASE__PATH=prod.db` in the shell overrides `DATABASE__PATH=dev.db` in `.env`.
|
||||||
|
|
||||||
|
#### Server and runtime
|
||||||
|
|
||||||
|
| Environment variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `HOST` | `0.0.0.0` | Address on which the server listens. |
|
||||||
|
| `PORT` | `8000` | Server port. |
|
||||||
|
| `LOG_LEVEL` | `info` | Uvicorn and application log level. |
|
||||||
|
| `RELOAD` | `false` | Restart the development server when source files change. |
|
||||||
|
| `ENVIRONMENT` | `development` | Runtime environment: `development`, `test`, or `production`. |
|
||||||
|
|
||||||
|
#### Provider
|
||||||
|
|
||||||
|
| Environment variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PROVIDER` | `openrouter` | Transcription provider. |
|
||||||
|
| `OPENROUTER_API_KEY` | Required | OpenRouter API key. |
|
||||||
|
| `PROVIDER_MODEL` | Provider default | Optional model override. |
|
||||||
|
| `OPENROUTER_HTTP_REFERER` | Unset | Optional OpenRouter attribution URL. |
|
||||||
|
| `OPENROUTER_APP_TITLE` | Unset | Optional OpenRouter attribution title. |
|
||||||
|
|
||||||
|
#### Database and files
|
||||||
|
|
||||||
|
Use nested env vars for database settings (recommended):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL=sqlite:///./transcription.db
|
DATABASE__DRIVER=sqlite
|
||||||
|
DATABASE__PATH=app.db
|
||||||
|
# BOOTSTRAP_SCHEMA_ON_STARTUP=true
|
||||||
|
SQLITE_CHECK_SAME_THREAD=false
|
||||||
UPLOAD_DIR=./uploads
|
UPLOAD_DIR=./uploads
|
||||||
PROMPT_DIR=./prompts
|
PROMPT_DIR=./prompts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For PostgreSQL:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE__DRIVER=postgres
|
||||||
|
DATABASE__HOST=localhost
|
||||||
|
DATABASE__PORT=5432
|
||||||
|
DATABASE__DATABASE=transcription
|
||||||
|
DATABASE__USER=postgres
|
||||||
|
DATABASE__PASSWORD=change-me
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses Pydantic nested settings (`env_nested_delimiter='__'`) and avoids JSON blobs in `.env`. A top-level `DATABASE={...}` JSON value is still supported as a fallback, and nested keys such as `DATABASE__PATH` take precedence over conflicting JSON keys.
|
||||||
|
|
||||||
|
`BOOTSTRAP_SCHEMA_ON_STARTUP` creates missing tables when the app starts. When unset, it is enabled in `development` and `test`, and disabled in `production`; set it explicitly to override that policy. `SQLITE_CHECK_SAME_THREAD` defaults to `false`.
|
||||||
|
|
||||||
|
#### Worker
|
||||||
|
|
||||||
|
```env
|
||||||
|
WORKER_MAX_RETRIES=0
|
||||||
|
WORKER_RETRY_BACKOFF_SECONDS=0
|
||||||
|
WORKER_PROVIDER_TIMEOUT_SECONDS=20
|
||||||
|
WORKER_MIN_TRANSCRIPTION_CHARS=0
|
||||||
|
WORKER_MIN_TRANSCRIPTION_LINES=0
|
||||||
|
WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
|
||||||
|
```
|
||||||
|
|
||||||
### 3) Run the app
|
### 3) Run the app
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run uvicorn transcription.app:create_app --factory --reload
|
uv run python -m transcription --port 9999 --reload --database.driver sqlite --bootstrap-schema-on-startup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This starts the development server with SQLite, creates missing tables, and enables automatic reload. Run `uv run python -m transcription --help` for all CLI options; CLI names use kebab case and nested database options use dot notation, such as `--database.path ./data/transcription.db`.
|
||||||
|
|
||||||
### 4) Open in browser
|
### 4) Open in browser
|
||||||
|
|
||||||
- GUI: [http://[IP_ADDRESS]:8000/ui](http://[IP_ADDRESS]:8000/ui)
|
- GUI: [http://localhost:9999/ui](http://localhost:9999/ui)
|
||||||
- Health check: [http://[IP_ADDRESS]:8000/healthz](http://[IP_ADDRESS]:8000/healthz)
|
- Health check: [http://localhost:9999/healthz](http://localhost:9999/healthz)
|
||||||
|
|
||||||
|
Replace `localhost` with the server's hostname or IP address when connecting from another machine.
|
||||||
|
|
||||||
## How to navigate the GUI
|
## How to navigate the GUI
|
||||||
|
|
||||||
@@ -62,11 +133,8 @@ uv run uvicorn transcription.app:create_app --factory --reload
|
|||||||
|
|
||||||
- **Job detail page** (`/ui/jobs/{job_id}`)
|
- **Job detail page** (`/ui/jobs/{job_id}`)
|
||||||
- Shows job metadata and status.
|
- Shows job metadata and status.
|
||||||
- Shows transcript metadata, including provider and model.
|
- Displays transcript text when successful.
|
||||||
- Shows a version table with `Created` and `Version`.
|
- Displays failure detail when transcription fails.
|
||||||
- Displays latest version text in an editable textbox.
|
|
||||||
- **Update** creates a new transcript version.
|
|
||||||
- Displays failure detail for failed revisions.
|
|
||||||
|
|
||||||
## Prompt artifacts
|
## Prompt artifacts
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# Historical Document Transcription
|
|
||||||
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
|
|
||||||
### Verbatim vs. Clean Copy
|
|
||||||
Transcriptions should be Verbatim and follow scholarly research guidelines, with no modifications to the original text.
|
|
||||||
|
|
||||||
### Prompt Curation Policy
|
|
||||||
Transcription behavior should be implemented with prompt assets that are human-maintainable over time.
|
|
||||||
|
|
||||||
1. Each transcription prompt is stored as an individual Markdown file.
|
|
||||||
2. Prompt files are refined iteratively as document quality and edge cases are discovered.
|
|
||||||
3. Prompt changes should be scoped to one prompt file at a time whenever possible to keep review history clear.
|
|
||||||
|
|
||||||
### Potential Document Issues
|
|
||||||
| Document Issue | How to Handle It | Example |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| **Misspellings & Errors** | Retain original spelling and insert italicized `[sic]` directly after the error. | `The weather was very cold and publick [sic] business delayed.` |
|
|
||||||
| **Missing Words / Slips** | Insert the missing word inside square brackets to restore basic readability. | `We went [to] the store to buy supplies.` |
|
|
||||||
| **Uncertain / Guesswork** | Place your best hypothesis followed by a question mark inside square brackets. | `He went to [Boston?] yesterday to meet the governor.` |
|
|
||||||
| **Completely Illegible** | Use a clear descriptive term like `[illegible]` or specify the reason (e.g., `[torn]`, `[ink blot]`). | `The total cost was [illegible] dollars.` or `The letter ends here [remainder of page torn].` |
|
|
||||||
| **Crossed-out Text** | Wrap the removed word or phrase in a deleted tag to preserve the author's edits. | `We left at [deleted: noon] one o'clock instead.` |
|
|
||||||
| **Squeezed-in Text** | Wrap text that was added above the line or in a tight space in an inserted tag. | `The [inserted: red] house on the hill was abandoned.` |
|
|
||||||
| **Superscripts & Abbreviations** | Bring raised letters down to the main line, or optionally expand them in brackets. | `Change Gen^l to Genl` OR `Change to Gen[era]l depending on project preference.` |
|
|
||||||
| **Images / Seals / Signs** | Describe the non-textual element using italicized text inside square brackets. | `[wax notary seal attached here]` or `[sketch of a fort layout]` |
|
|
||||||
| **Marginalia / Notes** | Note the spatial transition clearly before transcribing the note itself. | `[written in left margin:] Do not share this with anyone.` |
|
|
||||||
| **Line Breaks / Hyphens** | Rejoin words split across a page margin silently, dropping the line-break hyphen. | `Original: "estab- / lishment" becomes "establishment"` |
|
|
||||||
| **Ambiguous Capitalization** | Default to modern capitalization rules unless an archaic uppercase letter is clearly intentional. | `If a standard noun like 'Farm' looks randomly capitalized, type 'farm'.` |
|
|
||||||
**Hierarchical Outlines** | Preserve exact numbering characters (including lowercase Roman numerals or terminal 'j'). Replicate indentation levels using spaces/tabs. Do not correct math or sequence errors silently. | `I. Main Topic`<br>` a. Sub-point`<br>` b. Next point`<br>`III. [sic] Third Topic` |
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# 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/db/models.py` (SQLModel/Pydantic V2 schema definitions for the current implementation)
|
||||||
|
* `src/transcription/services/*.py` (Transactional operations for `Document`, `Person`, `Source`, `Job`, and `JobSource`)
|
||||||
|
|
||||||
|
### Infrastructure Layer
|
||||||
|
|
||||||
|
* `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_v2.md)
|
||||||
|
- [System Design Intent](invariant/intent.md)
|
||||||
|
- [Transcription Methodology](invariant/transcription_methodology.md)
|
||||||
|
- System Architecture (this document)
|
||||||
|
- [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,88 @@
|
|||||||
|
# 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](invariant/intent.md)
|
||||||
|
- [Transcription Methodology](invariant/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)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# implementation_plan_v2
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the current V1 SQLModel schema with the approved V2 schema and make sure every database operation works through the existing async SQLAlchemy/SQLModel session layer.
|
||||||
|
|
||||||
|
Use a fresh database. There will be no migrations, data conversion, legacy compatibility shims, or parallel V1/V2 code paths.
|
||||||
|
|
||||||
|
## Current Project Impact
|
||||||
|
|
||||||
|
- `src/transcription/db/models.py` still defines the V1 `Document`, `Source`, `Job`, and `Revision` tables.
|
||||||
|
- The V2 target adds `Person`, `DocumentPerson`, and `JobSource`, moves revisions onto `Source`, and removes the direct `Source.job_id` relationship.
|
||||||
|
- The engine, session factory, transaction handling, and PostgreSQL async support already exist and do not need to be rewritten.
|
||||||
|
- Async CRUD currently lives in `DocumentService`, `JobService`, `TranscriptionService`, and the upload record helper. Their queries and eager-loading options depend on V1 relationships.
|
||||||
|
- Existing tests cover only part of the schema and CRUD surface.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### 1. Update the schema
|
||||||
|
|
||||||
|
- Replace the models in `src/transcription/db/models.py` with the approved V2 tables, enums, relationships, foreign keys, constraints, and indexes.
|
||||||
|
- Remove `Revision`, `Source.job_id`, and the transcription fields that no longer belong on `Job`.
|
||||||
|
- Keep `create_all()` as the schema bootstrap for a fresh database.
|
||||||
|
- Delete `_ensure_sqlite_compat_columns()` and all schema patching from `src/transcription/db/operations.py`.
|
||||||
|
- Keep the Python models and `docs/schema_v2.md` consistent.
|
||||||
|
|
||||||
|
### 2. Align the async CRUD methods
|
||||||
|
|
||||||
|
- Keep the existing `ServiceBase` session and transaction pattern.
|
||||||
|
- Update document CRUD to load and manage its ordered `Source` rows and `DocumentPerson` links.
|
||||||
|
- Update job CRUD and queue queries to use `JobSource` instead of `Source.job_id`.
|
||||||
|
- Add the missing async CRUD operations for `Person`, `Source`, `DocumentPerson`, and `JobSource` using the existing service style. Do not add another repository abstraction.
|
||||||
|
- Replace revision CRUD with direct updates to `Source.revised_text` and `Source.date_revised`.
|
||||||
|
- Remove the temporary transcript compatibility aliases instead of redirecting them.
|
||||||
|
- Update only direct database call sites that construct or query these records; UI and worker feature changes are not part of this work.
|
||||||
|
|
||||||
|
### 3. Verify the schema and CRUD
|
||||||
|
|
||||||
|
- Update the schema bootstrap test to expect `person`, `document`, `document_person`, `source`, `job`, and `job_source`, with no `revision` table.
|
||||||
|
- Add async create, read, update, delete, list, and filtered-query tests for each entity that exposes those operations.
|
||||||
|
- Test relationship loading, page ordering, uniqueness constraints, delete behavior, status values, and `JobSource` JSON fields.
|
||||||
|
- Test both service-owned sessions and caller-provided sessions so flush/commit behavior remains correct.
|
||||||
|
- Run the focused database and service tests, then the full suite with `uv run pytest`.
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
- A fresh database is created directly from the V2 SQLModel metadata.
|
||||||
|
- All async CRUD methods pass against the V2 relationships and fields.
|
||||||
|
- No code references `Revision`, `Source.job_id`, removed `Job` transcription fields, or compatibility aliases.
|
||||||
|
- The focused tests and full test suite pass.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Database migrations or preservation of V1 data
|
||||||
|
- Legacy compatibility code
|
||||||
|
- Database engine or session-layer rewrites
|
||||||
|
- UI redesign, batch orchestration, worker concurrency, deployment, and operational runbooks
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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 18+
|
||||||
|
* **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](invariant/intent.md)
|
||||||
|
- [Transcription Methodology](invariant/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,24 @@
|
|||||||
|
# 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))
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Transcription Methodology & Style Guide
|
||||||
|
|
||||||
|
## 1. Overview & Core Philosophy
|
||||||
|
|
||||||
|
This document defines the formal transcription standard for processing historical manuscripts, letters, diaries, and printed ephemera.
|
||||||
|
|
||||||
|
Following the principles established by Mary-Jo Kline in A Guide to Documentary Editing, this project adheres to a Strict Literal Transcription (Verbatim) model as its foundational layer. The primary goal is total textual fidelity—capturing what the author wrote, not what they intended to write—while ensuring the output remains machine-readable and indexable for downstream digital query and search systems.
|
||||||
|
|
||||||
|
## 2. Textual Policy
|
||||||
|
|
||||||
|
Transcribers (human or AI) must record the exact text of the source document without silent corrections, modernizations, or stylistic smoothing except where explicitly instructed in this guide.
|
||||||
|
|
||||||
|
* **Substantives:** Words, letter forms, structural layout, and semantic content must be recorded strictly as presented in the original document.
|
||||||
|
|
||||||
|
* **Accidentals:** Punctuation, capitalization, misspellings, and archaic character representations must be preserved unless an explicit rule below allows for standardization.
|
||||||
|
|
||||||
|
## 3. Standard Transcription Rules & Markup
|
||||||
|
|
||||||
|
The following rules map directly to editorial conventions for handling common manuscript anomalies and physical document features.
|
||||||
|
|
||||||
|
### 3.1 Textual Anomalies & Corrections
|
||||||
|
|
||||||
|
| Document Feature | Rule | Standard Markup Format | Output Example |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Misspellings & Errors** | Retain original spelling verbatim. Insert an italicized [sic] immediately following the error. Do not correct spelling silently. | [sic] | The weather was very cold and publick [sic] business delayed. |
|
||||||
|
| **Missing Words / Omissions** | Insert necessary words required to restore basic grammatical sense inside square brackets. | [word] | We went [to] the store to buy supplies. |
|
||||||
|
| **Uncertain / Conjectural** | Place best hypothesis followed by a question mark inside square brackets when handwriting is doubtful. | [word?] | He went to [Boston?] yesterday to meet the governor. |
|
||||||
|
| **Completely Illegible** | Use [illegible] for unreadable script. Use explicit damage descriptors when physical impairment prevents reading. | [illegible] or [reason] | The total cost was [illegible] dollars. or The letter ends here [remainder of page torn]. |
|
||||||
|
| **Canceled / Struck-through** | Wrap text removed by the author inside a [deleted: ...] tag to preserve authorial revisions. | [deleted: text] | We left at [deleted: noon] one o'clock instead. |
|
||||||
|
| **Interlineations / Additions** | Wrap text inserted above, below, or in margins into the narrative flow inside an [inserted: ...] tag. | [inserted: text] | The [inserted: red] house on the hill was abandoned. |
|
||||||
|
|
||||||
|
### 3.2 Typography, Characters & Layout
|
||||||
|
|
||||||
|
| Document Feature | Rule | Standard Markup Format | Output Example |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Superscripts & Abbreviations** | Bring raised letters down to the main line. Optionally expand abbreviations within square brackets based on project configuration. | [expanded] | Gen^l becomes Genl or Gen[era]l. |
|
||||||
|
| **Line-End Hyphenation** | Rejoin words split across a page or line boundary silently, dropping the soft hyphen. | Silently rejoin | Original: "estab- / lishment" becomes establishment |
|
||||||
|
| **Capitalization** | Preserve explicit capitalization. Default to modern capitalization rules only when authorial intent is ambiguous or archaic forms confuse sentence structure. | Literal / Contextual | If a standard noun like 'Farm' is clearly capitalized, record 'Farm'. If ambiguous, default to 'farm'. |
|
||||||
|
| **Hierarchical Outlines** | Preserve exact numbering characters (including lowercase Roman numerals or terminal 'j'). Replicate indentation levels using standard spacing. Do not correct sequence or mathematical errors. | Preserve syntax | I. Main Topic a. Sub-point b. Next pointIII. [sic] Third Topic |
|
||||||
|
|
||||||
|
### 3.3 Visual & Spatial Elements
|
||||||
|
|
||||||
|
| Document Feature | Rule | Standard Markup Format | Output Example |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Non-Textual Artifacts** | Record non-textual elements (seals, stamps, sketches, physical damage) using brief descriptive text inside square brackets. | [description] | [wax notary seal attached here] or [sketch of a fort layout] |
|
||||||
|
| **Marginalia & Addenda** | Explicitly indicate spatial transitions before transcribing content located in margins or non-standard orientations. | [location:] | [written in left margin:] Do not share this with anyone. |
|
||||||
|
|
||||||
|
## 4. Prompt Asset Integration
|
||||||
|
|
||||||
|
When executing programmatic transcriptions via LLM APIs or local models, processing instructions must be packaged into single-purpose system prompts aligned with these rules.
|
||||||
|
|
||||||
|
1. **Isolation:** Each transcription prompt file exists as an independent Markdown asset in the repository.
|
||||||
|
2. **Deterministic Output:** Prompts must explicitly instruct models to follow the markup standards in Section 3 without introducing conversational wrappers, extra prose, or structural markdown outside the source document's native layout.
|
||||||
|
3. **Iterative Scoping:** Rule modifications or edge-case additions must be submitted as isolated delta commits to individual prompt files to maintain clean revision tracking.
|
||||||
@@ -1,572 +0,0 @@
|
|||||||
# Step 1 Implementation Plan: `config.py` + `models.py` + `db.py`
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Establish the foundational data layer and configuration system that every subsequent MVP step builds on. At the end of this step, the project has a runnable Python package with a validated schema, typed configuration, and a test suite proving the data layer works — before any UI, worker, or AI provider code exists.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Prerequisite: Project Structure Scaffolding
|
|
||||||
|
|
||||||
Before writing any logic, create the package skeleton so imports work correctly.
|
|
||||||
|
|
||||||
### Files to create (empty `__init__.py` stubs)
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
└── transcription/
|
|
||||||
├── __init__.py
|
|
||||||
├── providers/
|
|
||||||
│ └── __init__.py
|
|
||||||
├── services/
|
|
||||||
│ └── __init__.py
|
|
||||||
└── ui/
|
|
||||||
└── __init__.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Files to create (with logic — the Step 1 deliverables)
|
|
||||||
|
|
||||||
```
|
|
||||||
src/transcription/config.py
|
|
||||||
src/transcription/models.py
|
|
||||||
src/transcription/db.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test files to create
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
├── __init__.py
|
|
||||||
├── conftest.py
|
|
||||||
├── test_config.py
|
|
||||||
├── test_models.py
|
|
||||||
└── test_db.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update `pyproject.toml`
|
|
||||||
|
|
||||||
Add the dependencies that Step 1 requires and won't change later:
|
|
||||||
|
|
||||||
```toml pyproject.toml
|
|
||||||
[project]
|
|
||||||
name = "transcription"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Historical document transcription system"
|
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
dependencies = [
|
|
||||||
"openrouter>=0.7.0",
|
|
||||||
"pydantic>=2.13.4",
|
|
||||||
"pydantic-settings>=2.9.1",
|
|
||||||
"sqlmodel>=0.0.25",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"pytest>=8.0",
|
|
||||||
"pytest-asyncio>=0.25",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
addopts = "--strict-markers -q"
|
|
||||||
markers = [
|
|
||||||
"unit: pure logic tests with no external dependencies",
|
|
||||||
"integration: tests that touch framework or database contracts",
|
|
||||||
"external: tests that call external services (slow, requires credentials)",
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Key additions:
|
|
||||||
- **`openrouter`** — official OpenRouter Python SDK used for model calls
|
|
||||||
- **`pydantic-settings`** — for `BaseSettings` with env-var loading (this was split out of `pydantic` core in v2)
|
|
||||||
- **`sqlmodel`** — provides SQLModel (which bundles SQLAlchemy + Pydantic model integration) and the SQLite driver
|
|
||||||
- **`pytest` + `pytest-asyncio`** — in `dev` extras for test execution
|
|
||||||
- **`[tool.pytest.ini_options]`** — strict marker checking enabled from the start; markers registered upfront per pytesting skill conventions
|
|
||||||
|
|
||||||
### Delete `hello.py`
|
|
||||||
|
|
||||||
The placeholder file is no longer needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. `config.py` — Centralized Configuration
|
|
||||||
|
|
||||||
**Satisfies:** REQ-8 (centralized config and logging at startup)
|
|
||||||
|
|
||||||
### Design Decisions
|
|
||||||
|
|
||||||
| Decision | Rationale |
|
|
||||||
|----------|-----------|
|
|
||||||
| Use `pydantic-settings` `BaseSettings` | Type-safe, validates on construction, loads from env vars and `.env` files automatically |
|
|
||||||
| `PROVIDER` constrained to `openrouter` for MVP | Keeps configuration explicit while avoiding premature multi-provider complexity |
|
|
||||||
| `OPENROUTER_API_KEY` required | Matches official SDK docs and avoids ambiguous provider-agnostic naming |
|
|
||||||
| `PROVIDER_MODEL` defaults to `None` | OpenRouter adapter (Step 3) supplies a sensible default when `None` |
|
|
||||||
| `OPENROUTER_HTTP_REFERER` and `OPENROUTER_APP_TITLE` optional | Matches SDK optional app-attribution fields |
|
|
||||||
| `DATABASE_URL` defaults to SQLite | Zero-setup local development; PostgreSQL swap is a single env-var change post-MVP |
|
|
||||||
| `UPLOAD_DIR` and `PROMPT_DIR` as `Path` objects | Enables `.mkdir(parents=True, exist_ok=True)` and path validation at startup |
|
|
||||||
| Logging configured via `logging.config.dictConfig` in `setup_logging()` | Centralized, explicit formatter/handler/root logger topology; called once at startup with `disable_existing_loggers=False` |
|
|
||||||
|
|
||||||
### Proposed Implementation
|
|
||||||
|
|
||||||
```python src/transcription/config.py
|
|
||||||
"""Centralized application configuration.
|
|
||||||
|
|
||||||
All settings are loaded from environment variables (or a .env file)
|
|
||||||
once at startup. Provider-specific defaults (model names, base URLs)
|
|
||||||
are resolved by the provider adapters, not here.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from enum import StrEnum
|
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
import logging
|
|
||||||
import logging.config
|
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class Provider(StrEnum):
|
|
||||||
OPENROUTER = "openrouter"
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=".env",
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- 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
|
|
||||||
|
|
||||||
# --- persistence ---
|
|
||||||
database_url: str = "sqlite:///./transcription.db"
|
|
||||||
|
|
||||||
# --- filesystem paths ---
|
|
||||||
upload_dir: Path = Path("./uploads")
|
|
||||||
prompt_dir: Path = Path("./prompts")
|
|
||||||
|
|
||||||
|
|
||||||
LOGGING_CONFIG: dict[str, object] = {
|
|
||||||
"version": 1,
|
|
||||||
"disable_existing_loggers": False,
|
|
||||||
"formatters": {
|
|
||||||
"standard": {
|
|
||||||
"format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
|
||||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"handlers": {
|
|
||||||
"console": {
|
|
||||||
"class": "logging.StreamHandler",
|
|
||||||
"formatter": "standard",
|
|
||||||
"stream": "ext://sys.stdout",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"level": "INFO",
|
|
||||||
"handlers": ["console"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
"""Return the singleton Settings instance.
|
|
||||||
|
|
||||||
Cached so the entire application shares one validated config.
|
|
||||||
"""
|
|
||||||
return Settings()
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging() -> None:
|
|
||||||
"""Configure root logging once at startup."""
|
|
||||||
logging.config.dictConfig(LOGGING_CONFIG)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Behaviors
|
|
||||||
|
|
||||||
- **Startup validation**: If `OPENROUTER_API_KEY` is missing from the environment, `Settings()` raises a `ValidationError` immediately — the app won't start with a missing key.
|
|
||||||
- **`.env` support**: Developers can create a `.env` file in the project root for local keys; it's never committed (already covered by the existing `.gitignore` pattern or a new entry).
|
|
||||||
- **`extra="ignore"`**: Unknown env vars don't cause errors, keeping the config resilient to unrelated environment variables.
|
|
||||||
- **`lru_cache`**: `get_settings()` is the single access point. All modules import and call this function rather than constructing `Settings` directly.
|
|
||||||
- **Centralized logging**: `setup_logging()` calls `dictConfig` exactly once at startup; all modules should use `logging.getLogger(__name__)` and avoid `basicConfig`.
|
|
||||||
|
|
||||||
### `.env` template (not committed — add to `.gitignore`)
|
|
||||||
|
|
||||||
```bash .env.example
|
|
||||||
PROVIDER=openrouter
|
|
||||||
OPENROUTER_API_KEY=sk-or-...
|
|
||||||
# PROVIDER_MODEL= # optional: OpenRouter adapter supplies default
|
|
||||||
# OPENROUTER_HTTP_REFERER=https://example.com
|
|
||||||
# OPENROUTER_APP_TITLE=Historical Transcription MVP
|
|
||||||
# DATABASE_URL=sqlite:///./transcription.db
|
|
||||||
# UPLOAD_DIR=./uploads
|
|
||||||
# PROMPT_DIR=./prompts
|
|
||||||
```
|
|
||||||
|
|
||||||
### `.gitignore` addition
|
|
||||||
|
|
||||||
```gitignore .gitignore
|
|
||||||
# ... existing entries ...
|
|
||||||
|
|
||||||
# Environment secrets
|
|
||||||
.env
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. `models.py` — SQLModel Domain Models
|
|
||||||
|
|
||||||
**Satisfies:** REQ-3 (persist and expose job states), REQ-4 (persist transcription output and failure details)
|
|
||||||
|
|
||||||
### Design Decisions
|
|
||||||
|
|
||||||
| Decision | Rationale |
|
|
||||||
|----------|-----------|
|
|
||||||
| Three models: `Document`, `Job`, `Transcript` | Minimal set from MVP Feature 5. One-to-many from Document→Job and one-to-one from Job→Transcript |
|
|
||||||
| `JobStatus` as a `StrEnum` | Readable in the database (`"queued"` not `1`), type-safe in Python, trivially serializable to JSON for the UI |
|
|
||||||
| Status values: `queued`, `processing`, `transcribed`, `failed` | Matches MVP Feature 2 lifecycle. REQ-3 also lists `upload` and `completed` — these are deferred to post-MVP when revision/review workflows exist |
|
|
||||||
| UUIDs for primary keys | Avoids auto-increment collision concerns if we later move to PostgreSQL; safe for distributed ID generation; `uuid4` is simple |
|
|
||||||
| `uploaded_at`, `created_at`, `updated_at` as UTC `datetime` | Timezone-naive UTC by convention for MVP. Sufficient for single-user, single-timezone operation |
|
|
||||||
| `Transcript.text` is nullable | A failed job creates a Transcript with `text=None` and `error_detail` populated, keeping the query model uniform |
|
|
||||||
| Relationships via SQLModel `Relationship` | Enables `document.jobs` and `job.transcript` navigation in service code without manual joins |
|
|
||||||
|
|
||||||
### Proposed Implementation
|
|
||||||
|
|
||||||
- `resource://skills/fastapi-async-sqlalchemy-modernization/document`
|
|
||||||
|
|
||||||
```python src/transcription/models.py
|
|
||||||
"""SQLModel domain models for the transcription system.
|
|
||||||
|
|
||||||
Three models capture the MVP lifecycle:
|
|
||||||
Document → one-to-many → Job → one-to-one → Transcript
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from enum import StrEnum
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(StrEnum):
|
|
||||||
QUEUED = "queued"
|
|
||||||
PROCESSING = "processing"
|
|
||||||
TRANSCRIBED = "transcribed"
|
|
||||||
FAILED = "failed"
|
|
||||||
|
|
||||||
|
|
||||||
class Document(SQLModel, table=True):
|
|
||||||
"""An uploaded document image."""
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
filename: str
|
|
||||||
file_path: str
|
|
||||||
uploaded_at: datetime = Field(
|
|
||||||
default_factory=lambda: datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- relationships ---
|
|
||||||
jobs: list["Job"] = Relationship(back_populates="document")
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
created_at: datetime = Field(
|
|
||||||
default_factory=lambda: datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
updated_at: datetime = Field(
|
|
||||||
default_factory=lambda: datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- relationships ---
|
|
||||||
document: Document = Relationship(back_populates="jobs")
|
|
||||||
transcript: "Transcript | None" = Relationship(back_populates="job")
|
|
||||||
|
|
||||||
|
|
||||||
class Transcript(SQLModel, table=True):
|
|
||||||
"""The output of a transcription job."""
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
job_id: UUID = Field(foreign_key="job.id", unique=True)
|
|
||||||
text: str | None = None
|
|
||||||
error_detail: str | None = None
|
|
||||||
created_at: datetime = Field(
|
|
||||||
default_factory=lambda: datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- relationships ---
|
|
||||||
job: Job = Relationship(back_populates="transcript")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Entity-Relationship Summary
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────┐ ┌──────────┐ ┌─────────────┐
|
|
||||||
│ Document │ 1───* │ Job │ 1───1 │ Transcript │
|
|
||||||
├──────────┤ ├──────────┤ ├─────────────┤
|
|
||||||
│ id (PK) │ │ id (PK) │ │ id (PK) │
|
|
||||||
│ filename │ │ doc_id │──FK──▶│ job_id (FK) │
|
|
||||||
│ file_path│ │ status │ │ text │
|
|
||||||
│ uploaded │ │ created │ │ error_detail│
|
|
||||||
│ │ │ updated │ │ created │
|
|
||||||
└──────────┘ └──────────┘ └─────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why Only Four Status Values
|
|
||||||
|
|
||||||
REQ-3 lists six states: `upload`, `queued`, `processing`, `transcribed`, `failed`, `completed`. The MVP simplifies this:
|
|
||||||
|
|
||||||
| REQ-3 State | MVP Treatment |
|
|
||||||
|-------------|---------------|
|
|
||||||
| `upload` | Implicit — the Document record exists before a Job is created. No separate job state needed. |
|
|
||||||
| `queued` | ✅ Included — job created, waiting for worker pickup |
|
|
||||||
| `processing` | ✅ Included — worker is actively transcribing |
|
|
||||||
| `transcribed` | ✅ Included — AI output received and stored |
|
|
||||||
| `failed` | ✅ Included — error captured |
|
|
||||||
| `completed` | Deferred — implies human review/acceptance. In MVP, `transcribed` is the terminal success state. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. `db.py` — Database Engine and Session Management
|
|
||||||
|
|
||||||
**Satisfies:** MVP Feature 5 (SQLite auto-created on first startup)
|
|
||||||
|
|
||||||
### Design Decisions
|
|
||||||
|
|
||||||
| Decision | Rationale |
|
|
||||||
|----------|-----------|
|
|
||||||
| Module-level `create_engine` + `Session` factory | REQ-7 (lifespan-owned resources) is deferred. A module-level engine is adequate for MVP's single-process, single-user operation |
|
|
||||||
| `create_all()` as an explicit function | Called at app startup. MVP auto-creates tables (REQ-10 deferred), but the function is isolated so it's easy to gate behind a flag later |
|
|
||||||
| `get_session()` as a generator | Standard FastAPI/SQLModel pattern — yields a session, ensures cleanup. Compatible with `Depends()` when the API layer arrives in Step 5 |
|
|
||||||
| `echo=False` default | Keeps logs clean. Can be toggled for debugging |
|
|
||||||
|
|
||||||
### Proposed Implementation
|
|
||||||
|
|
||||||
```python src/transcription/db.py
|
|
||||||
"""Database engine, session factory, and schema bootstrap.
|
|
||||||
|
|
||||||
MVP uses SQLite with auto-create-tables at startup.
|
|
||||||
PostgreSQL migration is a post-MVP configuration change.
|
|
||||||
"""
|
|
||||||
import contextlib
|
|
||||||
from collections.abc import Generator
|
|
||||||
|
|
||||||
from sqlmodel import Session, SQLModel, create_engine
|
|
||||||
|
|
||||||
from transcription.config import get_settings
|
|
||||||
|
|
||||||
|
|
||||||
def _build_engine():
|
|
||||||
settings = get_settings()
|
|
||||||
connect_args = {}
|
|
||||||
if settings.database_url.startswith("sqlite"):
|
|
||||||
connect_args["check_same_thread"] = False
|
|
||||||
return create_engine(
|
|
||||||
settings.database_url,
|
|
||||||
echo=False,
|
|
||||||
connect_args=connect_args,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
engine = _build_engine()
|
|
||||||
|
|
||||||
|
|
||||||
def create_all() -> None:
|
|
||||||
"""Create all tables. Called once at application startup."""
|
|
||||||
SQLModel.metadata.create_all(engine)
|
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def get_session() -> Generator[Session]:
|
|
||||||
"""Yield a database session and ensure cleanup."""
|
|
||||||
with Session(engine) as session:
|
|
||||||
yield session
|
|
||||||
```
|
|
||||||
|
|
||||||
### SQLite-Specific Note
|
|
||||||
|
|
||||||
`check_same_thread=False` is required for SQLite when the session may be accessed from different threads (e.g., a background worker on a different thread than the request handler). This setting is harmless and ignored for PostgreSQL connection strings.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Test Plan
|
|
||||||
|
|
||||||
Refer to these resources for rules and guidelines about structure:
|
|
||||||
|
|
||||||
- `resource://skills/pytesting/document`
|
|
||||||
- `resource://catalog/prompts/pytest-scaffold`
|
|
||||||
- `resource://catalog/prompts/pytest-fill-scaffold`
|
|
||||||
|
|
||||||
Hierarchy pattern used in this step:
|
|
||||||
|
|
||||||
```text
|
|
||||||
tests/
|
|
||||||
conftest.py
|
|
||||||
test_config.py
|
|
||||||
TestSettingsLoading
|
|
||||||
test_loads_from_env
|
|
||||||
test_requires_api_key
|
|
||||||
TestProviderSettings
|
|
||||||
test_defaults_to_openrouter
|
|
||||||
test_rejects_invalid_value
|
|
||||||
test_optional_fields_default_to_none
|
|
||||||
TestPathSettings
|
|
||||||
test_path_fields_are_path_objects
|
|
||||||
test_models.py
|
|
||||||
TestDocumentModel
|
|
||||||
test_can_be_persisted
|
|
||||||
test_defaults_are_populated
|
|
||||||
TestJobModel
|
|
||||||
test_can_be_created_for_document
|
|
||||||
test_defaults_are_populated
|
|
||||||
test_transitions_to_transcribed
|
|
||||||
test_transitions_to_failed
|
|
||||||
TestTranscriptModel
|
|
||||||
test_success_record_persists
|
|
||||||
test_failure_record_persists
|
|
||||||
test_job_id_is_unique
|
|
||||||
TestRelationships
|
|
||||||
test_document_exposes_jobs
|
|
||||||
test_job_exposes_transcript
|
|
||||||
test_db.py
|
|
||||||
TestSchemaBootstrap
|
|
||||||
test_create_all_creates_expected_tables
|
|
||||||
TestSessionFactory
|
|
||||||
test_get_session_yields_session
|
|
||||||
test_session_is_closed_after_generator_exit
|
|
||||||
```
|
|
||||||
|
|
||||||
### `tests/conftest.py` — Shared Fixtures
|
|
||||||
|
|
||||||
```python tests/conftest.py
|
|
||||||
"""Shared test fixtures.
|
|
||||||
|
|
||||||
Every test gets a fresh in-memory SQLite database so tests are
|
|
||||||
isolated, fast, and leave no artifacts on disk.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlmodel import Session, SQLModel, create_engine
|
|
||||||
from sqlmodel.pool import StaticPool
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def session():
|
|
||||||
"""Provide a clean database session for each test."""
|
|
||||||
engine = create_engine(
|
|
||||||
"sqlite://",
|
|
||||||
connect_args={"check_same_thread": False},
|
|
||||||
poolclass=StaticPool,
|
|
||||||
)
|
|
||||||
SQLModel.metadata.create_all(engine)
|
|
||||||
with Session(engine) as session:
|
|
||||||
yield session
|
|
||||||
```
|
|
||||||
|
|
||||||
`StaticPool` ensures a single in-memory SQLite connection is shared across threads, which is required when `TestClient` (Step 5) spawns threads that would otherwise get separate in-memory databases. Establishing it now keeps the fixture stable across all future steps.
|
|
||||||
|
|
||||||
### `tests/test_config.py` — Configuration Hierarchy
|
|
||||||
|
|
||||||
| Class | Method | What It Verifies |
|
|
||||||
|------|--------|------------------|
|
|
||||||
| `TestSettingsLoading` | `test_loads_from_env` | `Settings` constructs successfully when `OPENROUTER_API_KEY` is set via env var |
|
|
||||||
| `TestSettingsLoading` | `test_requires_api_key` | `Settings()` raises `ValidationError` when `OPENROUTER_API_KEY` is missing |
|
|
||||||
| `TestProviderSettings` | `test_defaults_to_openrouter` | Default provider is `openrouter` when not explicitly set |
|
|
||||||
| `TestProviderSettings` | `test_rejects_invalid_value` | Setting `PROVIDER=invalid` raises `ValidationError` |
|
|
||||||
| `TestProviderSettings` | `test_optional_fields_default_to_none` | `provider_model`, `openrouter_http_referer`, and `openrouter_app_title` are `None` when unset |
|
|
||||||
| `TestPathSettings` | `test_path_fields_are_path_objects` | `upload_dir` and `prompt_dir` are `Path` instances |
|
|
||||||
|
|
||||||
### `tests/test_models.py` — Model & Relationship Hierarchy
|
|
||||||
|
|
||||||
| Class | Method | What It Verifies |
|
|
||||||
|------|--------|------------------|
|
|
||||||
| `TestDocumentModel` | `test_can_be_persisted` | A `Document` can be persisted and read back with correct fields |
|
|
||||||
| `TestDocumentModel` | `test_defaults_are_populated` | `id` is auto-generated UUID, `uploaded_at` is populated |
|
|
||||||
| `TestJobModel` | `test_can_be_created_for_document` | A `Job` linked to a `Document` via FK persists correctly |
|
|
||||||
| `TestJobModel` | `test_defaults_are_populated` | Default status is `queued`, `created_at` and `updated_at` are populated |
|
|
||||||
| `TestJobModel` | `test_transitions_to_transcribed` | Status can be updated from `queued` → `processing` → `transcribed` |
|
|
||||||
| `TestJobModel` | `test_transitions_to_failed` | Status can be updated from `processing` → `failed` |
|
|
||||||
| `TestTranscriptModel` | `test_success_record_persists` | A `Transcript` with `text` set and `error_detail=None` persists correctly |
|
|
||||||
| `TestTranscriptModel` | `test_failure_record_persists` | A `Transcript` with `text=None` and `error_detail` set persists correctly |
|
|
||||||
| `TestRelationships` | `test_document_exposes_jobs` | `document.jobs` returns the linked `Job` list |
|
|
||||||
| `TestRelationships` | `test_job_exposes_transcript` | `job.transcript` returns the linked `Transcript` |
|
|
||||||
| `TestTranscriptModel` | `test_job_id_is_unique` | Inserting two transcripts with the same `job_id` raises an integrity error |
|
|
||||||
|
|
||||||
### `tests/test_db.py` — Database Bootstrap Hierarchy
|
|
||||||
|
|
||||||
| Class | Method | What It Verifies |
|
|
||||||
|------|--------|------------------|
|
|
||||||
| `TestSchemaBootstrap` | `test_create_all_creates_expected_tables` | After `create_all()`, the expected tables (`document`, `job`, `transcript`) exist in the database |
|
|
||||||
| `TestSessionFactory` | `test_get_session_yields_session` | `get_session()` yields a usable `Session` object |
|
|
||||||
| `TestSessionFactory` | `test_session_is_closed_after_generator_exit` | After the generator is exhausted, the session is closed |
|
|
||||||
|
|
||||||
### Marker Strategy (Step 1)
|
|
||||||
|
|
||||||
- Markers (`unit`, `integration`, `external`) are registered upfront in `pyproject.toml` with `--strict-markers` enabled, per pytesting skill conventions.
|
|
||||||
- All Step 1 tests are unmarked — they run in the default lane since they are fast, deterministic, and have no external dependencies.
|
|
||||||
- When slower integration or external tests are introduced in later steps, apply explicit markers and keep test names unchanged.
|
|
||||||
|
|
||||||
### Test Workflow
|
|
||||||
|
|
||||||
Follow the two-phase approach from `resource://catalog/prompts/pytest-scaffold` and `resource://catalog/prompts/pytest-fill-scaffold`:
|
|
||||||
|
|
||||||
1. **Scaffold phase**: Create test files with class hierarchy, method names, and one-line docstrings only. Validate collection:
|
|
||||||
- `uv run pytest --collect-only -q`
|
|
||||||
2. **Fill phase**: Implement assertions, fixtures, and minimal test data. Treat scaffolded names and docstrings as locked. Validate execution:
|
|
||||||
- `uv run pytest -q`
|
|
||||||
|
|
||||||
Scaffolded structure is treated as a stable baseline — do not rename, move, merge, split, or re-nest tests once the scaffold is reviewed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Step 1 Completion Checklist
|
|
||||||
|
|
||||||
When all of the following are true, Step 1 is done and Step 2 can begin:
|
|
||||||
|
|
||||||
| # | Criterion | How to Verify |
|
|
||||||
|---|-----------|---------------|
|
|
||||||
| 1 | `src/transcription/` package exists with `config.py`, `models.py`, `db.py` | `ls` / file inspection |
|
|
||||||
| 2 | Empty `__init__.py` stubs exist for `providers/`, `services/`, `ui/` | `ls` / file inspection |
|
|
||||||
| 3 | `Settings` loads from environment and validates `OPENROUTER_API_KEY` is present | `test_config.py` passes |
|
|
||||||
| 4 | `Document`, `Job`, `Transcript` models create tables in SQLite | `test_models.py` passes |
|
|
||||||
| 5 | `JobStatus` enum has exactly four values: `queued`, `processing`, `transcribed`, `failed` | `test_models.py` passes |
|
|
||||||
| 6 | Foreign key relationships work: Document→Job→Transcript | `test_models.py` passes |
|
|
||||||
| 7 | `create_all()` bootstraps the schema; `get_session()` yields a working session | `test_db.py` passes |
|
|
||||||
| 8 | All tests pass: `uv run pytest -q` | CI / local run |
|
|
||||||
| 9 | `hello.py` is deleted | File inspection |
|
|
||||||
| 10 | `pyproject.toml` includes `openrouter`, `sqlmodel`, `pydantic-settings`, `pytest`, `pytest-asyncio` | File inspection |
|
|
||||||
| 10a | `pyproject.toml` has `[tool.pytest.ini_options]` with `--strict-markers` and registered markers | File inspection |
|
|
||||||
| 11 | `.env.example` documents all config vars; `.env` is in `.gitignore` | File inspection |
|
|
||||||
| 12 | `setup_logging()` uses `logging.config.dictConfig` with centralized formatter/handler/root config | File inspection |
|
|
||||||
| 13 | `uv run pytest --collect-only -q` shows expected test hierarchy | Local run |
|
|
||||||
| 14 | `uv run pytest -q` passes all tests | Local run |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. What This Step Does NOT Include
|
|
||||||
|
|
||||||
Explicitly out of scope to prevent scope creep:
|
|
||||||
|
|
||||||
| Excluded | Reason |
|
|
||||||
|----------|--------|
|
|
||||||
| FastAPI / NiceGUI app entrypoint | Step 5 |
|
|
||||||
| Additional provider adapters beyond OpenRouter | Post-MVP |
|
|
||||||
| Upload service logic | Step 4 |
|
|
||||||
| Worker / background processing | Step 4 |
|
|
||||||
| Transcription prompt files | Step 2 |
|
|
||||||
| Alembic or migration tooling | Post-MVP (REQ-10 deferred) |
|
|
||||||
| Async session factory | Post-MVP (REQ-7 deferred) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This plan produces a fully tested, importable data foundation. Every subsequent step imports from `transcription.config`, `transcription.models`, and `transcription.db` without modification.
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
## Step 2: prompts/transcribe_document.md
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Implement the MVP prompt artifact system by creating a curated transcription prompt file:
|
|
||||||
|
|
||||||
- `prompts/transcribe_document.md`
|
|
||||||
|
|
||||||
This step primarily satisfies:
|
|
||||||
|
|
||||||
- **REQ-12**: prompts stored as individual Markdown artifacts
|
|
||||||
- MVP Feature 3: prompt-driven verbatim transcription behavior grounded in `docs/intent.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope for Step 2
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
1. Create prompt artifact directory and first prompt file.
|
|
||||||
2. Encode transcription rules from `docs/intent.md` into a model-facing prompt.
|
|
||||||
3. Define stable prompt structure so future revisions are easy to diff/review.
|
|
||||||
4. Add lightweight tests that validate artifact presence and baseline quality constraints.
|
|
||||||
5. Update docs/README references so Step 3 can consume prompt file directly.
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- Provider integration logic (Step 3)
|
|
||||||
- Worker/job orchestration (Step 4)
|
|
||||||
- UI behavior (Step 5)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proposed Deliverables
|
|
||||||
|
|
||||||
1. **`prompts/transcribe_document.md`**
|
|
||||||
- production prompt text for historical document transcription
|
|
||||||
|
|
||||||
2. **`prompts/README.md`** (recommended)
|
|
||||||
- conventions for prompt files, revision policy, naming
|
|
||||||
|
|
||||||
3. **`tests/test_prompts.py`** (recommended)
|
|
||||||
- artifact existence + structure checks
|
|
||||||
|
|
||||||
4. **Small docs update** (README or docs reference)
|
|
||||||
- indicate that prompts are file-based and loaded from `PROMPT_DIR`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Detailed Work Breakdown
|
|
||||||
|
|
||||||
### 1) Create prompt artifact folder and canonical file
|
|
||||||
- Add `prompts/` at repo root.
|
|
||||||
- Add `transcribe_document.md` as the first curated artifact.
|
|
||||||
- Keep filename stable; this becomes the default in Step 3 unless overridden.
|
|
||||||
|
|
||||||
### 2) Author prompt content using a strict, sectioned format
|
|
||||||
Use section headers so future diffs are clean and policy changes are isolated.
|
|
||||||
|
|
||||||
Suggested sections:
|
|
||||||
|
|
||||||
1. **Purpose**
|
|
||||||
- verbatim scholarly transcription of historical documents
|
|
||||||
|
|
||||||
2. **Output requirements**
|
|
||||||
- plain text only
|
|
||||||
- no summaries, no paraphrasing
|
|
||||||
- preserve reading order and meaningful structure
|
|
||||||
|
|
||||||
3. **Core fidelity rules**
|
|
||||||
- preserve original wording and punctuation
|
|
||||||
- don’t silently normalize grammar/spelling
|
|
||||||
- no invented content
|
|
||||||
|
|
||||||
4. **Issue-handling rules (mapped from Intent table)**
|
|
||||||
- misspellings with `[sic]`
|
|
||||||
- missing words with `[word]`
|
|
||||||
- uncertainty with `[guess?]`
|
|
||||||
- illegible with `[illegible]` / reason tags
|
|
||||||
- crossed-out text as `[deleted: ...]`
|
|
||||||
- inserted text as `[inserted: ...]`
|
|
||||||
- superscripts handling guidance
|
|
||||||
- non-text elements as `[description]`
|
|
||||||
- marginalia format `[written in left margin: ...]`
|
|
||||||
- line-break hyphen rejoin behavior
|
|
||||||
- capitalization policy
|
|
||||||
- hierarchical outline preservation (including unusual numbering)
|
|
||||||
|
|
||||||
5. **Confidence/ambiguity policy**
|
|
||||||
- prefer explicit uncertainty markers over hallucination
|
|
||||||
|
|
||||||
6. **Final self-checklist for model**
|
|
||||||
- did I preserve structure?
|
|
||||||
- did I mark uncertain text?
|
|
||||||
- did I avoid silent corrections?
|
|
||||||
|
|
||||||
### 3) Add prompt-library conventions (`prompts/README.md`)
|
|
||||||
Recommended conventions:
|
|
||||||
- one prompt per file
|
|
||||||
- snake_case names
|
|
||||||
- each file starts with purpose + behavior contract
|
|
||||||
- iterative edits, one prompt per PR where possible
|
|
||||||
- no secrets in prompt files
|
|
||||||
|
|
||||||
### 4) Add tests for prompt assets (`tests/test_prompts.py`)
|
|
||||||
Keep tests robust but not brittle.
|
|
||||||
|
|
||||||
Recommended tests:
|
|
||||||
1. `test_prompt_file_exists`
|
|
||||||
2. `test_prompt_file_is_not_empty`
|
|
||||||
3. `test_prompt_mentions_verbatim_behavior`
|
|
||||||
4. `test_prompt_includes_uncertainty_and_illegible_markers`
|
|
||||||
5. `test_prompt_includes_deleted_and_inserted_conventions`
|
|
||||||
|
|
||||||
Avoid exact full-text matching; verify key semantic anchors only.
|
|
||||||
|
|
||||||
### 5) Optional config alignment check
|
|
||||||
Current config already has:
|
|
||||||
- `prompt_dir: Path = Path("./prompts")`
|
|
||||||
|
|
||||||
In Step 2, ensure docs reflect this and that Step 3 will resolve:
|
|
||||||
- `PROMPT_DIR / "transcribe_document.md"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task-by-Task Execution Checklist
|
|
||||||
|
|
||||||
## Phase A — Scaffold files
|
|
||||||
|
|
||||||
- [ ] **A1. Create prompt directory**
|
|
||||||
- Path: `prompts/`
|
|
||||||
- Verify: directory exists at repo root
|
|
||||||
|
|
||||||
- [ ] **A2. Create canonical prompt file**
|
|
||||||
- Path: `prompts/transcribe_document.md`
|
|
||||||
- Verify: file exists and is non-empty
|
|
||||||
|
|
||||||
- [ ] **A3. (Recommended) Create prompt library README**
|
|
||||||
- Path: `prompts/README.md`
|
|
||||||
- Verify: includes naming + revision conventions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase B — Author prompt content (core work)
|
|
||||||
|
|
||||||
- [ ] **B1. Add Purpose section**
|
|
||||||
- States verbatim historical transcription objective
|
|
||||||
- Explicitly disallows summarization/paraphrase
|
|
||||||
|
|
||||||
- [ ] **B2. Add Output Contract section**
|
|
||||||
- Plain text output expectation
|
|
||||||
- Preserve meaningful structure and reading order
|
|
||||||
- No fabricated text
|
|
||||||
|
|
||||||
- [ ] **B3. Add Rule Set from `docs/intent.md`**
|
|
||||||
- Misspellings/errors: `[sic]`
|
|
||||||
- Missing words: `[word]`
|
|
||||||
- Uncertain readings: `[guess?]`
|
|
||||||
- Illegible regions: `[illegible]` / reason labels
|
|
||||||
- Crossed-out text: `[deleted: ...]`
|
|
||||||
- Squeezed-in text: `[inserted: ...]`
|
|
||||||
- Superscripts/abbrev handling guidance
|
|
||||||
- Non-text visuals: bracketed descriptive labels
|
|
||||||
- Marginalia formatting cue
|
|
||||||
- Rejoin line-break hyphenated words silently
|
|
||||||
- Ambiguous capitalization policy
|
|
||||||
- Hierarchical outline numbering preservation
|
|
||||||
|
|
||||||
- [ ] **B4. Add Ambiguity and Confidence policy**
|
|
||||||
- “Mark uncertainty instead of guessing”
|
|
||||||
- “Never silently normalize uncertain passages”
|
|
||||||
|
|
||||||
- [ ] **B5. Add Final Self-Check section**
|
|
||||||
- Checklist for fidelity, uncertainty labeling, and format compliance
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase C — Add validations (tests)
|
|
||||||
|
|
||||||
- [ ] **C1. Create prompt tests file**
|
|
||||||
- Path: `tests/test_prompts.py`
|
|
||||||
|
|
||||||
- [ ] **C2. Add existence/health checks**
|
|
||||||
- Prompt file exists
|
|
||||||
- Prompt file has content (non-whitespace)
|
|
||||||
|
|
||||||
- [ ] **C3. Add semantic anchor checks**
|
|
||||||
- Mentions verbatim behavior
|
|
||||||
- Mentions uncertainty marker pattern (`?` in brackets conceptually)
|
|
||||||
- Mentions illegible handling
|
|
||||||
- Mentions deleted/inserted conventions
|
|
||||||
|
|
||||||
- [ ] **C4. Keep tests resilient**
|
|
||||||
- Avoid exact full-file snapshot assertions
|
|
||||||
- Assert required concepts, not precise phrasing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase D — Documentation alignment
|
|
||||||
|
|
||||||
- [ ] **D1. Update top-level docs/README reference**
|
|
||||||
- Mention that prompts live in `prompts/`
|
|
||||||
- Mention Step 3 loads from `PROMPT_DIR`
|
|
||||||
|
|
||||||
- [ ] **D2. Confirm config compatibility**
|
|
||||||
- `src/transcription/config.py` already uses `prompt_dir = Path("./prompts")`
|
|
||||||
- No code change needed unless naming/path mismatch appears
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase E — Verification
|
|
||||||
|
|
||||||
- [ ] **E1. Run targeted test file**
|
|
||||||
- `uv run pytest tests/test_prompts.py -q`
|
|
||||||
|
|
||||||
- [ ] **E2. Run full suite**
|
|
||||||
- `uv run pytest -q`
|
|
||||||
|
|
||||||
- [ ] **E3. Confirm no regressions**
|
|
||||||
- All existing tests still green (expected: previous 20 + new prompt tests)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase F — Commit plan (recommended granularity)
|
|
||||||
|
|
||||||
- [ ] **F1. Commit 1: scaffold**
|
|
||||||
- `prompts/transcribe_document.md` (initial structure)
|
|
||||||
- `prompts/README.md` (if included)
|
|
||||||
|
|
||||||
- [ ] **F2. Commit 2: finalized prompt content**
|
|
||||||
- full rule-complete prompt text
|
|
||||||
|
|
||||||
- [ ] **F3. Commit 3: tests + docs alignment**
|
|
||||||
- `tests/test_prompts.py`
|
|
||||||
- README/docs mention of prompt artifact pattern
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Done Criteria (quick gate)
|
|
||||||
|
|
||||||
- [ ] Canonical prompt exists and is curated for verbatim transcription.
|
|
||||||
- [ ] Prompt encodes all high-value handling rules from `docs/intent.md`.
|
|
||||||
- [ ] Prompt tests pass.
|
|
||||||
- [ ] Full project tests pass with `uv`.
|
|
||||||
- [ ] Ready for Step 3 provider integration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acceptance Criteria (Definition of Done)
|
|
||||||
|
|
||||||
Step 2 is complete when all are true:
|
|
||||||
|
|
||||||
1. `prompts/transcribe_document.md` exists and is committed.
|
|
||||||
2. Prompt includes all critical handling rules from `docs/intent.md`.
|
|
||||||
3. Prompt is structured with stable section headings for future curation.
|
|
||||||
4. Prompt tests pass under `uv run pytest -q`.
|
|
||||||
5. Existing tests remain green (total suite still passes).
|
|
||||||
6. Docs indicate prompt artifact location and curation policy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Risks and Mitigations
|
|
||||||
|
|
||||||
1. **Risk: prompt too vague → hallucinated reconstructions**
|
|
||||||
- Mitigation: explicit uncertainty/illegible conventions and “no invention” rule.
|
|
||||||
|
|
||||||
2. **Risk: prompt too rigid for mixed document types**
|
|
||||||
- Mitigation: include neutral defaults + clear annotation formats.
|
|
||||||
|
|
||||||
3. **Risk: brittle tests block iterative prompt tuning**
|
|
||||||
- Mitigation: test semantic anchors, not exact wording.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Handoff to Step 3
|
|
||||||
|
|
||||||
After Step 2, Step 3 can immediately:
|
|
||||||
1. Load `transcribe_document.md` from `PROMPT_DIR`
|
|
||||||
2. Inject prompt into OpenRouter request
|
|
||||||
3. Start validating real transcription behavior with minimal glue code
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
## Step 3: services/transcription.py + providers/
|
|
||||||
|
|
||||||
### Objective
|
|
||||||
|
|
||||||
Implement the **AI transcription integration layer** so the app can:
|
|
||||||
|
|
||||||
1. Read the curated prompt from `PROMPT_DIR`
|
|
||||||
2. Send prompt + image to the configured provider (OpenRouter)
|
|
||||||
3. Return normalized transcription output (or structured failure)
|
|
||||||
|
|
||||||
This corresponds to MVP Step 3 from `docs/mvp.md`:
|
|
||||||
- `services/transcription.py`
|
|
||||||
- `providers/` adapter(s)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope for Step 3
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
- Provider abstraction and OpenRouter adapter
|
|
||||||
- Prompt file loading utility in service layer
|
|
||||||
- Image payload preparation
|
|
||||||
- One high-level transcription service function usable by Step 4 worker
|
|
||||||
- Unit tests (mocked provider SDK, no external calls)
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- Job polling/background loop (Step 4)
|
|
||||||
- DB status transition orchestration in worker loop (Step 4)
|
|
||||||
- UI invocation/wiring (Step 5)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Planned Deliverables
|
|
||||||
|
|
||||||
### Source files
|
|
||||||
- `src/transcription/providers/base.py`
|
|
||||||
- `src/transcription/providers/openrouter.py`
|
|
||||||
- `src/transcription/providers/__init__.py` (exports + factory)
|
|
||||||
- `src/transcription/services/transcription.py`
|
|
||||||
- `src/transcription/services/__init__.py` (optional export)
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- `tests/providers/test_openrouter.py`
|
|
||||||
- `tests/services/test_transcription.py`
|
|
||||||
|
|
||||||
### Test directory convention
|
|
||||||
- Mirror source domains under `tests/`.
|
|
||||||
- Provider adapter tests live under `tests/providers/`.
|
|
||||||
- Service-layer tests live under `tests/services/`.
|
|
||||||
- Prefer one focused test module per production module (for Step 3: `test_openrouter.py`, `test_transcription.py`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design Decisions (before coding)
|
|
||||||
|
|
||||||
1. **Provider interface first**
|
|
||||||
- Define a stable contract independent of SDK specifics.
|
|
||||||
- Prevent Step 4 from depending on raw SDK response shapes.
|
|
||||||
|
|
||||||
2. **Service returns normalized result object**
|
|
||||||
- Include: `text`, `provider`, `model`, `raw_error`/exception metadata.
|
|
||||||
- Worker can map this cleanly to `Transcript` and `JobStatus`.
|
|
||||||
|
|
||||||
3. **Prompt loaded from file at call time**
|
|
||||||
- Uses `get_settings().prompt_dir / "transcribe_document.md"`.
|
|
||||||
- Keeps prompt edits hot-swappable without code changes.
|
|
||||||
|
|
||||||
4. **Clear exception boundary**
|
|
||||||
- SDK/network/model failures become predictable domain exceptions:
|
|
||||||
- `ProviderError`
|
|
||||||
- `PromptLoadError`
|
|
||||||
- `TranscriptionError` (optional top-level wrapper)
|
|
||||||
|
|
||||||
5. **Model resolution policy**
|
|
||||||
- Use `settings.provider_model` if set
|
|
||||||
- Otherwise use adapter default constant (e.g., vision-capable model slug)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task-by-Task Execution Checklist
|
|
||||||
|
|
||||||
## Phase A — Provider contract
|
|
||||||
|
|
||||||
- [ ] Create `src/transcription/providers/base.py`
|
|
||||||
- [ ] Define protocol/ABC for transcription providers:
|
|
||||||
- [ ] method signature accepts prompt text + image bytes (or data URL) + mime type
|
|
||||||
- [ ] returns normalized text result (and optional metadata)
|
|
||||||
- [ ] Define shared provider exceptions:
|
|
||||||
- [ ] `ProviderError`
|
|
||||||
- [ ] optional subclasses (`ProviderAuthError`, `ProviderResponseError`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase B — OpenRouter adapter
|
|
||||||
|
|
||||||
- [ ] Create `src/transcription/providers/openrouter.py`
|
|
||||||
- [ ] Implement `OpenRouterTranscriptionProvider` with:
|
|
||||||
- [ ] config-driven API key usage
|
|
||||||
- [ ] optional referer/title attribution headers
|
|
||||||
- [ ] model resolution fallback when `provider_model` is unset
|
|
||||||
- [ ] Implement request building:
|
|
||||||
- [ ] prompt included as instruction content
|
|
||||||
- [ ] image included in supported format for vision call
|
|
||||||
- [ ] Implement response parsing:
|
|
||||||
- [ ] extract final transcript text from SDK response
|
|
||||||
- [ ] validate non-empty text
|
|
||||||
- [ ] Wrap SDK failures into `ProviderError` with clean message
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase C — Provider factory
|
|
||||||
|
|
||||||
- [ ] Update `src/transcription/providers/__init__.py`
|
|
||||||
- [ ] Add `get_transcription_provider()` factory:
|
|
||||||
- [ ] reads `settings.provider`
|
|
||||||
- [ ] returns OpenRouter adapter for `openrouter`
|
|
||||||
- [ ] raises explicit error for unsupported provider values
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase D — Transcription service (Step 3 core)
|
|
||||||
|
|
||||||
- [ ] Create `src/transcription/services/transcription.py`
|
|
||||||
- [ ] Add prompt loader function:
|
|
||||||
- [ ] default file: `transcribe_document.md`
|
|
||||||
- [ ] raises `PromptLoadError` on missing/empty file
|
|
||||||
- [ ] Add image loader/validator:
|
|
||||||
- [ ] path existence check
|
|
||||||
- [ ] allowed mime detection (`.jpg/.jpeg/.png/.tiff/.pdf` policy aligned to MVP)
|
|
||||||
- [ ] Add high-level function (name example):
|
|
||||||
- [ ] `transcribe_document_image(image_path, prompt_name="transcribe_document.md")`
|
|
||||||
- [ ] loads prompt + image
|
|
||||||
- [ ] calls provider from factory
|
|
||||||
- [ ] returns normalized transcription result object
|
|
||||||
- [ ] Add structured logging at key boundaries:
|
|
||||||
- [ ] prompt loaded
|
|
||||||
- [ ] provider invoked
|
|
||||||
- [ ] success/failure outcome (no sensitive data in logs)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase E — Tests (two-phase scaffold -> fill)
|
|
||||||
|
|
||||||
### Required execution resources
|
|
||||||
|
|
||||||
Load and reference these directly during test planning/implementation so the two-phase flow is enforced:
|
|
||||||
|
|
||||||
- [ ] `resource://catalog/prompts/pytest-scaffold`
|
|
||||||
- [ ] `resource://prompts/pytest-scaffold/document`
|
|
||||||
- [ ] `resource://catalog/prompts/pytest-fill-scaffold`
|
|
||||||
- [ ] `resource://prompts/pytest-fill-scaffold/document`
|
|
||||||
|
|
||||||
### Phase E1 — Scaffold test structure first
|
|
||||||
|
|
||||||
Prompt: `resource://catalog/prompts/pytest-scaffold`
|
|
||||||
|
|
||||||
Suggested arguments:
|
|
||||||
- [ ] `target_modules` = `src/transcription/providers/openrouter.py`, `src/transcription/services/transcription.py`
|
|
||||||
- [ ] `mode` = `scaffold`
|
|
||||||
- [ ] `path_strategy` = `src-to-tests-mirror`
|
|
||||||
- [ ] `naming_style` = `concise-behavior`
|
|
||||||
|
|
||||||
Expected scaffold outcomes:
|
|
||||||
- [ ] `tests/providers/test_openrouter.py` exists with class/method skeletons and one-line docstrings
|
|
||||||
- [ ] `tests/services/test_transcription.py` exists with class/method skeletons and one-line docstrings
|
|
||||||
- [ ] collection succeeds on scaffold-only tests
|
|
||||||
|
|
||||||
Scaffold coverage targets:
|
|
||||||
- [ ] adapter initializes from settings
|
|
||||||
- [ ] model fallback when `provider_model is None`
|
|
||||||
- [ ] referer/title options included when set
|
|
||||||
- [ ] successful SDK response parses transcript text
|
|
||||||
- [ ] SDK exception maps to `ProviderError`
|
|
||||||
- [ ] empty/invalid response maps to `ProviderError`
|
|
||||||
- [ ] prompt loader reads canonical prompt file
|
|
||||||
- [ ] missing prompt raises `PromptLoadError`
|
|
||||||
- [ ] transcription function loads file and calls provider once
|
|
||||||
- [ ] image path missing raises clear error
|
|
||||||
- [ ] provider error is propagated/wrapped predictably
|
|
||||||
- [ ] returned result includes transcript text and metadata
|
|
||||||
|
|
||||||
### Phase E2 — Fill scaffolded tests with assertions
|
|
||||||
|
|
||||||
Prompt: `resource://catalog/prompts/pytest-fill-scaffold`
|
|
||||||
|
|
||||||
Suggested arguments:
|
|
||||||
- [ ] `target_files` = `tests/providers/test_openrouter.py`, `tests/services/test_transcription.py`
|
|
||||||
- [ ] `stack` = `pure-python`
|
|
||||||
- [ ] `strategy` = `minimal`
|
|
||||||
- [ ] `marker_lane` = `unit`
|
|
||||||
|
|
||||||
Fill constraints:
|
|
||||||
- [ ] preserve scaffold class/method names and one-line docstrings
|
|
||||||
- [ ] keep mocks to an absolute minimum; mock only network boundaries and non-deterministic failures
|
|
||||||
- [ ] keep one behavior target per test method
|
|
||||||
|
|
||||||
> Default suite should remain deterministic and fast, but mocking should be minimal and intentional.
|
|
||||||
|
|
||||||
### Optional real-endpoint validation lane
|
|
||||||
|
|
||||||
- [ ] Add an opt-in integration lane for real provider calls (for example `@pytest.mark.integration` and `@pytest.mark.live_api`).
|
|
||||||
- [ ] Gate live tests behind explicit env vars (for example `OPENROUTER_API_KEY`, optional `RUN_LIVE_API_TESTS=1`).
|
|
||||||
- [ ] Exclude live tests from default CI/local runs unless explicitly requested.
|
|
||||||
- [ ] Keep at least one thin smoke path that can validate request/response compatibility against the real endpoint.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase F — Verification commands
|
|
||||||
|
|
||||||
- [ ] E1 scaffold validation: `uv run pytest --collect-only -q`
|
|
||||||
- [ ] E2 fill validation (unit lane): `uv run pytest -m unit -q`
|
|
||||||
- [ ] E2 targeted provider file: `uv run pytest tests/providers/test_openrouter.py -q`
|
|
||||||
- [ ] E2 targeted service file: `uv run pytest tests/services/test_transcription.py -q`
|
|
||||||
- [ ] E2 final full-suite check: `uv run pytest -q`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Notes / Guardrails
|
|
||||||
|
|
||||||
- Avoid coupling Step 3 service to DB models directly (that belongs in Step 4 orchestration).
|
|
||||||
- Do not silently swallow provider errors.
|
|
||||||
- Keep prompt filename stable (`transcribe_document.md`) unless explicitly parameterized.
|
|
||||||
- Keep request/response normalization inside provider adapter, not worker/UI layers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Definition of Done (Step 3)
|
|
||||||
|
|
||||||
Step 3 is done when:
|
|
||||||
|
|
||||||
1. Provider abstraction exists and OpenRouter adapter is implemented.
|
|
||||||
2. Service can transcribe a local image using prompt file content.
|
|
||||||
3. Failures are returned as structured exceptions, not raw SDK traceback noise.
|
|
||||||
4. Unit tests for provider and service pass.
|
|
||||||
5. Full suite remains green under `uv run pytest -q`.
|
|
||||||
6. Step 4 can call a single service function to process queued jobs.
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
## Step 4: `services/upload.py` + `worker.py`
|
|
||||||
|
|
||||||
### Objective
|
|
||||||
|
|
||||||
Implement the MVP upload and background-processing pipeline so the system can:
|
|
||||||
|
|
||||||
1. Save uploaded files into `UPLOAD_DIR`
|
|
||||||
2. Create `Document` + `Job(status="queued")`
|
|
||||||
3. Process queued jobs in a worker loop:
|
|
||||||
- `queued -> processing`
|
|
||||||
- call Step 3 transcription service
|
|
||||||
- persist `Transcript`
|
|
||||||
- finalize as `transcribed` or `failed`
|
|
||||||
|
|
||||||
This step advances MVP Feature 1 + Feature 2 and supports REQ-1, REQ-2, REQ-3, REQ-4, REQ-6.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
- `src/transcription/services/upload.py`
|
|
||||||
- `src/transcription/worker.py`
|
|
||||||
- Upload persistence logic and initial job creation
|
|
||||||
- Worker polling and single-job lifecycle execution
|
|
||||||
- Deterministic test coverage for upload + worker (default suite)
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- UI integration and pages (Step 5)
|
|
||||||
- Queue infrastructure beyond in-process loop
|
|
||||||
- Async DB/session architecture refactor
|
|
||||||
- Broad production hardening beyond MVP needs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Planned Deliverables
|
|
||||||
|
|
||||||
### Source files
|
|
||||||
- `src/transcription/services/upload.py`
|
|
||||||
- `src/transcription/worker.py`
|
|
||||||
- `src/transcription/services/__init__.py` (export updates as needed)
|
|
||||||
|
|
||||||
### Test files
|
|
||||||
- `tests/services/test_upload.py`
|
|
||||||
- `tests/services/test_worker.py`
|
|
||||||
|
|
||||||
### Optional external lane (already present pattern)
|
|
||||||
- reuse `external` marker for live-provider checks where appropriate
|
|
||||||
- keep external out of default lane
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Required MCP Prompt References (for test workflow)
|
|
||||||
|
|
||||||
Apply these resources directly during Step 4 test creation:
|
|
||||||
|
|
||||||
1. `resource://catalog/prompts/pytest-scaffold`
|
|
||||||
2. `resource://prompts/pytest-scaffold/document`
|
|
||||||
3. `resource://catalog/prompts/pytest-fill-scaffold`
|
|
||||||
4. `resource://prompts/pytest-fill-scaffold/document`
|
|
||||||
|
|
||||||
And (as referenced by those prompts) apply relevant pytest skill references for:
|
|
||||||
- naming/hierarchy
|
|
||||||
- marker defaults
|
|
||||||
- SQLAlchemy sync testing behavior where applicable
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design Decisions
|
|
||||||
|
|
||||||
1. **Upload service owns initial file + record creation**
|
|
||||||
- Writes file, creates `Document`, creates queued `Job`, returns IDs/path.
|
|
||||||
|
|
||||||
2. **Worker owns lifecycle transitions**
|
|
||||||
- Worker is the single owner of `queued -> processing -> terminal` job state changes.
|
|
||||||
|
|
||||||
3. **Worker uses Step 3 service boundary**
|
|
||||||
- Worker calls `transcribe_document_image(...)`; no provider-specific SDK logic in worker.
|
|
||||||
|
|
||||||
4. **Failure information is always persisted**
|
|
||||||
- On failure: store `Transcript(text=None, error_detail=...)` and set `Job.status=failed`.
|
|
||||||
|
|
||||||
5. **Loop remains simple and stoppable**
|
|
||||||
- In-process polling loop with stop event and poll interval for MVP simplicity and testability.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task-by-Task Execution Checklist
|
|
||||||
|
|
||||||
## Phase A — Implement upload service (`src/transcription/services/upload.py`)
|
|
||||||
|
|
||||||
- [ ] Create `UploadError` exception
|
|
||||||
- [ ] Create `UploadJobResult` dataclass with:
|
|
||||||
- [ ] `document_id`
|
|
||||||
- [ ] `job_id`
|
|
||||||
- [ ] `stored_path`
|
|
||||||
- [ ] `original_filename`
|
|
||||||
- [ ] Add filename safety handling:
|
|
||||||
- [ ] normalize to basename
|
|
||||||
- [ ] avoid path traversal
|
|
||||||
- [ ] collision-safe stored name (e.g., UUID prefix/suffix)
|
|
||||||
- [ ] Validate upload payload:
|
|
||||||
- [ ] non-empty bytes required
|
|
||||||
- [ ] extension in supported set (`.jpg/.jpeg/.png/.tif/.tiff/.pdf`)
|
|
||||||
- [ ] Ensure upload directory exists (`mkdir(parents=True, exist_ok=True)`)
|
|
||||||
- [ ] Write file bytes to `UPLOAD_DIR`
|
|
||||||
- [ ] Persist DB records in one transaction:
|
|
||||||
- [ ] `Document(filename, file_path)`
|
|
||||||
- [ ] `Job(document_id=..., status=queued)`
|
|
||||||
- [ ] Return `UploadJobResult`
|
|
||||||
- [ ] Add logging for success/failure boundaries
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase B — Implement worker core (`src/transcription/worker.py`)
|
|
||||||
|
|
||||||
- [ ] Add `process_next_queued_job(...) -> bool`
|
|
||||||
- [ ] Fetch oldest queued job
|
|
||||||
- [ ] Return `False` when no queued jobs exist
|
|
||||||
- [ ] Transition picked job to `processing` and update timestamp
|
|
||||||
- [ ] Resolve associated `Document.file_path`
|
|
||||||
- [ ] Call `transcribe_document_image(image_path=...)`
|
|
||||||
- [ ] On success:
|
|
||||||
- [ ] insert/update transcript text
|
|
||||||
- [ ] clear error detail
|
|
||||||
- [ ] mark job `transcribed`
|
|
||||||
- [ ] update timestamp
|
|
||||||
- [ ] On failure:
|
|
||||||
- [ ] insert/update transcript with `text=None`, `error_detail=...`
|
|
||||||
- [ ] mark job `failed`
|
|
||||||
- [ ] update timestamp
|
|
||||||
- [ ] Commit terminal state and return `True`
|
|
||||||
- [ ] Add logs around job pickup, transition, and terminal outcome
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase C — Implement worker loop (`src/transcription/worker.py`)
|
|
||||||
|
|
||||||
- [ ] Add `run_worker_loop(...)`
|
|
||||||
- [ ] Accept configurable stop event/signal
|
|
||||||
- [ ] Accept configurable poll interval
|
|
||||||
- [ ] Repeatedly call `process_next_queued_job`
|
|
||||||
- [ ] Sleep only when queue is empty
|
|
||||||
- [ ] Exit cleanly when stop event is set
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase D — Exports
|
|
||||||
|
|
||||||
- [ ] Update `src/transcription/services/__init__.py` to expose upload APIs
|
|
||||||
- [ ] Keep existing transcription exports intact
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase E — Tests via MCP scaffold -> fill flow
|
|
||||||
|
|
||||||
## E1 Scaffold (structure only)
|
|
||||||
|
|
||||||
Use scaffold prompt workflow first for:
|
|
||||||
- `src/transcription/services/upload.py`
|
|
||||||
- `src/transcription/worker.py`
|
|
||||||
|
|
||||||
Expected scaffold targets:
|
|
||||||
- `tests/services/test_upload.py`
|
|
||||||
- `tests/services/test_worker.py`
|
|
||||||
|
|
||||||
Scaffold rules:
|
|
||||||
- [ ] Class hierarchy + method names + one-line docstrings only
|
|
||||||
- [ ] No assertions or implementation details in scaffold phase
|
|
||||||
- [ ] Keep method names concise and behavior-focused
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
- [ ] `uv run pytest --collect-only -q`
|
|
||||||
|
|
||||||
## E2 Fill scaffold (implementation)
|
|
||||||
|
|
||||||
Use fill prompt workflow for:
|
|
||||||
- `tests/services/test_upload.py`
|
|
||||||
- `tests/services/test_worker.py`
|
|
||||||
- stack: `sqlalchemy-sync` (or `mixed` if combining pure + DB behaviors)
|
|
||||||
- marker lane preference: `unit` and `integration` as appropriate
|
|
||||||
- strategy: minimal deterministic implementation
|
|
||||||
|
|
||||||
Fill rules (invariants):
|
|
||||||
- [ ] Preserve scaffold class names, method names, and one-line docstrings
|
|
||||||
- [ ] Do not rename/re-nest scaffolded tests unless explicitly approved
|
|
||||||
- [ ] One behavior target per test
|
|
||||||
- [ ] Minimal mocking; mock only network/nondeterministic boundaries
|
|
||||||
|
|
||||||
Suggested test coverage:
|
|
||||||
|
|
||||||
### `tests/services/test_upload.py`
|
|
||||||
- [ ] creates file + document + queued job (`integration`)
|
|
||||||
- [ ] rejects empty bytes (`unit`)
|
|
||||||
- [ ] rejects unsupported extension (`unit`)
|
|
||||||
- [ ] writes collision-safe unique filename (`integration`)
|
|
||||||
- [ ] persisted job status is `queued` (`integration`)
|
|
||||||
|
|
||||||
### `tests/services/test_worker.py`
|
|
||||||
- [ ] returns `False` when queue empty (`integration`)
|
|
||||||
- [ ] transitions `queued -> processing -> transcribed` on success (`integration`)
|
|
||||||
- [ ] stores transcript text on success (`integration`)
|
|
||||||
- [ ] transitions to `failed` and stores `error_detail` on failure (`integration`)
|
|
||||||
- [ ] updates existing transcript instead of duplicate create (`integration`)
|
|
||||||
- [ ] worker loop exits when stop event set (`unit`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Marker Strategy
|
|
||||||
|
|
||||||
- `unit`: pure logic tests (filename handling, loop stop behavior, validation logic)
|
|
||||||
- `integration`: DB + service orchestration tests (SQLite/session/contracts)
|
|
||||||
- `external`: opt-in live provider tests only (not part of default Step 4 lane)
|
|
||||||
|
|
||||||
No new marker needed; reuse existing marker registration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Validation Sequence (strict order)
|
|
||||||
|
|
||||||
- [ ] `uv run pytest --collect-only -q`
|
|
||||||
- [ ] `uv run pytest -m unit -q` *(if unit tests touched)*
|
|
||||||
- [ ] `uv run pytest tests/services/test_upload.py -q`
|
|
||||||
- [ ] `uv run pytest tests/services/test_worker.py -q`
|
|
||||||
- [ ] `uv run pytest -q`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reporting Requirements (after implementation)
|
|
||||||
|
|
||||||
Implementation report must include:
|
|
||||||
|
|
||||||
1. Files created/updated
|
|
||||||
2. Fixture and marker decisions
|
|
||||||
3. MCP references used and why
|
|
||||||
4. Validation command results
|
|
||||||
5. Remaining risks/open questions (only blockers)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Keep Step 4 independent from UI concerns.
|
|
||||||
- Do not call provider SDK directly from worker.
|
|
||||||
- Do not silently swallow exceptions.
|
|
||||||
- Always persist terminal job outcome.
|
|
||||||
- Keep default suite deterministic and fast.
|
|
||||||
- Preserve scaffold invariants during fill phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Definition of Done (Step 4)
|
|
||||||
|
|
||||||
Step 4 is complete when:
|
|
||||||
|
|
||||||
- [ ] Upload service writes file and creates `Document` + queued `Job`
|
|
||||||
- [ ] Worker processes queued jobs end-to-end using Step 3 transcription service
|
|
||||||
- [ ] Success path persists transcript text and sets `transcribed`
|
|
||||||
- [ ] Failure path persists error detail and sets `failed`
|
|
||||||
- [ ] Queue-empty path returns cleanly
|
|
||||||
- [ ] New tests pass and full suite is green (`uv run pytest -q`)
|
|
||||||
- [ ] Output report includes MCP reference usage + validation evidence
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
Step 5 is implemented and passing ✅
|
|
||||||
|
|
||||||
### What I implemented
|
|
||||||
|
|
||||||
#### App/bootstrap
|
|
||||||
- Added `src/transcription/app.py`:
|
|
||||||
- `create_app()` factory
|
|
||||||
- FastAPI lifespan startup/shutdown:
|
|
||||||
- `setup_logging()`
|
|
||||||
- `create_all()`
|
|
||||||
- ensure `upload_dir` and `prompt_dir`
|
|
||||||
- start/stop worker thread (`run_worker_loop`)
|
|
||||||
- includes health router
|
|
||||||
- registers NiceGUI pages
|
|
||||||
|
|
||||||
#### API
|
|
||||||
- Added `src/transcription/api/__init__.py`
|
|
||||||
- Added `src/transcription/api/health.py`:
|
|
||||||
- `GET /healthz` returns `{"status": "ok"}`
|
|
||||||
|
|
||||||
#### UI
|
|
||||||
- Added `src/transcription/ui/upload_page.py`:
|
|
||||||
- upload page route
|
|
||||||
- accepted extensions helper
|
|
||||||
- upload submit helper to Step 4 service (`create_upload_job`)
|
|
||||||
- success/error state messaging
|
|
||||||
- Added `src/transcription/ui/jobs_page.py`:
|
|
||||||
- jobs list page
|
|
||||||
- job detail page
|
|
||||||
- `fetch_jobs()` and `fetch_job_detail()` helpers
|
|
||||||
- Updated `src/transcription/ui/__init__.py`:
|
|
||||||
- explicit page registration
|
|
||||||
- NiceGUI mounted via `ui.run_with(app, mount_path="/ui", ...)`
|
|
||||||
|
|
||||||
#### Dependencies
|
|
||||||
- Added runtime dependencies:
|
|
||||||
- `fastapi`
|
|
||||||
- `nicegui`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Tests created and filled (scaffold → fill)
|
|
||||||
|
|
||||||
- `tests/test_app.py`
|
|
||||||
- `tests/api/test_health.py`
|
|
||||||
- `tests/ui/test_pages_registration.py`
|
|
||||||
- `tests/ui/test_upload_page.py`
|
|
||||||
- `tests/ui/test_jobs_page.py`
|
|
||||||
|
|
||||||
All scaffolded method names/docstrings were preserved and filled with executable assertions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Validation results (strict sequence)
|
|
||||||
|
|
||||||
- `uv run pytest --collect-only -q` ✅
|
|
||||||
- `uv run pytest -m unit -q` ✅
|
|
||||||
- `uv run pytest tests/api/test_health.py -q` ✅
|
|
||||||
- `uv run pytest tests/ui/test_pages_registration.py -q` ✅
|
|
||||||
- `uv run pytest tests/test_app.py -q` ✅
|
|
||||||
- `uv run pytest tests/ui/test_upload_page.py -q` ✅
|
|
||||||
- `uv run pytest tests/ui/test_jobs_page.py -q` ✅
|
|
||||||
- `uv run pytest -q` ✅
|
|
||||||
|
|
||||||
Full suite is green.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Note
|
|
||||||
You’ll see one warning from FastAPI/Starlette test client about `httpx` deprecation; it does not affect correctness and all tests pass.
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
## Step 5: `app.py` + UI Pages (NiceGUI + FastAPI composition)
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement the MVP user-facing application layer so users can:
|
|
||||||
|
|
||||||
1. Upload a document from the UI
|
|
||||||
2. Trigger Step 4 upload/job creation flow
|
|
||||||
3. See live job lifecycle status (`queued`, `processing`, `transcribed`, `failed`)
|
|
||||||
4. Open a job detail view to read transcript text or failure details
|
|
||||||
|
|
||||||
This step composes Steps 1–4 into a usable UI.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Summary (NiceGUI-aligned)
|
|
||||||
|
|
||||||
Step 5 uses a **FastAPI app factory + lifespan orchestration** and mounts/registers NiceGUI pages via explicit page modules.
|
|
||||||
|
|
||||||
Reference baseline: `resource://skills/nicegui/document`
|
|
||||||
|
|
||||||
### Core architecture decisions
|
|
||||||
|
|
||||||
- **App factory:** `create_app()`
|
|
||||||
- **Lifespan-managed resources:** worker start/stop managed in startup/shutdown
|
|
||||||
- **Modular pages:** upload and jobs pages in separate modules (no monolithic UI file)
|
|
||||||
- **Health endpoint:** FastAPI-side `/healthz`
|
|
||||||
- **UI composition:** route pages stay modular and reusable shared shell/components live under `ui/components` as needed
|
|
||||||
- **Styling architecture:** shared CSS loaded once at startup; avoid ad-hoc per-page styling drift
|
|
||||||
- **Dependency direction (one-way):**
|
|
||||||
- `app` -> `config/logging/db/worker/ui/api`
|
|
||||||
- `ui/pages` -> `ui/components` + `services`
|
|
||||||
- `services` -> `db/models/providers`
|
|
||||||
- no reverse imports from services into UI/API
|
|
||||||
|
|
||||||
### DB and AI stance (explicit)
|
|
||||||
|
|
||||||
- **DB:** already enabled (SQLModel + SQLite), session lifecycle remains request/service-scoped as built in prior steps.
|
|
||||||
- **AI workflow:** already in place via Step 3 transcription service + Step 4 worker; UI does not call provider SDK directly.
|
|
||||||
- **Mounted docs:** not in Step 5 scope; docs mounting remains disabled for MVP.
|
|
||||||
|
|
||||||
### Async and responsiveness stance
|
|
||||||
|
|
||||||
- Prefer `async def` for page handlers and service boundaries when I/O is involved.
|
|
||||||
- Keep UI handlers non-blocking (no blocking sleeps or synchronous long I/O calls).
|
|
||||||
- For long-running user actions, always provide explicit loading/progress/error states.
|
|
||||||
- Keep cancellation/timeout behavior explicit for refresh/poll operations where applicable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
- `src/transcription/app.py`
|
|
||||||
- `src/transcription/ui/upload_page.py`
|
|
||||||
- `src/transcription/ui/jobs_page.py`
|
|
||||||
- `src/transcription/ui/__init__.py`
|
|
||||||
- `src/transcription/api/health.py` (or equivalent FastAPI health route module)
|
|
||||||
- UI/app tests with MCP scaffold->fill flow
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- Auth
|
|
||||||
- advanced filtering/search UX
|
|
||||||
- batch upload UX beyond MVP
|
|
||||||
- deployment/container hardening
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Planned Deliverables
|
|
||||||
|
|
||||||
### Source files
|
|
||||||
- `src/transcription/app.py` (app factory + lifespan wiring)
|
|
||||||
- `src/transcription/api/health.py` (GET `/healthz`)
|
|
||||||
- `src/transcription/ui/upload_page.py` (upload flow)
|
|
||||||
- `src/transcription/ui/jobs_page.py` (status list + detail)
|
|
||||||
- `src/transcription/ui/__init__.py` (explicit `register_pages(...)` export)
|
|
||||||
- `src/transcription/ui/components/*` (shared shell/navigation/status components if introduced)
|
|
||||||
- `src/transcription/ui/static/*.css` (optional shared CSS loaded once at startup)
|
|
||||||
|
|
||||||
### Test files
|
|
||||||
- `tests/test_app.py`
|
|
||||||
- `tests/api/test_health.py`
|
|
||||||
- `tests/ui/test_pages_registration.py`
|
|
||||||
- `tests/ui/test_upload_page.py`
|
|
||||||
- `tests/ui/test_jobs_page.py`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Plan + Checklist
|
|
||||||
|
|
||||||
Plan baseline and guardrails source: `resource://skills/nicegui/document`
|
|
||||||
|
|
||||||
## Phase A — App factory and lifespan orchestration
|
|
||||||
|
|
||||||
- [ ] Create `create_app()` in `src/transcription/app.py`
|
|
||||||
- [ ] Add FastAPI lifespan startup/shutdown handlers
|
|
||||||
- [ ] Startup responsibilities:
|
|
||||||
- [ ] `setup_logging()`
|
|
||||||
- [ ] `create_all()`
|
|
||||||
- [ ] ensure directories exist (`upload_dir`, `prompt_dir`)
|
|
||||||
- [ ] create worker stop event
|
|
||||||
- [ ] start worker background thread/task
|
|
||||||
- [ ] Shutdown responsibilities:
|
|
||||||
- [ ] signal stop event
|
|
||||||
- [ ] join/cleanup worker thread/task cleanly
|
|
||||||
- [ ] Register API router(s), including health route
|
|
||||||
- [ ] Register NiceGUI pages via explicit page registration function
|
|
||||||
- [ ] Load shared CSS once at startup (if present)
|
|
||||||
|
|
||||||
## Phase B — FastAPI health endpoint
|
|
||||||
|
|
||||||
- [ ] Create `src/transcription/api/health.py`
|
|
||||||
- [ ] Add `GET /healthz` returning simple healthy payload
|
|
||||||
- [ ] Wire route into app factory
|
|
||||||
|
|
||||||
## Phase C — Upload page (`ui/upload_page.py`)
|
|
||||||
|
|
||||||
- [ ] Add upload route/page registration function
|
|
||||||
- [ ] Render file input accepting supported extensions
|
|
||||||
- [ ] On submit:
|
|
||||||
- [ ] show loading/progress state
|
|
||||||
- [ ] call `create_upload_job(filename, file_bytes, ...)`
|
|
||||||
- [ ] show success state with job reference/link
|
|
||||||
- [ ] On error:
|
|
||||||
- [ ] show user-safe error message
|
|
||||||
- [ ] restore ready UI state
|
|
||||||
- [ ] Ensure non-blocking I/O in UI event handlers; offload CPU-heavy work to worker path
|
|
||||||
- [ ] Make timeout/cancellation behavior explicit for any long-running action
|
|
||||||
|
|
||||||
## Phase D — Jobs page (`ui/jobs_page.py`)
|
|
||||||
|
|
||||||
- [ ] Add jobs list route/page registration function
|
|
||||||
- [ ] Display jobs with status + timestamps
|
|
||||||
- [ ] Add job detail route/view
|
|
||||||
- [ ] Show transcript on success, error detail on failure
|
|
||||||
- [ ] Include explicit refresh action and loading state
|
|
||||||
- [ ] Ensure error states are surfaced to user and logged
|
|
||||||
- [ ] Keep refresh path async and bounded to avoid UI freeze
|
|
||||||
|
|
||||||
## Phase E — UI registration module
|
|
||||||
|
|
||||||
- [ ] Update `src/transcription/ui/__init__.py`
|
|
||||||
- [ ] Export `register_pages(...)`
|
|
||||||
- [ ] Ensure each page module exports `register_page(...)`
|
|
||||||
- [ ] Keep page registration explicit and modular
|
|
||||||
|
|
||||||
## Phase F — Shared components and style consistency
|
|
||||||
|
|
||||||
- [ ] Add `ui/components` module only for reusable shell elements (header/nav/status chips), not page-local logic
|
|
||||||
- [ ] Keep structural layout in Python; keep visual polish in shared CSS
|
|
||||||
- [ ] Avoid one-off styling duplication across upload/jobs pages
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MCP Testing Workflow (Required)
|
|
||||||
|
|
||||||
Use these resources directly:
|
|
||||||
|
|
||||||
- `resource://catalog/prompts/pytest-scaffold`
|
|
||||||
- `resource://prompts/pytest-scaffold/document`
|
|
||||||
- `resource://catalog/prompts/pytest-fill-scaffold`
|
|
||||||
- `resource://prompts/pytest-fill-scaffold/document`
|
|
||||||
|
|
||||||
## E1 — Scaffold tests first (structure only)
|
|
||||||
|
|
||||||
Target modules:
|
|
||||||
- `src/transcription/app.py`
|
|
||||||
- `src/transcription/api/health.py`
|
|
||||||
- `src/transcription/ui/upload_page.py`
|
|
||||||
- `src/transcription/ui/jobs_page.py`
|
|
||||||
|
|
||||||
Scaffold test files:
|
|
||||||
- `tests/test_app.py`
|
|
||||||
- `tests/api/test_health.py`
|
|
||||||
- `tests/ui/test_pages_registration.py`
|
|
||||||
- `tests/ui/test_upload_page.py`
|
|
||||||
- `tests/ui/test_jobs_page.py`
|
|
||||||
|
|
||||||
Scaffold constraints:
|
|
||||||
- [ ] class/method skeletons only
|
|
||||||
- [ ] one-line docstrings
|
|
||||||
- [ ] concise behavior-focused names
|
|
||||||
- [ ] no implementation assertions yet
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
- [ ] `uv run pytest --collect-only -q`
|
|
||||||
|
|
||||||
## E2 — Fill scaffold tests
|
|
||||||
|
|
||||||
Fill constraints from MCP guidance:
|
|
||||||
- [ ] preserve scaffold class/method names and docstrings (locked baseline)
|
|
||||||
- [ ] one behavior target per method
|
|
||||||
- [ ] deterministic tests preferred
|
|
||||||
- [ ] minimal mocking; only nondeterministic boundaries
|
|
||||||
|
|
||||||
Stack:
|
|
||||||
- [ ] `fastapi` (or `mixed` if needed for UI+DB fixture combination)
|
|
||||||
|
|
||||||
Suggested coverage:
|
|
||||||
|
|
||||||
### `tests/api/test_health.py`
|
|
||||||
- [ ] `/healthz` returns success status and expected payload shape
|
|
||||||
|
|
||||||
### `tests/ui/test_pages_registration.py`
|
|
||||||
- [ ] page registration wiring succeeds
|
|
||||||
- [ ] expected routes are present
|
|
||||||
|
|
||||||
### `tests/test_app.py`
|
|
||||||
- [ ] startup path initializes runtime dependencies
|
|
||||||
- [ ] worker start is invoked on startup
|
|
||||||
- [ ] worker shutdown signal/cleanup is invoked on shutdown
|
|
||||||
|
|
||||||
### `tests/ui/test_upload_page.py`
|
|
||||||
- [ ] upload action calls upload service
|
|
||||||
- [ ] success feedback displayed
|
|
||||||
- [ ] error feedback displayed for `UploadError`
|
|
||||||
- [ ] loading/progress state behavior covered
|
|
||||||
- [ ] timeout/cancellation behavior covered (if implemented)
|
|
||||||
|
|
||||||
### `tests/ui/test_jobs_page.py`
|
|
||||||
- [ ] list renders job statuses
|
|
||||||
- [ ] detail shows transcript text for successful job
|
|
||||||
- [ ] detail shows error detail for failed job
|
|
||||||
- [ ] refresh/loading state behavior covered
|
|
||||||
|
|
||||||
Marker strategy:
|
|
||||||
- [ ] `unit` for pure helpers/state formatting
|
|
||||||
- [ ] `integration` for app/page/service+DB contracts
|
|
||||||
- [ ] `external` not required for default Step 5 lane
|
|
||||||
|
|
||||||
Async behavior assertions:
|
|
||||||
- [ ] long-running actions keep button/inputs in expected disabled state
|
|
||||||
- [ ] completion/failure returns controls to ready state
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Validation Sequence (strict)
|
|
||||||
|
|
||||||
- [ ] `uv run pytest --collect-only -q`
|
|
||||||
- [ ] `uv run pytest -m unit -q` *(if unit tests touched)*
|
|
||||||
- [ ] `uv run pytest tests/api/test_health.py -q`
|
|
||||||
- [ ] `uv run pytest tests/ui/test_pages_registration.py -q`
|
|
||||||
- [ ] `uv run pytest tests/test_app.py -q`
|
|
||||||
- [ ] `uv run pytest tests/ui/test_upload_page.py -q`
|
|
||||||
- [ ] `uv run pytest tests/ui/test_jobs_page.py -q`
|
|
||||||
- [ ] `uv run pytest -q`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails (NiceGUI + MVP)
|
|
||||||
|
|
||||||
- [ ] Do not collapse pages into one file.
|
|
||||||
- [ ] Do not use implicit global side effects for runtime wiring.
|
|
||||||
- [ ] Keep UI responsive with explicit loading/progress/error states.
|
|
||||||
- [ ] Do not block UI handlers with synchronous long I/O.
|
|
||||||
- [ ] Do not place provider SDK calls in UI handlers.
|
|
||||||
- [ ] Keep dependency direction one-way and maintainable.
|
|
||||||
- [ ] Keep shared UI in `ui/components`; keep service logic out of page modules.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
|
|
||||||
- [ ] App factory + lifespan are in place
|
|
||||||
- [ ] Health endpoint exists and is tested
|
|
||||||
- [ ] Upload page creates queued jobs through service boundary
|
|
||||||
- [ ] Jobs list/detail pages render status/transcript/failure data
|
|
||||||
- [ ] Worker lifecycle is started/stopped by app lifespan
|
|
||||||
- [ ] Async UI states (loading/success/error) are deterministic and tested
|
|
||||||
- [ ] Scaffold->fill testing flow completed and validated
|
|
||||||
- [ ] Full suite passes: `uv run pytest -q`
|
|
||||||
|
|
||||||
## Completion Checks (NiceGUI skill aligned)
|
|
||||||
|
|
||||||
- [ ] Uses app factory and FastAPI lifespan
|
|
||||||
- [ ] Pages are modularized (not single-file UI)
|
|
||||||
- [ ] Health endpoint exists on FastAPI side
|
|
||||||
- [ ] Dependency direction is clean and one-way
|
|
||||||
- [ ] Async-first guidance is applied where I/O exists, with explicit non-blocking UX states
|
|
||||||
- [ ] DB/AI/docs decisions are explicit and reflected in structure
|
|
||||||
- [ ] Plan references baseline URI: `resource://skills/nicegui/document`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PR Checklist (Integrated)
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
- [ ] `app.py` app factory + lifespan implemented
|
|
||||||
- [ ] FastAPI health route (`/healthz`) implemented
|
|
||||||
- [ ] `ui/upload_page.py` implemented
|
|
||||||
- [ ] `ui/jobs_page.py` implemented
|
|
||||||
- [ ] `ui/__init__.py` explicit page registration implemented
|
|
||||||
- [ ] Worker startup/shutdown managed by lifespan
|
|
||||||
|
|
||||||
### Testing (MCP-compliant)
|
|
||||||
- [ ] Scaffold phase completed first for all Step 5 tests
|
|
||||||
- [ ] `--collect-only` passed on scaffolds
|
|
||||||
- [ ] Fill phase completed without renaming/re-nesting scaffolded tests
|
|
||||||
- [ ] Marker decisions documented (`unit` vs `integration`)
|
|
||||||
- [ ] Targeted tests passed
|
|
||||||
- [ ] Full suite passed
|
|
||||||
|
|
||||||
### Evidence
|
|
||||||
- [ ] Validation command outputs captured
|
|
||||||
- [ ] Files created/updated listed
|
|
||||||
- [ ] MCP prompt resources referenced in implementation notes
|
|
||||||
- [ ] Any residual risks/questions documented
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
Step 6 implemented ✅
|
|
||||||
|
|
||||||
### Files added
|
|
||||||
- `tests/integration/test_pipeline_flow.py`
|
|
||||||
- `tests/test_traceability.py`
|
|
||||||
|
|
||||||
### What was implemented
|
|
||||||
|
|
||||||
#### 1) End-to-end pipeline integration coverage
|
|
||||||
`tests/integration/test_pipeline_flow.py` now verifies:
|
|
||||||
|
|
||||||
- **Success path**
|
|
||||||
- upload creates queued job
|
|
||||||
- worker processes job
|
|
||||||
- job becomes `transcribed`
|
|
||||||
- transcript text is persisted
|
|
||||||
- error detail remains `None`
|
|
||||||
|
|
||||||
- **Failure path**
|
|
||||||
- upload creates queued job
|
|
||||||
- worker processing failure is handled
|
|
||||||
- job becomes `failed`
|
|
||||||
- transcript has `text=None`
|
|
||||||
- `error_detail` contains provider failure reason
|
|
||||||
|
|
||||||
#### 2) MVP requirement traceability check
|
|
||||||
`tests/test_traceability.py` adds a concrete map:
|
|
||||||
|
|
||||||
- REQ-0, REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-6, REQ-8, REQ-12
|
|
||||||
- each REQ maps to one or more existing test files
|
|
||||||
- test asserts each mapped path exists (guards against drift)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Validation run results
|
|
||||||
|
|
||||||
Executed and passed:
|
|
||||||
|
|
||||||
1. `uv run pytest --collect-only -q` ✅
|
|
||||||
2. `uv run pytest -m unit -q` ✅
|
|
||||||
3. `uv run pytest -m integration -q` ✅
|
|
||||||
4. `uv run pytest -m "not external" -q` ✅
|
|
||||||
5. `uv run pytest tests/integration/test_pipeline_flow.py -q` ✅
|
|
||||||
6. `uv run pytest tests/ui/test_upload_page.py -q` ✅
|
|
||||||
7. `uv run pytest tests/ui/test_jobs_page.py -q` ✅
|
|
||||||
8. `uv run pytest -q` ✅
|
|
||||||
|
|
||||||
All green.
|
|
||||||
(Only existing non-blocking FastAPI TestClient deprecation warning remains.)
|
|
||||||
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
## Step 6: Test and Verification Hardening (MVP closeout)
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Complete MVP verification by building a **requirements-traceable, deterministic test strategy** across unit/integration/external lanes, then enforcing stable validation commands and reporting.
|
|
||||||
|
|
||||||
This step finalizes the MVP implementation sequence from `docs/mvp.md` (Step 6 in the build order: tests and automated verification).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MCP Resource Integration (what was applied)
|
|
||||||
|
|
||||||
I reviewed all top-level skills/prompts from `john-stream-mcp` and integrated the relevant guidance into this plan:
|
|
||||||
|
|
||||||
### Directly applied
|
|
||||||
- `resource://skills/pytesting/document`
|
|
||||||
- `resource://catalog/prompts/pytest-scaffold`
|
|
||||||
- `resource://prompts/pytest-scaffold/document`
|
|
||||||
- `resource://catalog/prompts/pytest-fill-scaffold`
|
|
||||||
- `resource://prompts/pytest-fill-scaffold/document`
|
|
||||||
- `resource://skills/nicegui/document`
|
|
||||||
- `resource://skills/nicegui-ui-customization/document`
|
|
||||||
- `resource://skills/fastapi-uv-docker/document`
|
|
||||||
- `resource://skills/python-logging-dictconfig/document`
|
|
||||||
- `resource://skills/python-typing/document`
|
|
||||||
- `resource://skills/ruff-linting-formating/document`
|
|
||||||
|
|
||||||
### Reviewed but informational/non-blocking for Step 6
|
|
||||||
- `copilot-customization`, `mcp-details`, `vscode-configuration`, `zensical-docs`, and authoring/shim prompts.
|
|
||||||
- These are primarily customization/documentation tooling resources, not core MVP test-lane blockers.
|
|
||||||
- Step 6 includes optional workflow follow-ups where relevant (e.g., VS Code task conveniences).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
- Strengthen and complete test coverage for the shipped MVP slice (Steps 1–5)
|
|
||||||
- Add requirement-to-test traceability for REQ-0..REQ-12 (MVP subset emphasized)
|
|
||||||
- Enforce deterministic default lanes (`unit`, `integration`)
|
|
||||||
- Keep `external` lane opt-in and isolated
|
|
||||||
- Validate app/UI/service/worker contracts end-to-end at test level
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- Major architecture rewrites (async SQLAlchemy migration, queue system, etc.)
|
|
||||||
- Full production deployment rollout
|
|
||||||
- Post-MVP feature expansion (revision history, search, export)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Planned Deliverables
|
|
||||||
|
|
||||||
### Test files (new/updated)
|
|
||||||
- `tests/test_traceability.py` *(or docs-based traceability matrix if preferred)*
|
|
||||||
- `tests/integration/test_pipeline_flow.py` *(upload -> queued -> worker -> transcript/failed)*
|
|
||||||
- `tests/ui/test_upload_page.py` (augment loading/error/ready-state checks as practical)
|
|
||||||
- `tests/ui/test_jobs_page.py` (augment refresh/error behavior checks as practical)
|
|
||||||
- Existing tests touched only when needed; preserve naming/hierarchy unless explicitly approved.
|
|
||||||
|
|
||||||
### Optional docs output
|
|
||||||
- `docs/tests.md` or `docs/verification.md` with lane definitions and command matrix
|
|
||||||
- REQ-to-test mapping table
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design and Policy Decisions (MCP-aligned)
|
|
||||||
|
|
||||||
1. **Scaffold-first, fill-second workflow is mandatory**
|
|
||||||
- First create/adjust skeletons and collect.
|
|
||||||
- Then fill test bodies.
|
|
||||||
- Preserve scaffold names/docstrings during fill.
|
|
||||||
|
|
||||||
2. **Deterministic-first default lanes**
|
|
||||||
- `unit` and `integration` run by default.
|
|
||||||
- `external` remains explicit opt-in.
|
|
||||||
|
|
||||||
3. **One behavior target per test**
|
|
||||||
- Short, behavior-focused names.
|
|
||||||
- Precise assertions on observable outcomes.
|
|
||||||
|
|
||||||
4. **Test double discipline (from pytesting skill)**
|
|
||||||
- Prefer real-input/real-object paths first.
|
|
||||||
- If monkeypatch/mocks/fakes are needed for a boundary, keep narrowly scoped.
|
|
||||||
- Avoid call-only assertions.
|
|
||||||
|
|
||||||
5. **NiceGUI responsiveness expectations**
|
|
||||||
- Verify loading/success/error state transitions where testable.
|
|
||||||
- Ensure user-facing feedback behavior is covered.
|
|
||||||
|
|
||||||
6. **FastAPI/ops baseline checks**
|
|
||||||
- Keep `/healthz` route validation in default lanes.
|
|
||||||
- Keep startup/shutdown lifecycle assertions present.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Plan + Checklist
|
|
||||||
|
|
||||||
## Phase A — Coverage and traceability audit
|
|
||||||
|
|
||||||
- [ ] Build a REQ-to-test matrix for MVP requirements:
|
|
||||||
- [ ] REQ-0, REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-6, REQ-8, REQ-12
|
|
||||||
- [ ] Identify weak spots:
|
|
||||||
- [ ] full pipeline integration (service + worker + persistence)
|
|
||||||
- [ ] UI state transition assertions (loading/error/ready)
|
|
||||||
- [ ] failure-path persistence verification robustness
|
|
||||||
- [ ] Record current baseline command results before edits
|
|
||||||
|
|
||||||
## Phase B — Scaffold phase (pytest-scaffold resources)
|
|
||||||
|
|
||||||
Target modules/areas:
|
|
||||||
- pipeline integration flow
|
|
||||||
- UI behavior augmentations
|
|
||||||
- traceability checks/document validators (if test-backed)
|
|
||||||
|
|
||||||
- [ ] Scaffold new/adjusted test files/classes/methods only
|
|
||||||
- [ ] Keep one-line intent docstrings
|
|
||||||
- [ ] Keep behavior-focused names
|
|
||||||
- [ ] Run: `uv run pytest --collect-only -q`
|
|
||||||
|
|
||||||
## Phase C — Fill phase (pytest-fill-scaffold resources)
|
|
||||||
|
|
||||||
- [ ] Fill scaffolded methods with deterministic setup/assertions
|
|
||||||
- [ ] Preserve scaffold names/hierarchy/docstrings
|
|
||||||
- [ ] Add/adjust fixtures at nearest useful scope
|
|
||||||
- [ ] Keep DB tests in `integration`; pure helper tests in `unit`
|
|
||||||
|
|
||||||
### Required coverage additions
|
|
||||||
|
|
||||||
#### Pipeline integration
|
|
||||||
- [ ] Upload service creates document/job and file path persists
|
|
||||||
- [ ] Worker success path creates transcript and terminal status
|
|
||||||
- [ ] Worker failure path persists error detail and terminal failed status
|
|
||||||
- [ ] Queue-empty behavior remains stable (`False` return / no side effects)
|
|
||||||
|
|
||||||
#### UI behavior (practical, testable boundaries)
|
|
||||||
- [ ] Upload helper flow success and UploadError surfacing
|
|
||||||
- [ ] Jobs data helpers return stable normalized view models
|
|
||||||
- [ ] Refresh/detail fallback behavior for missing/invalid job IDs
|
|
||||||
|
|
||||||
#### Traceability
|
|
||||||
- [ ] Every in-scope MVP REQ has at least one mapped test/assertion point
|
|
||||||
- [ ] Document and/or enforce mapping consistency
|
|
||||||
|
|
||||||
## Phase D — External lane stability
|
|
||||||
|
|
||||||
- [ ] Keep real-image external tests isolated under `@pytest.mark.external`
|
|
||||||
- [ ] Ensure no external test leaks into default runs
|
|
||||||
- [ ] Confirm artifact capture behavior remains stable
|
|
||||||
|
|
||||||
## Phase E — Quality gates and workflow
|
|
||||||
|
|
||||||
- [ ] Confirm logging/lifecycle startup tests still pass after changes
|
|
||||||
- [ ] (If enabled) add/update lint/type check commands in docs:
|
|
||||||
- [ ] Ruff lane (if configured)
|
|
||||||
- [ ] typing lane (if configured)
|
|
||||||
- [ ] Optionally add VS Code task aliases for test lanes (non-blocking)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Marker and Fixture Strategy
|
|
||||||
|
|
||||||
- `unit`: pure logic, helper behavior, formatting/normalization
|
|
||||||
- `integration`: DB + service + app lifecycle contracts
|
|
||||||
- `external`: live provider/real image checks only
|
|
||||||
|
|
||||||
Fixture policy:
|
|
||||||
- Prefer reusable fixtures in `tests/conftest.py` only when broadly shared
|
|
||||||
- Use subtree/local fixtures for domain-specific setup
|
|
||||||
- Keep setup explicit and readable
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Validation Sequence (strict)
|
|
||||||
|
|
||||||
- [ ] `uv run pytest --collect-only -q`
|
|
||||||
- [ ] `uv run pytest -m unit -q`
|
|
||||||
- [ ] `uv run pytest -m integration -q`
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
- [ ] `uv run pytest tests/integration/test_pipeline_flow.py -q` *(if added)*
|
|
||||||
- [ ] `uv run pytest tests/ui/test_upload_page.py -q`
|
|
||||||
- [ ] `uv run pytest tests/ui/test_jobs_page.py -q`
|
|
||||||
- [ ] `uv run pytest -q`
|
|
||||||
|
|
||||||
Optional external verification:
|
|
||||||
- [ ] `uv run pytest -m external -q`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Do not rename/re-nest scaffolded tests during fill unless explicitly requested.
|
|
||||||
- Do not broaden external dependencies in default lane.
|
|
||||||
- Do not add flaky timing-based assertions; keep deterministic boundaries.
|
|
||||||
- Keep business logic out of UI tests; test through service/helper boundaries.
|
|
||||||
- Preserve one-way dependency direction in test setup patterns.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Definition of Done (Step 6)
|
|
||||||
|
|
||||||
- [ ] MVP requirement coverage is explicitly traceable
|
|
||||||
- [ ] Deterministic lanes (`unit` + `integration`) are stable and green
|
|
||||||
- [ ] External lane remains opt-in and green when enabled
|
|
||||||
- [ ] Pipeline success/failure lifecycle paths are verified end-to-end
|
|
||||||
- [ ] UI helper/state behavior has explicit success/error assertions
|
|
||||||
- [ ] Full suite passes with `uv run pytest -q`
|
|
||||||
- [ ] Verification evidence is captured in implementation report
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PR Checklist (Step 6)
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
- [ ] Added/updated test files per scoped gaps
|
|
||||||
- [ ] Added REQ traceability mapping
|
|
||||||
- [ ] Kept default lanes deterministic
|
|
||||||
- [ ] Preserved scaffold invariants during fill
|
|
||||||
|
|
||||||
### Testing (MCP-compliant)
|
|
||||||
- [ ] Used scaffold prompt flow first
|
|
||||||
- [ ] Used fill prompt flow second
|
|
||||||
- [ ] Preserved naming/docstrings/hierarchy
|
|
||||||
- [ ] Marker usage documented (`unit`, `integration`, `external`)
|
|
||||||
|
|
||||||
### Evidence
|
|
||||||
- [ ] Collected command outputs in strict order
|
|
||||||
- [ ] Listed files changed
|
|
||||||
- [ ] Listed MCP resources used and why
|
|
||||||
- [ ] Noted residual risks/open questions (if any)
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
## Step 7 Results: Error Handling Standardization and Operational Visibility
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Step 7 was implemented across the MVP runtime boundaries with a shared error taxonomy, actionable UI error surfacing, worker failure normalization, and API error envelope handling.
|
|
||||||
|
|
||||||
All required validation gates in `docs/step7.md` were executed and passed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope Delivered
|
|
||||||
|
|
||||||
### Implemented
|
|
||||||
- Shared application error contract and taxonomy
|
|
||||||
- Service-layer error normalization (upload + transcription)
|
|
||||||
- UI error presentation helpers with suggested actions and error references
|
|
||||||
- Worker failure persistence format with category/suggestion/error_id markers
|
|
||||||
- API exception handlers for structured error responses
|
|
||||||
- Targeted tests for new error contract behavior
|
|
||||||
|
|
||||||
### Not implemented in this step
|
|
||||||
- External lane execution (`-m external`) was not required for Step 7 completion and was not run in this pass.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Added
|
|
||||||
|
|
||||||
- `src/transcription/errors.py`
|
|
||||||
- `src/transcription/api/errors.py`
|
|
||||||
- `src/transcription/ui/error_presenter.py`
|
|
||||||
- `tests/test_errors.py`
|
|
||||||
- `tests/api/test_error_responses.py`
|
|
||||||
- `docs/step7.md`
|
|
||||||
|
|
||||||
## Files Updated
|
|
||||||
|
|
||||||
- `src/transcription/app.py`
|
|
||||||
- `src/transcription/services/upload.py`
|
|
||||||
- `src/transcription/services/transcription.py`
|
|
||||||
- `src/transcription/ui/upload_page.py`
|
|
||||||
- `src/transcription/ui/jobs_page.py`
|
|
||||||
- `src/transcription/worker.py`
|
|
||||||
- `tests/services/test_upload.py`
|
|
||||||
- `tests/services/test_transcription.py`
|
|
||||||
- `tests/services/test_worker.py`
|
|
||||||
- `tests/integration/test_pipeline_flow.py`
|
|
||||||
- `uv.lock`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Notes by Phase
|
|
||||||
|
|
||||||
### Phase A/B (Foundation)
|
|
||||||
- Added `ErrorCategory` enum and `AppError` base type in `src/transcription/errors.py`.
|
|
||||||
- Added helper utilities:
|
|
||||||
- `new_error_id()`
|
|
||||||
- `build_error_envelope(...)`
|
|
||||||
- `classify_unexpected_error(...)`
|
|
||||||
- `format_error_detail(...)`
|
|
||||||
|
|
||||||
### Phase C (Service/Provider normalization)
|
|
||||||
- `UploadError` now extends `AppError` and includes category/suggestion/retriable metadata.
|
|
||||||
- `PromptLoadError` and `TranscriptionError` now extend `AppError`.
|
|
||||||
- Provider failures are mapped with deterministic category semantics (auth/payload/provider-failure cases).
|
|
||||||
|
|
||||||
### Phase D (UI visibility)
|
|
||||||
- Added `src/transcription/ui/error_presenter.py`.
|
|
||||||
- Upload and jobs pages now use centralized UI error rendering and summary helpers.
|
|
||||||
- UI error paths now include more visible/actionable guidance and reference IDs.
|
|
||||||
|
|
||||||
### Phase E (Worker failure handling)
|
|
||||||
- Worker now normalizes exception handling into structured persisted `error_detail` strings with:
|
|
||||||
- category marker
|
|
||||||
- suggestion marker
|
|
||||||
- error_id marker
|
|
||||||
- Logging now includes category/error_id context in failure paths.
|
|
||||||
|
|
||||||
### Phase F (API envelope)
|
|
||||||
- Added `src/transcription/api/errors.py` and registered handlers in app factory.
|
|
||||||
- AppError and unexpected exceptions now serialize to stable API envelopes with mapped status codes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Validation Commands and Outcomes
|
|
||||||
|
|
||||||
All commands were executed with `uv run python -m pytest ...` and completed successfully.
|
|
||||||
|
|
||||||
1. `uv run python -m pytest tests/test_errors.py -q` ✅
|
|
||||||
2. `uv run python -m pytest tests/services/test_upload.py -q` ✅
|
|
||||||
3. `uv run python -m pytest tests/services/test_transcription.py -q` ✅
|
|
||||||
4. `uv run python -m pytest tests/providers/test_openrouter.py -q` ✅
|
|
||||||
5. `uv run python -m pytest tests/services/test_worker.py -q` ✅
|
|
||||||
6. `uv run python -m pytest tests/integration/test_pipeline_flow.py -q` ✅
|
|
||||||
7. `uv run python -m pytest tests/api/test_error_responses.py -q` ✅
|
|
||||||
8. `uv run python -m pytest tests/ui/test_upload_page.py -q` ✅
|
|
||||||
9. `uv run python -m pytest tests/ui/test_jobs_page.py -q` ✅
|
|
||||||
10. `uv run python -m pytest -m "not external" -q` ✅
|
|
||||||
11. `uv run python -m pytest --collect-only -q` ✅
|
|
||||||
12. `uv run python -m pytest -m unit -q` ✅
|
|
||||||
13. `uv run python -m pytest -m integration -q` ✅
|
|
||||||
14. `uv run python -m pytest tests/integration/test_pipeline_flow.py -q` ✅
|
|
||||||
15. `uv run python -m pytest tests/ui/test_upload_page.py -q` ✅
|
|
||||||
16. `uv run python -m pytest tests/ui/test_jobs_page.py -q` ✅
|
|
||||||
17. `uv run python -m pytest -q` ✅
|
|
||||||
|
|
||||||
Observed warning (non-blocking): Starlette/FastAPI TestClient deprecation warning related to `httpx` package naming.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Policy Alignment Check (`docs/error_handling.md`)
|
|
||||||
|
|
||||||
Aligned items:
|
|
||||||
- Stable taxonomy categories are implemented.
|
|
||||||
- Unexpected errors are normalized.
|
|
||||||
- User-facing UI paths include actionable guidance and references.
|
|
||||||
- Worker persistence includes trace-friendly failure detail.
|
|
||||||
- API error responses are structured and category-aware.
|
|
||||||
|
|
||||||
Follow-up candidates:
|
|
||||||
- Add richer UI tests that validate rendered suggested-action content end-to-end (current tests focus helper/service contracts).
|
|
||||||
- Consider typed storage fields for error metadata instead of packed `error_detail` strings in a future schema revision.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 7 Definition of Done Status
|
|
||||||
|
|
||||||
- [x] Shared error taxonomy implemented across MVP layers
|
|
||||||
- [x] GUI error paths upgraded for visibility/actionability
|
|
||||||
- [x] Worker failure persistence and log context standardized
|
|
||||||
- [x] API error envelope handling added and tested
|
|
||||||
- [x] Phase-level and full-suite validation gates passed
|
|
||||||
- [x] Results documented in this report
|
|
||||||
|
|
||||||
Step 7 is complete.
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
## Step 7: Error Handling Standardization and Operational Visibility
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Apply the canonical error policy from `docs/error_handling.md` to the MVP implementation so failures are:
|
|
||||||
|
|
||||||
- consistently classified
|
|
||||||
- visibly surfaced in the GUI
|
|
||||||
- paired with suggested corrective actions
|
|
||||||
- traceable through logs via error reference IDs
|
|
||||||
- validated through deterministic tests after each phase
|
|
||||||
|
|
||||||
This step extends MVP hardening by converting current ad hoc exception behavior into a stable cross-layer contract.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
- Introduce a shared application error contract and taxonomy implementation
|
|
||||||
- Normalize service/provider exceptions into taxonomy categories
|
|
||||||
- Improve GUI error visibility and suggested-action UX
|
|
||||||
- Standardize worker failure persistence and logging context
|
|
||||||
- Add API error-envelope policy hooks for current/future endpoints
|
|
||||||
- Add targeted tests and phase-level/full-suite validation gates
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- Major architecture rewrites (distributed queue, multi-service decomposition)
|
|
||||||
- Post-MVP feature expansion unrelated to error handling
|
|
||||||
- Full observability platform rollout (tracing backends, APM)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Policy Source of Truth
|
|
||||||
|
|
||||||
- Canonical policy document: `docs/error_handling.md`
|
|
||||||
- If implementation and policy diverge, policy is authoritative and code/tests must be updated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Planned Deliverables
|
|
||||||
|
|
||||||
### Runtime code
|
|
||||||
- `src/transcription/errors.py` *(new shared contract module)*
|
|
||||||
- `src/transcription/ui/error_presenter.py` *(new UI error rendering helper)*
|
|
||||||
- Updates to:
|
|
||||||
- `src/transcription/services/upload.py`
|
|
||||||
- `src/transcription/services/transcription.py`
|
|
||||||
- `src/transcription/providers/openrouter.py`
|
|
||||||
- `src/transcription/worker.py`
|
|
||||||
- `src/transcription/ui/upload_page.py`
|
|
||||||
- `src/transcription/ui/jobs_page.py`
|
|
||||||
- `src/transcription/api/*` *(as needed for envelope/handlers)*
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- `tests/test_errors.py` *(new shared error contract tests)*
|
|
||||||
- updates/additions in:
|
|
||||||
- `tests/services/test_upload.py`
|
|
||||||
- `tests/services/test_transcription.py` *(add if missing)*
|
|
||||||
- `tests/providers/test_openrouter.py`
|
|
||||||
- `tests/services/test_worker.py`
|
|
||||||
- `tests/ui/test_upload_page.py`
|
|
||||||
- `tests/ui/test_jobs_page.py`
|
|
||||||
- `tests/api/test_error_responses.py` *(new, if API handlers added)*
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- Update `docs/error_handling.md` only if implementation reveals policy gaps
|
|
||||||
- Capture validation evidence in a Step 7 results artifact (`docs/step7-results.md`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design and Policy Decisions
|
|
||||||
|
|
||||||
1. **Stable taxonomy contract**
|
|
||||||
- Use policy categories as stable identifiers (`validation_error`, `user_input_error`, etc.).
|
|
||||||
|
|
||||||
2. **Actionable UX is mandatory**
|
|
||||||
- User-visible errors must include a suggested course of action.
|
|
||||||
|
|
||||||
3. **Traceability by default**
|
|
||||||
- Non-trivial errors include an `error_id` in both logs and user-facing output.
|
|
||||||
|
|
||||||
4. **Safe surface / rich logs**
|
|
||||||
- UI/API show safe summaries; logs retain diagnostic detail and traceback.
|
|
||||||
|
|
||||||
5. **Deterministic verification cadence**
|
|
||||||
- Targeted tests after each change batch, then phase-level regression gates.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Plan + Checklist
|
|
||||||
|
|
||||||
## Phase A — Baseline Validation and Gap Confirmation
|
|
||||||
|
|
||||||
- [ ] Run baseline tests before changes
|
|
||||||
- [ ] Record baseline outputs and any known flaky behavior
|
|
||||||
- [ ] Confirm current behavior against `docs/error_handling.md` requirements
|
|
||||||
|
|
||||||
### Validation gate
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
- [ ] `uv run pytest -q`
|
|
||||||
|
|
||||||
## Phase B — Shared Error Contract Foundation
|
|
||||||
|
|
||||||
- [ ] Add `src/transcription/errors.py` with:
|
|
||||||
- [ ] stable category enum
|
|
||||||
- [ ] base `AppError` (category/message/suggestion/error_id/retriable)
|
|
||||||
- [ ] helpers for error-id generation and fallback classification
|
|
||||||
- [ ] Keep category names aligned with `docs/error_handling.md`
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- [ ] Add `tests/test_errors.py`
|
|
||||||
- [ ] category stability assertions
|
|
||||||
- [ ] error_id creation behavior
|
|
||||||
- [ ] fallback classification for unexpected exceptions
|
|
||||||
|
|
||||||
### Validation gate
|
|
||||||
- [ ] `uv run pytest tests/test_errors.py -q`
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
|
|
||||||
## Phase C — Service and Provider Normalization
|
|
||||||
|
|
||||||
- [ ] Refactor upload service exceptions to shared taxonomy
|
|
||||||
- [ ] Refactor transcription service exceptions to shared taxonomy
|
|
||||||
- [ ] Normalize provider adapter failures into deterministic categories
|
|
||||||
- [ ] Preserve causal chaining (`raise ... from exc`)
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- [ ] Extend `tests/services/test_upload.py`:
|
|
||||||
- [ ] empty payload category/suggestion
|
|
||||||
- [ ] unsupported extension category/suggestion
|
|
||||||
- [ ] persistence failure category mapping
|
|
||||||
- [ ] Add/extend `tests/services/test_transcription.py`:
|
|
||||||
- [ ] missing/empty prompt behavior
|
|
||||||
- [ ] unsupported file type behavior
|
|
||||||
- [ ] provider failure mapping behavior
|
|
||||||
- [ ] Extend `tests/providers/test_openrouter.py`:
|
|
||||||
- [ ] auth error mapping
|
|
||||||
- [ ] malformed response mapping
|
|
||||||
|
|
||||||
### Validation gate
|
|
||||||
- [ ] `uv run pytest tests/services/test_upload.py -q`
|
|
||||||
- [ ] `uv run pytest tests/services/test_transcription.py -q`
|
|
||||||
- [ ] `uv run pytest tests/providers/test_openrouter.py -q`
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
|
|
||||||
## Phase D — GUI Visibility and Suggested Actions
|
|
||||||
|
|
||||||
- [ ] Add `src/transcription/ui/error_presenter.py`
|
|
||||||
- [ ] Update upload/jobs pages to use centralized error presentation
|
|
||||||
- [ ] Ensure GUI surfaces:
|
|
||||||
- [ ] user-safe message
|
|
||||||
- [ ] suggested action
|
|
||||||
- [ ] error reference ID
|
|
||||||
- [ ] optional technical details panel
|
|
||||||
- [ ] Replace raw `str(exc)` UX where policy requires safer messaging
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- [ ] Extend `tests/ui/test_upload_page.py` for actionable error UX paths
|
|
||||||
- [ ] Extend `tests/ui/test_jobs_page.py` for refresh/detail error guidance
|
|
||||||
- [ ] Add `tests/ui/test_error_presenter.py` *(optional but recommended)*
|
|
||||||
|
|
||||||
### Validation gate
|
|
||||||
- [ ] `uv run pytest tests/ui/test_upload_page.py -q`
|
|
||||||
- [ ] `uv run pytest tests/ui/test_jobs_page.py -q`
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
|
|
||||||
## Phase E — Worker Failure Persistence and Logging Context
|
|
||||||
|
|
||||||
- [ ] Update worker failure handling to classify errors before persistence
|
|
||||||
- [ ] Ensure failed jobs persist actionable, structured error detail
|
|
||||||
- [ ] Add log context fields where available (`error_id`, `category`, `operation`, `job_id`)
|
|
||||||
- [ ] Ensure retry semantics are explicit and bounded (or clearly documented as deferred)
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- [ ] Extend `tests/services/test_worker.py`:
|
|
||||||
- [ ] missing document failure contract
|
|
||||||
- [ ] provider/transcription failure contract
|
|
||||||
- [ ] persisted error detail includes category/suggestion/error_id markers
|
|
||||||
- [ ] Validate integration failure flow in `tests/integration/test_pipeline_flow.py`
|
|
||||||
|
|
||||||
### Validation gate
|
|
||||||
- [ ] `uv run pytest tests/services/test_worker.py -q`
|
|
||||||
- [ ] `uv run pytest tests/integration/test_pipeline_flow.py -q`
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
|
|
||||||
## Phase F — API Error Envelope Alignment (Current + Future Routes)
|
|
||||||
|
|
||||||
- [ ] Add shared API error serialization utilities/handlers (as needed)
|
|
||||||
- [ ] Ensure API responses can include:
|
|
||||||
- [ ] `error_id`
|
|
||||||
- [ ] `category`
|
|
||||||
- [ ] `message`
|
|
||||||
- [ ] `suggestion`
|
|
||||||
- [ ] `timestamp`
|
|
||||||
- [ ] Map categories to HTTP status guidance from `docs/error_handling.md`
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- [ ] Add `tests/api/test_error_responses.py` *(if handlers added)*
|
|
||||||
- [ ] Keep `tests/api/test_health.py` passing
|
|
||||||
|
|
||||||
### Validation gate
|
|
||||||
- [ ] `uv run pytest tests/api/test_error_responses.py -q` *(if added)*
|
|
||||||
- [ ] `uv run pytest tests/api/test_health.py -q`
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
|
|
||||||
## Phase G — Final Regression and Documentation Closure
|
|
||||||
|
|
||||||
- [ ] Reconcile implementation details with `docs/error_handling.md`
|
|
||||||
- [ ] Update policy doc only where required by confirmed implementation learning
|
|
||||||
- [ ] Capture execution evidence in `docs/step7-results.md`
|
|
||||||
|
|
||||||
### Final validation sequence (strict)
|
|
||||||
- [ ] `uv run pytest --collect-only -q`
|
|
||||||
- [ ] `uv run pytest -m unit -q`
|
|
||||||
- [ ] `uv run pytest -m integration -q`
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
- [ ] `uv run pytest tests/integration/test_pipeline_flow.py -q`
|
|
||||||
- [ ] `uv run pytest tests/ui/test_upload_page.py -q`
|
|
||||||
- [ ] `uv run pytest tests/ui/test_jobs_page.py -q`
|
|
||||||
- [ ] `uv run pytest -q`
|
|
||||||
|
|
||||||
Optional:
|
|
||||||
- [ ] `uv run pytest -m external -q`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Do not weaken user-facing clarity to expose raw internals.
|
|
||||||
- Do not introduce silent exception swallowing.
|
|
||||||
- Do not break category-name stability without policy update.
|
|
||||||
- Do not merge phase changes without passing that phase validation gate.
|
|
||||||
- Keep targeted tests fast and deterministic; isolate external-provider tests under `external`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Definition of Done (Step 7)
|
|
||||||
|
|
||||||
- [ ] Shared error taxonomy is implemented and used across MVP layers
|
|
||||||
- [ ] GUI error experiences are visible, actionable, and traceable
|
|
||||||
- [ ] Worker persists and logs failure context consistently
|
|
||||||
- [ ] API error contract path is aligned for current/future endpoints
|
|
||||||
- [ ] Phase-by-phase test gates pass
|
|
||||||
- [ ] Full suite remains green (`uv run pytest -q`)
|
|
||||||
- [ ] Step 7 results are documented with evidence
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PR Checklist (Step 7)
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
- [ ] Added shared error contract module
|
|
||||||
- [ ] Updated service/provider/worker/UI error handling paths
|
|
||||||
- [ ] Added actionable GUI guidance for user-visible failures
|
|
||||||
- [ ] Added error reference IDs for traceability
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- [ ] Added/updated tests per phase scope
|
|
||||||
- [ ] Ran targeted phase tests after each change batch
|
|
||||||
- [ ] Ran `not external` regression at each phase boundary
|
|
||||||
- [ ] Ran full suite before closeout
|
|
||||||
|
|
||||||
### Documentation and Evidence
|
|
||||||
- [ ] `docs/error_handling.md` reviewed for alignment
|
|
||||||
- [ ] `docs/step7-results.md` includes executed command outputs
|
|
||||||
- [ ] Residual risks and deferred items explicitly recorded
|
|
||||||
-209
@@ -1,209 +0,0 @@
|
|||||||
## MVP Definition: Historical Document Transcription System
|
|
||||||
|
|
||||||
### 1. MVP Objective
|
|
||||||
Deliver the thinnest possible end-to-end vertical slice — a user uploads an image of a document, the system transcribes it via the OpenRouter Python SDK, and the user reads the resulting transcript — with just enough persistence and structure to validate the core value proposition: *can AI-driven transcription, guided by curated prompts, produce useful verbatim transcripts of historical family documents?*
|
|
||||||
|
|
||||||
The MVP deliberately defers full-text search, export, revision history, MongoDB, and timeline assembly. These are additive features that don't need validation before the core transcription loop is proven.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Core User Story
|
|
||||||
*As a family historian, I can upload a photo of a historical document, wait for it to be transcribed, and read the verbatim transcript — so I can evaluate whether this system will work for my thousands of documents.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. In-Scope Requirements (from ```requirements.md```)
|
|
||||||
|
|
||||||
| Requirement | ID | MVP Rationale |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| End-to-end transcription with lifecycle state | REQ-0 | This is the MVP. |
|
|
||||||
| Upload one or more images from the web UI | REQ-1 | Core entry point. MVP supports single-image upload (multi-image is a stretch goal). |
|
|
||||||
| Asynchronous processing → transcription or failure | REQ-2 | Validates the AI transcription pipeline. |
|
|
||||||
| Persist and expose job states (queued → processing → transcribed/failed) | REQ-3 | Minimum feedback loop for the user. |
|
|
||||||
| Persist transcription output and failure details | REQ-4 | User must be able to read the result. |
|
|
||||||
| UI views for status and transcript reading | REQ-5 | The user needs to see what happened. |
|
|
||||||
| Background processing to keep UI responsive | REQ-6 | Essential for usability during long AI calls. |
|
|
||||||
| Centralized config and logging at startup | REQ-8 | Small effort, high payoff for debugging. |
|
|
||||||
| Store transcription prompts as Markdown files | REQ-12 | Core to the Prompt Curation Policy in intent.md. Start with a single prompt file. |
|
|
||||||
|
|
||||||
|
|
||||||
### Deferred to Post-MVP
|
|
||||||
| Requirement | ID | Why Deferred |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Lifespan-owned runtime resources (engine, session factory, etc.) | REQ-7 | Important for production robustness, but a simple global or module-level setup is adequate for MVP validation. |
|
|
||||||
| Docker Compose (app + PostgreSQL + optional MongoDB) | REQ-9 | MVP runs locally with SQLite to eliminate container overhead during rapid iteration. PostgreSQL migration is Stage 1 hardening. |
|
|
||||||
| Explicit, opt-in schema bootstrap | REQ-10 | MVP uses auto-create-tables at startup (SQLModel create_all). Production schema discipline comes after the model stabilizes. |
|
|
||||||
| Service-backed persistence for core data | REQ-11 | MVP uses a thin repository layer over SQLite. Full service abstraction follows once the domain model is proven. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. MVP Feature Set
|
|
||||||
#### Feature 1: Document Upload (UI)
|
|
||||||
* A single NiceGUI page with a file-upload widget (accepts .jpg, .png, .tiff, .pdf).
|
|
||||||
* On upload: save the file to a local uploads/ directory, create a Document record, create a Job record with status queued.
|
|
||||||
* Minimal metadata capture: original filename, upload timestamp.
|
|
||||||
|
|
||||||
#### Feature 2: Asynchronous Transcription Worker
|
|
||||||
* An in-process background worker (Python asyncio task or BackgroundTasks) that:
|
|
||||||
1. Picks up queued jobs.
|
|
||||||
2. Transitions status to processing.
|
|
||||||
3. Sends the image + the curated Markdown prompt to an AI vision model via OpenRouter.
|
|
||||||
4. On success: saves the transcript text, transitions to transcribed.
|
|
||||||
5. On failure: saves the error detail, transitions to failed.
|
|
||||||
|
|
||||||
#### Feature 3: Transcription Prompt (Markdown Asset)
|
|
||||||
* A single Markdown file (prompts/transcribe_document.md) encoding the verbatim transcription rules from intent.md (the Document Issues table, scholarly guidelines, etc.).
|
|
||||||
* The worker reads this file at invocation time and injects it as the system/user prompt.
|
|
||||||
|
|
||||||
#### Feature 4: Job Status & Transcript Viewer (UI)
|
|
||||||
* A job list page showing all jobs with their current status (queued / processing / transcribed / failed).
|
|
||||||
* A transcript detail page showing:
|
|
||||||
* The original uploaded image (rendered inline).
|
|
||||||
* The transcription text (or the failure reason).
|
|
||||||
* Timestamp metadata.
|
|
||||||
|
|
||||||
#### Feature 5: Minimal Persistence (SQLite + SQLModel)
|
|
||||||
* Three tables/models:
|
|
||||||
* Document: id, filename, file_path, uploaded_at.
|
|
||||||
* Job: id, document_id (FK), status, created_at, updated_at.
|
|
||||||
* Transcript: id, job_id (FK), text, error_detail, created_at.
|
|
||||||
* SQLite database file stored locally. Auto-created on first startup.
|
|
||||||
|
|
||||||
#### Feature 6: Centralized Configuration
|
|
||||||
* A single config.py (or Pydantic BaseSettings) loading:
|
|
||||||
* PROVIDER (fixed to openrouter for MVP)
|
|
||||||
* OPENROUTER_API_KEY (required)
|
|
||||||
* PROVIDER_MODEL (default: OpenRouter model slug for vision transcription)
|
|
||||||
* OPENROUTER_HTTP_REFERER (optional; app attribution)
|
|
||||||
* OPENROUTER_APP_TITLE (optional; app attribution)
|
|
||||||
* DATABASE_URL (default: sqlite:///./transcription.db)
|
|
||||||
* UPLOAD_DIR (default: ./uploads)
|
|
||||||
* PROMPT_DIR (default: ./prompts)
|
|
||||||
|
|
||||||
#### Feature 7: MVP Dependency Baseline (OpenRouter-Centric)
|
|
||||||
* Runtime dependencies:
|
|
||||||
* openrouter (official OpenRouter Python SDK)
|
|
||||||
* pydantic
|
|
||||||
* pydantic-settings
|
|
||||||
* sqlmodel
|
|
||||||
* Explicitly out of MVP runtime dependencies:
|
|
||||||
* google-genai (deferred until/if Gemini is introduced post-MVP)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. MVP Architecture (Simplified)
|
|
||||||
|
|
||||||
```Apply
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ NiceGUI Web UI │
|
|
||||||
│ ┌──────────────┐ ┌───────────────────┐ │
|
|
||||||
│ │ Upload Page │ │ Jobs / Transcript │ │
|
|
||||||
│ └──────┬───────┘ └───────┬───────────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ ▼ ▼ │
|
|
||||||
│ ┌───────────────────────────┐ │
|
|
||||||
│ │ Application Service │ │
|
|
||||||
│ │ (upload, job lifecycle) │ │
|
|
||||||
│ └─────┬─────────────┬───────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ ┌─────▼─────┐ ┌─────▼───────────────┐ │
|
|
||||||
│ │ SQLite DB │ │ Background Worker │ │
|
|
||||||
│ │ (SQLModel)│ │ → AI Vision Provider│ │
|
|
||||||
│ └───────────┘ └─────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌─────▼──────┐ │
|
|
||||||
│ │ prompts/ │ │
|
|
||||||
│ │ *.md files │ │
|
|
||||||
│ └────────────┘ │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 6. Proposed File Structure
|
|
||||||
|
|
||||||
```Apply
|
|
||||||
project-root/
|
|
||||||
├── docs/ # (existing)
|
|
||||||
├── prompts/
|
|
||||||
│ └── transcribe_document.md # curated transcription prompt
|
|
||||||
├── src/
|
|
||||||
│ └── transcription/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── app.py # FastAPI + NiceGUI app entrypoint
|
|
||||||
│ ├── config.py # Pydantic BaseSettings
|
|
||||||
│ ├── models.py # SQLModel: Document, Job, Transcript
|
|
||||||
│ ├── db.py # engine, session, create_all
|
|
||||||
│ ├── providers/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── base.py # provider interface (transcribe contract)
|
|
||||||
│ │ ├── openrouter.py # OpenRouter via official Python SDK
|
|
||||||
│ ├── services/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── upload.py # save file + create records
|
|
||||||
│ │ └── transcription.py # call provider, update job
|
|
||||||
│ ├── worker.py # background job loop
|
|
||||||
│ └── ui/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── upload_page.py # NiceGUI upload page
|
|
||||||
│ └── jobs_page.py # NiceGUI job list + detail
|
|
||||||
├── tests/
|
|
||||||
│ ├── test_models.py
|
|
||||||
│ ├── test_upload.py
|
|
||||||
│ └── test_transcription.py
|
|
||||||
├── pyproject.toml
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 7. MVP Validation Criteria
|
|
||||||
The MVP is considered validated when:
|
|
||||||
|
|
||||||
1. ✅ A user can upload an image of a document through the browser.
|
|
||||||
2. ✅ The system asynchronously sends the image to the configured AI vision model with the curated prompt.
|
|
||||||
3. ✅ The transcript (or failure reason) is persisted and visible in the UI.
|
|
||||||
4. ✅ The transcription follows verbatim scholarly rules defined in intent.md (spot-checked by the user on real family documents).
|
|
||||||
5. ✅ The transcription prompt is stored as a standalone Markdown file and can be edited without code changes.
|
|
||||||
6. ✅ Job status transitions are visible: queued → processing → transcribed/failed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. Key Feedback Questions the MVP Should Answer
|
|
||||||
These are the real unknowns this MVP exists to resolve:
|
|
||||||
|
|
||||||
| # | Question | How We Learn |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 1 | Is AI transcription quality good enough for this document corpus? | User reviews 20–50 real transcriptions against originals. |
|
|
||||||
| 2 | Does the verbatim prompt produce scholarly-quality output, or does it need major rework? | Compare output to the Document Issues table rules in intent.md. |
|
|
||||||
| 3 | What document types are hardest (old cursive, faded ink, pencil, postcards)? | Track which uploads produce failed or low-quality results. |
|
|
||||||
| 4 | Is single-image upload sufficient, or is batch upload needed early? | User friction during real scanning sessions. |
|
|
||||||
| 5 | What metadata is missing that the user wishes they could capture at upload time? | User feedback after processing real batches. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 9. What Comes After MVP (Immediate Post-MVP)
|
|
||||||
Once the core transcription loop is validated, the next priorities (aligned to Architecture Stage 1) are:
|
|
||||||
|
|
||||||
1. **Multi-image upload** — process a batch from a scanning session.
|
|
||||||
2. **PostgreSQL migration** — swap SQLite for containerized PostgreSQL (REQ-9, REQ-10).
|
|
||||||
3. **Revision history** — allow the user to edit/correct transcripts with immutable version tracking.
|
|
||||||
4. **Full-text search** — search across all accepted transcripts.
|
|
||||||
5. **Repository/service layer formalization** — proper ports/adapters as the domain model stabilizes.
|
|
||||||
6. **Docker Compose deployment** — containerize the app for reproducible operation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 10. Implementation Approach
|
|
||||||
Recommended build order for the MVP (each step produces a testable increment):
|
|
||||||
|
|
||||||
| Step | Deliverable | Validates |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 1 | config.py + models.py + db.py — data layer with SQLite | Schema and config foundation |
|
|
||||||
| 2 | prompts/transcribe_document.md — curated prompt from intent.md | Prompt asset pattern |
|
|
||||||
| 3 | services/transcription.py + providers/ — call AI vision provider with prompt + image | Core AI integration |
|
|
||||||
| 4 | services/upload.py + worker.py — upload handling + background job loop | End-to-end pipeline (CLI-testable) |
|
|
||||||
| 5 | ui/upload_page.py + ui/jobs_page.py — NiceGUI pages | User-facing interface |
|
|
||||||
| 6 | tests/ — unit + integration tests Automated verification |
|
|
||||||
|
|
||||||
This MVP is deliberately narrow: **one prompt, one provider (OpenRouter), one user, one image at a time, SQLite, no containers**. Every omission is intentional — the goal is to get real family documents through the transcription pipeline as fast as possible and let the quality of the output guide every subsequent decision.
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 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](invariant/intent.md)
|
||||||
|
- [Transcription Methodology](invariant/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)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# 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](invariant/intent.md)
|
||||||
|
- [Transcription Methodology](invariant/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)
|
||||||
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
```mermaid
|
||||||
|
block-beta
|
||||||
|
columns 3
|
||||||
|
|
||||||
|
%% UI Component Column
|
||||||
|
block:UI["UI COMPONENTS / WIREFRAME"]:1
|
||||||
|
columns 1
|
||||||
|
|
||||||
|
block:HeaderUI["Header & Nav"]:1
|
||||||
|
columns 1
|
||||||
|
h_title["[Text] Document Name & Type"]
|
||||||
|
h_date["[Text] Date & Origin Location"]
|
||||||
|
end
|
||||||
|
|
||||||
|
block:EditorUI["Page Transcription Editor"]:1
|
||||||
|
columns 1
|
||||||
|
ed_img["[Image Viewer] Source Image"]
|
||||||
|
ed_page["[Badge] Page Number"]
|
||||||
|
ed_raw["[Read-Only] AI Raw Output"]
|
||||||
|
ed_rev["[Textarea] Human Revised Text"]
|
||||||
|
end
|
||||||
|
|
||||||
|
block:PeopleUI["Attribution Sidebar"]:1
|
||||||
|
columns 1
|
||||||
|
p_author["[List] Authors (Full Name)"]
|
||||||
|
p_recip["[List] Recipients (Full Name)"]
|
||||||
|
p_bio["[Card] Person Biography & Dates"]
|
||||||
|
end
|
||||||
|
|
||||||
|
block:JobUI["AI Processing Drawer"]:1
|
||||||
|
columns 1
|
||||||
|
j_status["[Badge] Job Status"]
|
||||||
|
j_model["[Text] Provider & Model"]
|
||||||
|
j_tokens["[JSON View] AI Token Usage"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Directional Mapping / Connectors
|
||||||
|
block:FLOW["MAPPING / FLOW"]:1
|
||||||
|
columns 1
|
||||||
|
f1["Reads / Updates -->"]
|
||||||
|
f2["Renders Active Page -->"]
|
||||||
|
f3["Joins via Role -->"]
|
||||||
|
f4["Executes & Logs -->"]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Postgres Schema Column
|
||||||
|
block:DB["POSTGRES SQL SCHEMA"]:1
|
||||||
|
columns 1
|
||||||
|
|
||||||
|
block:DocTbl["Table: document"]:1
|
||||||
|
columns 1
|
||||||
|
d_id["id : UUID (PK)"]
|
||||||
|
d_name["name : TEXT"]
|
||||||
|
d_type["document_type : TEXT"]
|
||||||
|
d_date["document_date : DATE"]
|
||||||
|
end
|
||||||
|
|
||||||
|
block:SrcTbl["Table: source"]:1
|
||||||
|
columns 1
|
||||||
|
s_id["id : UUID (PK)"]
|
||||||
|
s_page["page_number : INT"]
|
||||||
|
s_path["file_path : TEXT"]
|
||||||
|
s_raw["raw_transcription : TEXT"]
|
||||||
|
s_rev["revised_text : TEXT"]
|
||||||
|
end
|
||||||
|
|
||||||
|
block:PersonTbl["Table: person & document_person"]:1
|
||||||
|
columns 1
|
||||||
|
p_id["id : UUID (PK)"]
|
||||||
|
p_name["full_name : TEXT"]
|
||||||
|
p_role["role : 'author' | 'recipient'"]
|
||||||
|
end
|
||||||
|
|
||||||
|
block:JobTbl["Table: job & job_source"]:1
|
||||||
|
columns 1
|
||||||
|
j_id["id : UUID (PK)"]
|
||||||
|
j_stat["status : VARCHAR"]
|
||||||
|
j_prov["provider / model : TEXT"]
|
||||||
|
j_meta["ai_metadata : JSONB"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Connections
|
||||||
|
HeaderUI --> DocTbl
|
||||||
|
ed_img --> s_path
|
||||||
|
ed_page --> s_page
|
||||||
|
ed_raw --> s_raw
|
||||||
|
ed_rev --> s_rev
|
||||||
|
PeopleUI --> PersonTbl
|
||||||
|
JobUI --> JobTbl
|
||||||
|
```
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph UI["UI Components / Wireframe"]
|
||||||
|
direction TB
|
||||||
|
subgraph HeaderUI["Header & Nav"]
|
||||||
|
h_title["[Text] Document Name & Type"]
|
||||||
|
h_date["[Text] Date & Origin Location"]
|
||||||
|
end
|
||||||
|
subgraph EditorUI["Page Transcription Editor"]
|
||||||
|
ed_img["[Image Viewer] Source Image"]
|
||||||
|
ed_page["[Badge] Page Number"]
|
||||||
|
ed_raw["[Read-Only] AI Raw Output"]
|
||||||
|
ed_rev["[Textarea] Human Revised Text"]
|
||||||
|
end
|
||||||
|
subgraph PeopleUI["Attribution Sidebar"]
|
||||||
|
p_author["[List] Authors / Recipients"]
|
||||||
|
end
|
||||||
|
subgraph JobUI["AI Processing Drawer"]
|
||||||
|
j_status["[Badge] Job Status"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph DB["Postgres SQL Schema"]
|
||||||
|
direction TB
|
||||||
|
subgraph DocTbl["Table: document"]
|
||||||
|
d_name["name : TEXT"]
|
||||||
|
d_type["document_type : TEXT"]
|
||||||
|
end
|
||||||
|
subgraph SrcTbl["Table: source"]
|
||||||
|
s_path["file_path : TEXT"]
|
||||||
|
s_page["page_number : INT"]
|
||||||
|
s_raw["raw_transcription : TEXT"]
|
||||||
|
s_rev["revised_text : TEXT"]
|
||||||
|
end
|
||||||
|
subgraph PersonTbl["Table: person & document_person"]
|
||||||
|
p_name["full_name : TEXT"]
|
||||||
|
p_role["role : author | recipient"]
|
||||||
|
end
|
||||||
|
subgraph JobTbl["Table: job & job_source"]
|
||||||
|
j_stat["status : VARCHAR"]
|
||||||
|
j_meta["ai_metadata : JSONB"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Mappings
|
||||||
|
HeaderUI --> DocTbl
|
||||||
|
ed_img --> s_path
|
||||||
|
ed_page --> s_page
|
||||||
|
ed_raw --> s_raw
|
||||||
|
ed_rev --> s_rev
|
||||||
|
PeopleUI --> PersonTbl
|
||||||
|
JobUI --> JobTbl
|
||||||
|
```
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Architecture
|
# 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.
|
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.
|
||||||
|
|
||||||
@@ -17,10 +17,10 @@ The deployed system targets personal use and a corpus of several thousand docume
|
|||||||
|
|
||||||
Current scope includes:
|
Current scope includes:
|
||||||
|
|
||||||
- document upload and metadata capture
|
- content source upload and metadata capture
|
||||||
- asynchronous transcription jobs
|
- asynchronous transcription jobs
|
||||||
- prompt-library driven transcription behavior, with one Markdown file per prompt
|
- prompt-library driven transcription behavior, with one Markdown file per prompt
|
||||||
- transcript review and revision history
|
- original transcription review and optional revision review
|
||||||
- full-text search over accepted transcripts
|
- full-text search over accepted transcripts
|
||||||
- export of transcript data
|
- export of transcript data
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ flowchart LR
|
|||||||
Worker --> MG
|
Worker --> MG
|
||||||
```
|
```
|
||||||
|
|
||||||
## Runtime Ownership And Startup Policy (V1 Step 1)
|
## Runtime Ownership And Startup Policy
|
||||||
|
|
||||||
The current implementation now uses explicit lifespan-owned runtime resources.
|
The current implementation now uses explicit lifespan-owned runtime resources.
|
||||||
|
|
||||||
@@ -131,22 +131,31 @@ Out of scope:
|
|||||||
|
|
||||||
Production transcription flow:
|
Production transcription flow:
|
||||||
|
|
||||||
1. A user uploads an image or PDF through the UI or API.
|
1. A user uploads one or more content sources through the UI or API.
|
||||||
2. The application validates payloads and creates document and job records.
|
2. The application validates payloads and creates document, source, and job records.
|
||||||
3. The in-process worker dequeues the job and calls the transcription provider.
|
3. The in-process worker de-queues the job and calls the transcription provider.
|
||||||
4. The application persists transcript output, confidence metadata, and provenance events.
|
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.
|
5. Job status transitions from queued to processing to transcribed or failed.
|
||||||
6. The UI and API expose status, revision history, and searchable transcript text.
|
6. The UI and API expose status, optional revision to original transcription, and searchable transcription text.
|
||||||
|
|
||||||
## Data Model Ownership
|
## Data Model Ownership
|
||||||
|
|
||||||
System-of-record entities:
|
System-of-record entities:
|
||||||
|
|
||||||
- documents and pages
|
- documents and content sources
|
||||||
- transcription jobs and status events
|
- transcription jobs, original transcription, and status events
|
||||||
- transcript revisions
|
- transcript revisions
|
||||||
- provenance metadata
|
- 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:
|
Storage strategy:
|
||||||
|
|
||||||
- PostgreSQL for relational system-of-record entities
|
- PostgreSQL for relational system-of-record entities
|
||||||
@@ -250,12 +259,14 @@ Control:
|
|||||||
|
|
||||||
Risk:
|
Risk:
|
||||||
|
|
||||||
- transcription quality varies by document type, handwriting legibility, and image quality
|
- transcription quality varies by content source type, handwriting legibility, and source quality
|
||||||
|
|
||||||
Control:
|
Control:
|
||||||
|
|
||||||
- first-class human review and immutable revision history
|
- first-class human review and immutable revision history
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Technology References
|
## Technology References
|
||||||
|
|
||||||
- [FastAPI documentation](https://fastapi.tiangolo.com/)
|
- [FastAPI documentation](https://fastapi.tiangolo.com/)
|
||||||
@@ -264,13 +275,16 @@ Control:
|
|||||||
- [PostgreSQL documentation](https://www.postgresql.org/docs/)
|
- [PostgreSQL documentation](https://www.postgresql.org/docs/)
|
||||||
- [MongoDB documentation](https://www.mongodb.com/docs/)
|
- [MongoDB documentation](https://www.mongodb.com/docs/)
|
||||||
|
|
||||||
## Related Pages
|
## Related Local References
|
||||||
|
|
||||||
- [System overview](index.md)
|
- [System Overview](index_v1.md)
|
||||||
- [Version 1 plan](ver1/ver1.md)
|
- [System Design Intent](intent.md)
|
||||||
- [Version 1 Step 1 plan](ver1/ver1-step1.md)
|
- [Transcription Methodology](transcription_methodology.md)
|
||||||
- [Version 1 Step 1 results](ver1/ver1-step1-results.md)
|
- System Architecture (this document)
|
||||||
- [Architecture decision records index](adr/README.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
|
## Glossary
|
||||||
|
|
||||||
@@ -289,6 +303,6 @@ Control:
|
|||||||
- Port/Interface: A stable contract used by application/domain code to call infrastructure implementations.
|
- 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.
|
- 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.
|
- Provenance: Metadata that records where generated data came from and how it was produced.
|
||||||
- Revision history: Versioned record of transcript edits over time.
|
- Revision history: Optional versioned record of user-authored transcription edits over time.
|
||||||
- System of record: The authoritative persistent store for canonical data.
|
- 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.
|
- Vertical slice: A minimal end-to-end feature path spanning UI/API, application logic, and persistence.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Error Handling
|
# 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.
|
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.
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ The system uses stable, implementation-independent categories:
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `validation_error` | Payload or parameter shape/content is invalid | UI/API input validation, service guards | no |
|
| `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 |
|
| `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/transcript | no |
|
| `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 |
|
| `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 |
|
| `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_transient_error` | Temporary environment issue | network timeout, DB connection reset | yes |
|
||||||
@@ -111,7 +111,7 @@ All logged errors must include, where available:
|
|||||||
- `category`
|
- `category`
|
||||||
- `operation` (e.g., `upload.submit`, `worker.process_job`, `jobs.refresh`)
|
- `operation` (e.g., `upload.submit`, `worker.process_job`, `jobs.refresh`)
|
||||||
- `exception_type`
|
- `exception_type`
|
||||||
- `job_id`, `document_id` (when relevant)
|
- `job_id`, `document_id`, `source_id` (when relevant)
|
||||||
- UTC timestamp
|
- UTC timestamp
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
@@ -266,12 +266,18 @@ Change requirements:
|
|||||||
- preserve taxonomy stability; if changed, document migration impact
|
- preserve taxonomy stability; if changed, document migration impact
|
||||||
- record noteworthy policy changes in project release notes or changelog
|
- record noteworthy policy changes in project release notes or changelog
|
||||||
|
|
||||||
## Related Pages
|
---
|
||||||
|
|
||||||
- [System overview](index.md)
|
## Related Local References
|
||||||
- [Architecture](architecture.md)
|
|
||||||
- [Requirements](requirements.md)
|
- [System Overview](index_v1.md)
|
||||||
- [Intent](intent.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
|
## Glossary
|
||||||
|
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# 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,12 +1,10 @@
|
|||||||
## Document Transcription System
|
## 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.
|
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
|
## Start Here
|
||||||
|
|
||||||
Read [architecture.md](architecture.md) first.
|
Read [architecture_v1.md](architecture_v1.md) first.
|
||||||
|
|
||||||
Then review [ver1/ver1.md](ver1/ver1.md) for completion scope and [ver1/ver1-step1-results.md](ver1/ver1-step1-results.md) for current architecture-consolidation status.
|
|
||||||
|
|
||||||
The architecture page is the primary technical reference and defines:
|
The architecture page is the primary technical reference and defines:
|
||||||
|
|
||||||
@@ -17,14 +15,15 @@ The architecture page is the primary technical reference and defines:
|
|||||||
|
|
||||||
## What The Application Does
|
## What The Application Does
|
||||||
|
|
||||||
At a high level, users upload images of handwritten, typed, or typeset documents, run asynchronous transcription jobs, review and edit transcript revisions, and search across accepted text.
|
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:
|
### Core capabilities:
|
||||||
|
|
||||||
- document upload and metadata capture
|
- document grouping with one or more content sources and metadata capture
|
||||||
- asynchronous transcription with visible job status
|
- 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
|
- transcription prompt management with one Markdown file per prompt for human refinement over time
|
||||||
- revision history for transcript edits
|
- optional revisions for user-authored edits of original immutable transcription text
|
||||||
- full-text search over accepted transcripts
|
- full-text search over accepted transcripts
|
||||||
- export of transcript data
|
- export of transcript data
|
||||||
|
|
||||||
@@ -39,16 +38,18 @@ The system runs with minimal operational overhead:
|
|||||||
|
|
||||||
This operating model keeps deployment and maintenance simple while preserving clean boundaries for future scale.
|
This operating model keeps deployment and maintenance simple while preserving clean boundaries for future scale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Documentation Map
|
## Documentation Map
|
||||||
|
|
||||||
- Architecture and technical design: [architecture.md](architecture.md)
|
- System Overview (this document)
|
||||||
- Version 1 implementation plan: [ver1/ver1.md](ver1/ver1.md)
|
- [System Design Intent](intent.md)
|
||||||
- Version 1 Step 1 plan: [ver1/ver1-step1.md](ver1/ver1-step1.md)
|
- [Transcription Methodology](transcription_methodology.md)
|
||||||
- Version 1 Step 1 results: [ver1/ver1-step1-results.md](ver1/ver1-step1-results.md)
|
- [System Architecture](architecture_v1.md)
|
||||||
- Architecture decision records (ADR index): [adr/README.md](adr/README.md)
|
- [System Requirements](requirements_v1.md)
|
||||||
- Runtime and deployment requirements: [requirements.md](requirements.md)
|
- [Data model](schema_v1.md)
|
||||||
- Error handling policy and operational guidance: [error_handling.md](error_handling.md)
|
- [Error Handling Policy](error_handling_v1.md)
|
||||||
- Domain context and transcription policy: [intent.md](intent.md)
|
- [Implementation Plan](implementation_plan_v1.md)
|
||||||
|
|
||||||
## Glossary
|
## Glossary
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 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,6 +1,6 @@
|
|||||||
## Document Transcription System Requirements
|
## Document Transcription System Requirements
|
||||||
|
|
||||||
This page captures a SysML v1.6-style requirements baseline for the production system described in [index.md](index.md). The model is represented as concise tables and traceability lists that preserve SysML-style IDs and relationship semantics.
|
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
|
## Scope
|
||||||
|
|
||||||
@@ -15,9 +15,9 @@ This page captures a SysML v1.6-style requirements baseline for the production s
|
|||||||
| ID | Category | Requirement | Risk | Verify Method |
|
| ID | Category | Requirement | Risk | Verify Method |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| REQ-0 | System | Provide end-to-end document transcription with persistent, inspectable lifecycle state. | medium | demonstration |
|
| 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 document images from the web UI. | low | test |
|
| 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 a transcription or explicit failure. | high | 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: upload, queued, processing, transcribed, failed, completed. | high | inspection |
|
| 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-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-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-6 | Performance | Trigger background processing on upload to preserve UI responsiveness. | medium | analysis |
|
||||||
@@ -27,10 +27,11 @@ This page captures a SysML v1.6-style requirements baseline for the production s
|
|||||||
| REQ-10 | Design Constraint | Keep schema bootstrap explicit and opt-in; normal startup does not mutate production schema. | high | inspection |
|
| 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-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-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
|
### Requirement Relationships
|
||||||
|
|
||||||
- Contains: REQ-0 contains REQ-1 through REQ-12.
|
- Contains: REQ-0 contains REQ-1 through REQ-13.
|
||||||
- Derives: REQ-2 -> REQ-3, REQ-3 -> REQ-4.
|
- Derives: REQ-2 -> REQ-3, REQ-3 -> REQ-4.
|
||||||
- Traces: REQ-5 -> REQ-3.
|
- Traces: REQ-5 -> REQ-3.
|
||||||
- Refines: REQ-6 -> REQ-2.
|
- Refines: REQ-6 -> REQ-2.
|
||||||
@@ -50,17 +51,17 @@ This page captures a SysML v1.6-style requirements baseline for the production s
|
|||||||
|
|
||||||
### Satisfaction Mapping
|
### Satisfaction Mapping
|
||||||
|
|
||||||
- UI satisfies REQ-1, REQ-5.
|
- UI satisfies REQ-1, REQ-5, REQ-13.
|
||||||
- API satisfies REQ-5.
|
- API satisfies REQ-5.
|
||||||
- GRAPH satisfies REQ-2, REQ-6.
|
- GRAPH satisfies REQ-2, REQ-6.
|
||||||
- DBREL satisfies REQ-3, REQ-10.
|
- DBREL satisfies REQ-3, REQ-10, REQ-13.
|
||||||
- DBDOC satisfies REQ-4, REQ-11.
|
- DBDOC satisfies REQ-4, REQ-11.
|
||||||
- OPS satisfies REQ-9.
|
- OPS satisfies REQ-9.
|
||||||
- PROMPTS satisfies REQ-12.
|
- PROMPTS satisfies REQ-12.
|
||||||
|
|
||||||
### Verification Mapping
|
### Verification Mapping
|
||||||
|
|
||||||
- TESTS verifies REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-10, REQ-11, REQ-12.
|
- TESTS verifies REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-10, REQ-11, REQ-12, REQ-13.
|
||||||
|
|
||||||
## Requirement Notes
|
## Requirement Notes
|
||||||
|
|
||||||
@@ -76,6 +77,19 @@ This page captures a SysML v1.6-style requirements baseline for the production s
|
|||||||
- Analysis: evaluate asynchronous execution behavior and design sufficiency.
|
- Analysis: evaluate asynchronous execution behavior and design sufficiency.
|
||||||
- Test: automate behavioral checks through pytest suites and service-level tests.
|
- 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
|
## Glossary
|
||||||
|
|
||||||
- Document-oriented persistence: A storage approach that uses flexible document structures for variable data shapes.
|
- Document-oriented persistence: A storage approach that uses flexible document structures for variable data shapes.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
## 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.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
# Version 1 Implementation Plan
|
|
||||||
|
|
||||||
This plan defines the path from MVP to **Version 1 complete**.
|
|
||||||
The objective is to deliver the full scoped product with production readiness, while explicitly separating refinements/enhancements into a future document.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0) Plan Governance & Scope Control (Foundation)
|
|
||||||
|
|
||||||
**Goal:** Keep execution focused on V1 completion, not optimization/perfection.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Create and maintain a **V1 Traceability Matrix**:
|
|
||||||
- Requirement ID
|
|
||||||
- Current status (`done`, `partial`, `not started`)
|
|
||||||
- Owner
|
|
||||||
- Validation method
|
|
||||||
2. Define V1 completion gates:
|
|
||||||
- Functional complete
|
|
||||||
- Operationally complete
|
|
||||||
- Production-ready complete
|
|
||||||
3. Snapshot the MVP baseline (tag/changelog reference).
|
|
||||||
4. Create a standing rule: any non-V1 idea is logged to a separate enhancements backlog document (to be named later), not added to active V1 scope unless explicitly approved.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- `docs/ver1/ver1.md` (this plan)
|
|
||||||
- V1 traceability artifact (linked from here when created)
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Every in-scope requirement has explicit ownership and status.
|
|
||||||
- Scope-change process is agreed and followed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) Architecture Consolidation
|
|
||||||
|
|
||||||
**Goal:** Align implementation with the intended architecture and reduce MVP shortcuts.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Compare implemented modules/components with architecture documentation.
|
|
||||||
2. Identify and classify architectural debt:
|
|
||||||
- Temporary coupling
|
|
||||||
- Missing interfaces
|
|
||||||
- Placeholder services/components
|
|
||||||
3. Resolve high-risk architectural gaps first.
|
|
||||||
4. Record key decisions and tradeoffs in ADRs.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Updated architecture diagrams and boundaries
|
|
||||||
- ADR entries for major decisions
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Architecture documentation reflects system reality.
|
|
||||||
- Critical architecture risks are addressed or scheduled with owners/dates.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) Error Handling & Reliability Hardening
|
|
||||||
|
|
||||||
**Goal:** Ensure predictable, safe behavior under failure conditions.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Standardize error taxonomy and envelope format across all layers.
|
|
||||||
2. Ensure clear distinction between:
|
|
||||||
- User-facing errors
|
|
||||||
- Internal/system errors
|
|
||||||
- Retryable vs non-retryable failures
|
|
||||||
3. Add resilience controls where needed:
|
|
||||||
- Timeouts
|
|
||||||
- Retries with backoff
|
|
||||||
- Circuit breaking / fallback logic
|
|
||||||
4. Add failure-path tests for critical workflows.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Error code catalog/reference
|
|
||||||
- Failure mode test coverage for critical paths
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Error behavior is consistent across major flows.
|
|
||||||
- Known failure scenarios are tested and pass.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) Functional Completion by Requirement Domain
|
|
||||||
|
|
||||||
**Goal:** Complete all V1 functional requirements in a risk-aware order.
|
|
||||||
|
|
||||||
### Recommended Order
|
|
||||||
1. Business-critical end-user flows
|
|
||||||
2. Data integrity and consistency capabilities
|
|
||||||
3. Admin/operational controls
|
|
||||||
4. Lower-priority UX and quality-of-life items that are in V1 scope
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
For each requirement slice:
|
|
||||||
1. Finalize contract/schema
|
|
||||||
2. Implement domain logic
|
|
||||||
3. Implement persistence/state changes
|
|
||||||
4. Integrate API/UI
|
|
||||||
5. Add automated tests
|
|
||||||
6. Update docs
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Requirement completion report with validation evidence
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- All V1 “must-have” requirements are complete and validated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) Data Model, Migration, and Backfill Safety
|
|
||||||
|
|
||||||
**Goal:** Ensure data model and migrations are production-safe.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Validate schema against final V1 domain needs.
|
|
||||||
2. Implement forward-safe migrations.
|
|
||||||
3. Define rollback/mitigation plans for migration failures.
|
|
||||||
4. Build and verify backfill scripts (if needed).
|
|
||||||
5. Add migration rehearsal in staging with representative data.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Migration runbook
|
|
||||||
- Backfill verification checklist
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Migration plan validated in staging.
|
|
||||||
- No unresolved data-loss risk for V1 rollout.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5) Security, Access Control, and Compliance Baseline
|
|
||||||
|
|
||||||
**Goal:** Close MVP security gaps and establish V1 baseline controls.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Complete authn/authz coverage for all routes/actions.
|
|
||||||
2. Enforce input validation and output sanitization.
|
|
||||||
3. Verify secret management and credential rotation process.
|
|
||||||
4. Add audit logging for sensitive operations.
|
|
||||||
5. Run dependency/security scanning in CI and remediate findings.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Security checklist with status
|
|
||||||
- Threat/risk update for V1 scope
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- No unresolved critical/high vulnerabilities for V1 launch.
|
|
||||||
- Access control behavior verified by tests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6) Observability & Operability
|
|
||||||
|
|
||||||
**Goal:** Make system behavior observable and supportable in production.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Standardize structured logging and correlation IDs.
|
|
||||||
2. Add core metrics:
|
|
||||||
- Latency
|
|
||||||
- Throughput
|
|
||||||
- Error rates
|
|
||||||
- Resource saturation
|
|
||||||
3. Add tracing for critical request/workflow paths.
|
|
||||||
4. Define SLOs/SLIs and alert thresholds.
|
|
||||||
5. Prepare incident response and rollback runbooks.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Dashboards and alerts
|
|
||||||
- Operations runbooks
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Team can detect, triage, and remediate incidents quickly.
|
|
||||||
- Core production signals are available and reliable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7) Test Strategy Expansion & Quality Gates
|
|
||||||
|
|
||||||
**Goal:** Raise confidence for repeatable, low-risk releases.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Expand unit and integration tests across V1 features.
|
|
||||||
2. Add contract tests between key components/services.
|
|
||||||
3. Add end-to-end tests for critical user journeys.
|
|
||||||
4. Add non-functional tests where relevant:
|
|
||||||
- Performance/load
|
|
||||||
- Soak
|
|
||||||
- Failure-injection scenarios
|
|
||||||
5. Enforce CI quality gates (tests, lint, type checks, security scans).
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Test matrix with ownership
|
|
||||||
- CI gate definition and thresholds
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Critical-path regressions are blocked automatically.
|
|
||||||
- Test coverage and reliability thresholds meet V1 targets.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8) Performance & Scalability Validation
|
|
||||||
|
|
||||||
**Goal:** Meet expected V1 performance at projected load.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Define performance budgets per key flow.
|
|
||||||
2. Benchmark current behavior in staging.
|
|
||||||
3. Optimize bottlenecks (queries, caching, concurrency, etc.).
|
|
||||||
4. Re-test after each optimization and compare against budget.
|
|
||||||
5. Document known limits and safe operating bounds.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Performance benchmark report
|
|
||||||
- Optimization log
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- V1 performance targets met for expected usage profile.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9) Release Engineering & Environment Readiness
|
|
||||||
|
|
||||||
**Goal:** Make deployment repeatable, controlled, and reversible.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Harden CI/CD pipeline with clear promotion gates.
|
|
||||||
2. Ensure config parity and consistency across environments.
|
|
||||||
3. Define rollout strategy (phased/canary/limited release as applicable).
|
|
||||||
4. Validate rollback procedures in staging.
|
|
||||||
5. Produce release checklist and ownership model.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Release playbook
|
|
||||||
- Environment readiness checklist
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Deployment and rollback are rehearsed and reliable.
|
|
||||||
- Release process is executable without tribal knowledge.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10) Documentation Completion
|
|
||||||
|
|
||||||
**Goal:** Ensure V1 can be built, operated, and supported from documentation.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Update core project docs to match final V1 behavior:
|
|
||||||
- Architecture
|
|
||||||
- Error handling
|
|
||||||
- Requirements status
|
|
||||||
- Index/navigation
|
|
||||||
- Intent alignment summary
|
|
||||||
2. Add operator troubleshooting guides.
|
|
||||||
3. Add integration/API examples for consumers.
|
|
||||||
4. Publish changelog/version notes for V1.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Updated documentation set for V1
|
|
||||||
- V1 release notes
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- A new team member can run/support the system using docs alone.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11) Final Validation, UAT, and Launch
|
|
||||||
|
|
||||||
**Goal:** Confirm readiness and launch V1 safely.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Run full-system acceptance validation against the V1 traceability matrix.
|
|
||||||
2. Conduct stakeholder UAT and capture sign-off.
|
|
||||||
3. Execute production readiness review.
|
|
||||||
4. Launch in controlled phases and monitor key signals.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- UAT/PRR sign-off records
|
|
||||||
- Launch checklist and monitoring plan
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Stakeholder approval achieved.
|
|
||||||
- Launch metrics are stable within defined thresholds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12) Post-Launch Stabilization (30–60 Days)
|
|
||||||
|
|
||||||
**Goal:** Consolidate V1 in production before major expansion.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Track incidents, defects, and user feedback.
|
|
||||||
2. Prioritize stabilization fixes with short cycle times.
|
|
||||||
3. Remove temporary flags/mitigations introduced during launch.
|
|
||||||
4. Produce post-launch retrospective and handoff to standard roadmap cadence.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Stabilization report
|
|
||||||
- Prioritized backlog update
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Incident/error rates converge to steady-state targets.
|
|
||||||
- V1 transitions from launch mode to normal operations.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommended Execution Rhythm
|
|
||||||
|
|
||||||
- **Weekly:** Requirement closure + risk review
|
|
||||||
- **Biweekly:** Release train with quality gates
|
|
||||||
- **Milestone reviews:** After phases 2, 6, 9, and 11
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope Discipline Rule (V1 Focus)
|
|
||||||
|
|
||||||
To preserve delivery focus:
|
|
||||||
- V1 execution prioritizes completion of scoped requirements.
|
|
||||||
- Refinements/enhancements are captured in a separate future document and backlog.
|
|
||||||
- Only explicitly approved scope changes may enter this plan.
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# AI Coding Assistant Project Briefing & Context
|
||||||
|
|
||||||
|
## Project Mission
|
||||||
|
This application is a family history archival and transcription platform. Its primary goal is to accept scanned document images (letters, postcards, logbooks, diaries), execute OCR and structured transcription via AI vision models (OpenAI GPT-4o, Anthropic Claude 3.5 Sonnet), and manage historical metadata (authors, recipients, dates, and locations).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Stack & Architecture
|
||||||
|
* **Database:** PostgreSQL 13+ with native `UUID` (`gen_random_uuid()`) and `JSONB` columns.
|
||||||
|
* **Backend Runtime / Concurrency:** Python utilizing `asyncio` for concurrent HTTP API calls to AI providers, with strict rate-limiting via `asyncio.Semaphore`.
|
||||||
|
* **Validation & Types:** Python with **Pydantic** model definitions. Incoming AI responses must be parsed and validated with Pydantic models *before* database insertion.
|
||||||
|
* **ORM / Database Access:** SQLModel and SQLAlchemy, using parameterized statements and PostgreSQL-native types.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core System Directives for AI Code Generation
|
||||||
|
|
||||||
|
### 1. Data Immutability vs. Human Corrections
|
||||||
|
* `job_source.raw_transcription` and `source.raw_transcription` represent original, point-in-time machine outputs and are **immutable**.
|
||||||
|
* Human corrections occur on `source.revised_text`.
|
||||||
|
* When fetching text for the UI, always display `COALESCE(source.revised_text, source.raw_transcription)`.
|
||||||
|
|
||||||
|
### 2. Async Execution & Batching Rules
|
||||||
|
* A `job` represents an overarching execution run for a folder/group of images belonging to a single `document`.
|
||||||
|
* Images are submitted to AI APIs **one at a time in rapid succession** using `asyncio` worker pools.
|
||||||
|
* Each single-image API call populates a row in `job_source` with its own `status`, `raw_transcription`, `ai_metadata`, and `raw_api_response`.
|
||||||
|
* If 9 of 10 pages succeed and 1 fails, `job_source.status` for the failed image becomes `'failed'`, while `job.status` becomes `'partial_success'`. Do not mark the entire batch as failed if partial results exist.
|
||||||
|
|
||||||
|
### 3. Entity Relationships
|
||||||
|
* **Authors/Recipients:** A `document` can have multiple authors and recipients. Do NOT put direct `author_id` foreign keys on `document`. Query authors/recipients via `document_person` where `role = 'author'` or `role = 'recipient'`.
|
||||||
|
* **Page Ordering:** Multi-page documents must always be queried using `ORDER BY page_number ASC`.
|
||||||
|
|
||||||
|
### 4. Database Mutations
|
||||||
|
* Always use parameterized SQL queries (`$1`, `$2`) to prevent SQL injection.
|
||||||
|
* Store datetimes using UTC ISO 8601 strings or native PostgreSQL `TIMESTAMPTZ`.
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
# SQLModel Table Models
|
||||||
|
|
||||||
|
These models implement the canonical [Version 2 database schema](../schema_v2.md). Each schema entity is represented by exactly one `SQLModel` table class. Because `SQLModel` is built on Pydantic and SQLAlchemy, these classes provide application validation and PostgreSQL mappings without parallel row and create models.
|
||||||
|
|
||||||
|
Database-generated UUIDs and timestamps are `None` until PostgreSQL supplies their values during insert. The database columns remain non-nullable. `Person.metadata_` maps to the `metadata` column because `metadata` is reserved by SQLAlchemy's declarative API.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import date
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import JsonValue
|
||||||
|
from sqlalchemy import Column
|
||||||
|
from sqlalchemy import Date
|
||||||
|
from sqlalchemy import DateTime
|
||||||
|
from sqlalchemy import ForeignKey
|
||||||
|
from sqlalchemy import Index
|
||||||
|
from sqlalchemy import Integer
|
||||||
|
from sqlalchemy import String
|
||||||
|
from sqlalchemy import Text
|
||||||
|
from sqlalchemy import UniqueConstraint
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PostgreSQLUUID
|
||||||
|
from sqlmodel import Field
|
||||||
|
from sqlmodel import Relationship
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class PersonRole(StrEnum):
|
||||||
|
AUTHOR = "author"
|
||||||
|
RECIPIENT = "recipient"
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(StrEnum):
|
||||||
|
QUEUED = "queued"
|
||||||
|
PROCESSING = "processing"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
PARTIAL_SUCCESS = "partial_success"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class JobSourceStatus(StrEnum):
|
||||||
|
PENDING = "pending"
|
||||||
|
TRANSCRIBED = "transcribed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class Person(SQLModel, table=True):
|
||||||
|
__tablename__ = "person"
|
||||||
|
__table_args__ = (Index("idx_person_full_name", "full_name"),)
|
||||||
|
|
||||||
|
id: UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
PostgreSQLUUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=text("gen_random_uuid()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
full_name: str = Field(sa_column=Column(Text, nullable=False))
|
||||||
|
display_name: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
maiden_name: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
birth_date: date | None = Field(default=None, sa_column=Column(Date))
|
||||||
|
birth_date_raw: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
birth_place: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
death_date: date | None = Field(default=None, sa_column=Column(Date))
|
||||||
|
death_date_raw: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
death_place: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
biography: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
portrait_path: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
metadata_: JsonValue | None = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
sa_column=Column(
|
||||||
|
"metadata",
|
||||||
|
JSONB,
|
||||||
|
server_default=text("'{}'::jsonb"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("now()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
updated_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("now()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
document_people: list["DocumentPerson"] = Relationship(
|
||||||
|
back_populates="person",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Document(SQLModel, table=True):
|
||||||
|
__tablename__ = "document"
|
||||||
|
__table_args__ = (Index("idx_document_date", "document_date"),)
|
||||||
|
|
||||||
|
id: UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
PostgreSQLUUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=text("gen_random_uuid()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
name: str = Field(sa_column=Column(Text, nullable=False))
|
||||||
|
document_type: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
document_date: date | None = Field(default=None, sa_column=Column(Date))
|
||||||
|
document_date_raw: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
location_created: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
notes: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
archive_identifier: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("now()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
updated_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("now()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
document_people: list["DocumentPerson"] = Relationship(
|
||||||
|
back_populates="document",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
|
||||||
|
)
|
||||||
|
jobs: list["Job"] = Relationship(
|
||||||
|
back_populates="document",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
|
||||||
|
)
|
||||||
|
sources: list["Source"] = Relationship(
|
||||||
|
back_populates="document",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPerson(SQLModel, table=True):
|
||||||
|
__tablename__ = "document_person"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"document_id",
|
||||||
|
"person_id",
|
||||||
|
"role",
|
||||||
|
name="unique_document_person_role",
|
||||||
|
),
|
||||||
|
Index("idx_document_person_doc", "document_id"),
|
||||||
|
Index("idx_document_person_per", "person_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
PostgreSQLUUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=text("gen_random_uuid()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
document_id: UUID = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
PostgreSQLUUID(as_uuid=True),
|
||||||
|
ForeignKey("document.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
person_id: UUID = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
PostgreSQLUUID(as_uuid=True),
|
||||||
|
ForeignKey("person.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
role: PersonRole = Field(sa_column=Column(String(20), nullable=False))
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("now()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
document: Document | None = Relationship(
|
||||||
|
back_populates="document_people",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
person: Person | None = Relationship(
|
||||||
|
back_populates="document_people",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Job(SQLModel, table=True):
|
||||||
|
__tablename__ = "job"
|
||||||
|
__table_args__ = (Index("idx_job_document", "document_id"),)
|
||||||
|
|
||||||
|
id: UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
PostgreSQLUUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=text("gen_random_uuid()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
document_id: UUID = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
PostgreSQLUUID(as_uuid=True),
|
||||||
|
ForeignKey("document.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
status: JobStatus = Field(
|
||||||
|
default=JobStatus.QUEUED,
|
||||||
|
sa_column=Column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("'queued'"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
retry_count: int = Field(
|
||||||
|
default=0,
|
||||||
|
sa_column=Column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("0"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
provider: str = Field(sa_column=Column(Text, nullable=False))
|
||||||
|
model: str = Field(sa_column=Column(Text, nullable=False))
|
||||||
|
prompt_name: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
date_created: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("now()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
date_updated: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("now()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
document: Document | None = Relationship(
|
||||||
|
back_populates="jobs",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
job_sources: list["JobSource"] = Relationship(
|
||||||
|
back_populates="job",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Source(SQLModel, table=True):
|
||||||
|
__tablename__ = "source"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_source_document", "document_id"),
|
||||||
|
Index("idx_source_page_order", "document_id", "page_number"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
PostgreSQLUUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=text("gen_random_uuid()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
document_id: UUID = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
PostgreSQLUUID(as_uuid=True),
|
||||||
|
ForeignKey("document.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
page_number: int = Field(
|
||||||
|
default=1,
|
||||||
|
sa_column=Column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("1"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
upload_name: str = Field(sa_column=Column(Text, nullable=False))
|
||||||
|
filename: str = Field(sa_column=Column(Text, nullable=False))
|
||||||
|
file_path: str = Field(sa_column=Column(Text, nullable=False))
|
||||||
|
raw_transcription: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
revised_text: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
date_uploaded: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("now()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
date_revised: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
document: Document | None = Relationship(
|
||||||
|
back_populates="sources",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
job_sources: list["JobSource"] = Relationship(
|
||||||
|
back_populates="source",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JobSource(SQLModel, table=True):
|
||||||
|
__tablename__ = "job_source"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("job_id", "source_id", name="unique_job_source"),
|
||||||
|
Index("idx_job_source_job", "job_id"),
|
||||||
|
Index("idx_job_source_source", "source_id"),
|
||||||
|
Index(
|
||||||
|
"idx_job_source_ai_metadata",
|
||||||
|
"ai_metadata",
|
||||||
|
postgresql_using="gin",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
PostgreSQLUUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
server_default=text("gen_random_uuid()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
job_id: UUID = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
PostgreSQLUUID(as_uuid=True),
|
||||||
|
ForeignKey("job.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
source_id: UUID = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
PostgreSQLUUID(as_uuid=True),
|
||||||
|
ForeignKey("source.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
status: JobSourceStatus = Field(
|
||||||
|
default=JobSourceStatus.PENDING,
|
||||||
|
sa_column=Column(
|
||||||
|
String(50),
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("'pending'"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
raw_transcription: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
ai_metadata: JsonValue | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(JSONB),
|
||||||
|
)
|
||||||
|
raw_api_response: JsonValue | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(JSONB),
|
||||||
|
)
|
||||||
|
error_detail: str | None = Field(default=None, sa_column=Column(Text))
|
||||||
|
executed_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=text("now()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
job: Job | None = Relationship(
|
||||||
|
back_populates="job_sources",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
source: Source | None = Relationship(
|
||||||
|
back_populates="job_sources",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The enum annotations validate application values while the mapped columns retain the `VARCHAR` types specified by the DDL. PostgreSQL owns generated UUIDs and timestamps through `server_default`; call `session.refresh(instance)` after a flush or commit when those generated values are needed immediately.
|
||||||
|
|
||||||
|
`ai_metadata`, `raw_api_response`, and `metadata_` accept any JSON value supported by `JSONB`. Validate provider-specific payload structure before assigning it to these fields, while preserving the complete raw response in `raw_api_response`.
|
||||||
|
|
||||||
|
Relationships use `lazy="raise"` to prevent implicit database I/O in async code. Queries must explicitly load relationships they need, for example with `selectinload()`.
|
||||||
|
|
||||||
|
The schema's behavioral invariants are enforced outside the table shape where appropriate:
|
||||||
|
|
||||||
|
- `PersonRole`, `JobStatus`, and `JobSourceStatus` define the exact values listed by the schema.
|
||||||
|
- `unique_document_person_role` enforces role uniqueness for `(document_id, person_id, role)`.
|
||||||
|
- Services order document sources by `Source.document_id` and `Source.page_number`.
|
||||||
|
- Services derive aggregate `Job.status` from related `JobSource.status` values.
|
||||||
|
- Services preserve `JobSource.raw_transcription` and `JobSource.raw_api_response` as point-in-time outputs while updating the active text on `Source`.
|
||||||
@@ -39,6 +39,10 @@ dev = [
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "--strict-markers -q"
|
addopts = "--strict-markers -q"
|
||||||
|
asyncio_mode = "strict"
|
||||||
|
filterwarnings = [
|
||||||
|
"error:coroutine .* was never awaited:RuntimeWarning",
|
||||||
|
]
|
||||||
markers = [
|
markers = [
|
||||||
"unit: pure logic tests with no external dependencies",
|
"unit: pure logic tests with no external dependencies",
|
||||||
"integration: tests that touch framework or database contracts",
|
"integration: tests that touch framework or database contracts",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -2,42 +2,57 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from contextlib import AsyncExitStack
|
from contextlib import AsyncExitStack
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from .api.errors import register_error_handlers
|
from .api.errors import register_error_handlers
|
||||||
from .api.health import router as health_router
|
from .api.health import router as health_router
|
||||||
|
from .config import Settings
|
||||||
from .config import configure_logging
|
from .config import configure_logging
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .db import create_all
|
from .db import create_all
|
||||||
from .db import dispose_database_runtime
|
|
||||||
from .db import initialize_database_runtime
|
from .db import initialize_database_runtime
|
||||||
|
from .db.engine import get_database_url
|
||||||
|
from .db.engine import resolve_engine
|
||||||
|
from .db.session import dispose_session_factory
|
||||||
from .services import ServiceBundle
|
from .services import ServiceBundle
|
||||||
|
from .services.jobs import JobService
|
||||||
from .ui import register_pages
|
from .ui import register_pages
|
||||||
from .worker import worker_consumer_lifespan
|
from .worker import worker_consumer_lifespan
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _lifespan(app: FastAPI):
|
async def _lifespan(app: FastAPI):
|
||||||
configure_logging()
|
settings = getattr(app.state, "settings", None) or get_settings()
|
||||||
|
configure_logging(settings)
|
||||||
settings = get_settings()
|
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.services = ServiceBundle()
|
app.state.services = ServiceBundle()
|
||||||
app.state.runtime = initialize_database_runtime(settings=settings)
|
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||||
|
|
||||||
if settings.should_bootstrap_schema:
|
if settings.should_bootstrap_schema:
|
||||||
await create_all(engine=app.state.runtime.engine)
|
await create_all(engine=resolve_engine(settings=settings))
|
||||||
|
|
||||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
await _recover_stale_processing_jobs(app)
|
||||||
|
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
stack.push_async_callback(dispose_database_runtime)
|
stack.push_async_callback(
|
||||||
|
dispose_session_factory,
|
||||||
|
database_url=get_database_url(settings),
|
||||||
|
)
|
||||||
stop_event, worker_notifier = await stack.enter_async_context(
|
stop_event, worker_notifier = await stack.enter_async_context(
|
||||||
worker_consumer_lifespan(
|
worker_consumer_lifespan(
|
||||||
session_factory=app.state.runtime.session_factory,
|
session_factory=app.state.runtime.session_factory,
|
||||||
@@ -49,9 +64,30 @@ async def _lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
async def _recover_stale_processing_jobs(app: FastAPI) -> None:
|
||||||
|
"""Re-queue stale processing jobs at startup.
|
||||||
|
|
||||||
|
Any job left in PROCESSING longer than the configured provider timeout is
|
||||||
|
assumed orphaned and moved back to QUEUED before the worker starts.
|
||||||
|
"""
|
||||||
|
settings = app.state.settings
|
||||||
|
stale_before = datetime.now(UTC) - timedelta(seconds=settings.worker_provider_timeout_seconds)
|
||||||
|
job_service = JobService(session_factory=app.state.runtime.session_factory)
|
||||||
|
recovered = await job_service.requeue_stale_processing_jobs(stale_before=stale_before)
|
||||||
|
if recovered > 0:
|
||||||
|
logger.warning("Recovered %s stale processing job(s) at startup", recovered)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
"""Create and configure the FastAPI application."""
|
"""Create and configure the FastAPI application."""
|
||||||
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
||||||
|
active_settings = settings or get_settings()
|
||||||
|
app.state.settings = active_settings
|
||||||
|
app.mount(
|
||||||
|
"/uploads",
|
||||||
|
StaticFiles(directory=active_settings.upload_dir, check_dir=False),
|
||||||
|
name="uploads",
|
||||||
|
)
|
||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
@app.get("/", include_in_schema=False)
|
||||||
async def root_redirect() -> RedirectResponse:
|
async def root_redirect() -> RedirectResponse:
|
||||||
@@ -61,6 +97,10 @@ def create_app() -> FastAPI:
|
|||||||
async def ui_redirect() -> RedirectResponse:
|
async def ui_redirect() -> RedirectResponse:
|
||||||
return RedirectResponse(url="/ui/upload", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
return RedirectResponse(url="/ui/upload", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
register_error_handlers(app)
|
register_error_handlers(app)
|
||||||
register_pages(app)
|
register_pages(app)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker
|
|||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from transcription.db.runtime import DatabaseRuntime
|
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 WorkerNotifier
|
||||||
from transcription.worker import resolve_worker_notifier
|
from transcription.worker import resolve_worker_notifier
|
||||||
|
|
||||||
|
|||||||
+57
-14
@@ -6,11 +6,17 @@ are resolved by the provider adapters, not here.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging.config
|
import logging.config
|
||||||
from contextvars import ContextVar
|
from collections.abc import Sequence
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
from functools import cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
from typing import Any
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic import SecretStr
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from pydantic_settings import SettingsConfigDict
|
from pydantic_settings import SettingsConfigDict
|
||||||
|
|
||||||
@@ -21,13 +27,42 @@ class Provider(StrEnum):
|
|||||||
OPENROUTER = "openrouter"
|
OPENROUTER = "openrouter"
|
||||||
|
|
||||||
|
|
||||||
|
class SqliteSettings(BaseModel):
|
||||||
|
driver: Literal["sqlite"] = "sqlite"
|
||||||
|
path: str = "app.db"
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresSettings(BaseModel):
|
||||||
|
driver: Literal["postgres"] = "postgres"
|
||||||
|
host: str
|
||||||
|
port: int = 5432
|
||||||
|
database: str
|
||||||
|
user: str
|
||||||
|
password: SecretStr
|
||||||
|
|
||||||
|
|
||||||
|
DatabaseSettings = Annotated[
|
||||||
|
SqliteSettings | PostgresSettings,
|
||||||
|
Field(discriminator="driver"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
cli_implicit_flags=True,
|
||||||
|
cli_kebab_case=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 ---
|
# --- AI provider ---
|
||||||
provider: Provider = Provider.OPENROUTER
|
provider: Provider = Provider.OPENROUTER
|
||||||
openrouter_api_key: str
|
openrouter_api_key: str
|
||||||
@@ -39,8 +74,8 @@ class Settings(BaseSettings):
|
|||||||
environment: Literal["development", "test", "production"] = "development"
|
environment: Literal["development", "test", "production"] = "development"
|
||||||
|
|
||||||
# --- persistence ---
|
# --- persistence ---
|
||||||
database_url: str = "sqlite:///./transcription.db"
|
database: DatabaseSettings = Field(default_factory=SqliteSettings)
|
||||||
bootstrap_schema_on_startup: bool | None = None
|
bootstrap_schema_on_startup: bool = False
|
||||||
sqlite_check_same_thread: bool = False
|
sqlite_check_same_thread: bool = False
|
||||||
|
|
||||||
# --- filesystem paths ---
|
# --- filesystem paths ---
|
||||||
@@ -50,27 +85,32 @@ class Settings(BaseSettings):
|
|||||||
# --- worker reliability ---
|
# --- worker reliability ---
|
||||||
worker_max_retries: int = 0
|
worker_max_retries: int = 0
|
||||||
worker_retry_backoff_seconds: float = 0.0
|
worker_retry_backoff_seconds: float = 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)
|
||||||
|
worker_fail_on_finish_reason_length: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def should_bootstrap_schema(self) -> bool:
|
def should_bootstrap_schema(self) -> bool:
|
||||||
"""Return whether startup should auto-create schema for this environment."""
|
"""Return whether startup should auto-create schema for this environment."""
|
||||||
if self.bootstrap_schema_on_startup is not None:
|
if "bootstrap_schema_on_startup" in self.model_fields_set:
|
||||||
return self.bootstrap_schema_on_startup
|
return self.bootstrap_schema_on_startup
|
||||||
return self.environment in {"development", "test"}
|
return self.environment in {"development", "test"}
|
||||||
|
|
||||||
|
|
||||||
_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:
|
def parse_cli_settings(args: Sequence[str] | None = None) -> Settings:
|
||||||
settings = _settings.get()
|
"""Load settings with CLI arguments at the executable boundary."""
|
||||||
if settings is None:
|
cli_args = True if args is None else list(args)
|
||||||
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
return Settings(_cli_parse_args=cli_args) # pyright: ignore[reportCallIssue]
|
||||||
_settings.set(settings)
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
LOGGING_CONFIG: dict[str, object] = {
|
LOGGING_CONFIG: dict[str, Any] = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
"formatters": {
|
"formatters": {
|
||||||
@@ -100,7 +140,10 @@ LOGGING_CONFIG: dict[str, object] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging(settings: Settings | None = None) -> None:
|
||||||
"""Configure root logging once at startup."""
|
"""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")
|
logger.debug("Logging configured")
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
from .operations import create_all
|
from .operations import create_all
|
||||||
from .runtime import dispose_database_runtime
|
from .runtime import dispose_database_runtime
|
||||||
from .runtime import get_session
|
|
||||||
from .runtime import initialize_database_runtime
|
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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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,110 @@
|
|||||||
|
"""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")
|
||||||
@@ -10,36 +10,36 @@ from sqlmodel import SQLModel
|
|||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from ..models import Job
|
from .engine import resolve_engine
|
||||||
from ..models import JobStatus
|
from .models import Job
|
||||||
from .runtime import get_engine
|
from .models import JobStatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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.db import models as _models # noqa: F401
|
||||||
|
|
||||||
|
active_engine = engine or resolve_engine()
|
||||||
|
async with active_engine.begin() as connection:
|
||||||
|
await connection.run_sync(SQLModel.metadata.create_all)
|
||||||
|
await connection.run_sync(_ensure_sqlite_compat_columns)
|
||||||
|
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
|
||||||
|
|
||||||
|
|
||||||
async def get_next_queued_job(*, session: AsyncSession) -> Job | None:
|
async def get_next_queued_job(*, session: AsyncSession) -> Job | None:
|
||||||
"""Get the next queued job, if any."""
|
"""Get the next queued job, if any."""
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(Job)
|
select(Job)
|
||||||
.where(Job.status == JobStatus.QUEUED)
|
.where(Job.status == JobStatus.QUEUED)
|
||||||
.order_by(Job.created_at) # pyright: ignore[reportArgumentType]
|
.order_by(Job.date_created) # pyright: ignore[reportArgumentType]
|
||||||
.limit(1)
|
.limit(1)
|
||||||
) # fmt: skip
|
) # fmt: skip
|
||||||
return result.first()
|
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:
|
def _ensure_sqlite_compat_columns(connection: Connection) -> None:
|
||||||
"""Apply lightweight dev/test SQLite compatibility column patches.
|
"""Apply lightweight dev/test SQLite compatibility column patches.
|
||||||
|
|
||||||
@@ -51,16 +51,25 @@ def _ensure_sqlite_compat_columns(connection: Connection) -> None:
|
|||||||
|
|
||||||
inspector = inspect(connection)
|
inspector = inspect(connection)
|
||||||
table_names = set(inspector.get_table_names())
|
table_names = set(inspector.get_table_names())
|
||||||
if "job" not in table_names:
|
|
||||||
return
|
|
||||||
|
|
||||||
columns = {column["name"] for column in inspector.get_columns("job")}
|
if "job" in table_names:
|
||||||
if "retry_count" not in columns:
|
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"))
|
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")
|
logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0")
|
||||||
|
|
||||||
if "transcript" in table_names:
|
if "revision" in table_names:
|
||||||
transcript_columns = {column["name"] for column in inspector.get_columns("transcript")}
|
revision_columns = {column["name"] for column in inspector.get_columns("revision")}
|
||||||
if "model" not in transcript_columns:
|
if "source_id" in revision_columns:
|
||||||
connection.execute(text("ALTER TABLE transcript ADD COLUMN model VARCHAR"))
|
has_unique_source = False
|
||||||
logger.warning("Applied SQLite compatibility schema patch table=transcript column=model")
|
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,16 @@
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncGenerator
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from sqlmodel.pool import StaticPool
|
|
||||||
|
|
||||||
from ..config import Settings
|
from ..config import Settings
|
||||||
from ..config import get_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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -37,33 +35,6 @@ async def dispose_database_runtime() -> None:
|
|||||||
_runtime.set(None)
|
_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()
|
|
||||||
|
|
||||||
|
|
||||||
def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime:
|
def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime:
|
||||||
"""Initialize lifespan-owned async DB resources once per process."""
|
"""Initialize lifespan-owned async DB resources once per process."""
|
||||||
runtime = _runtime.get()
|
runtime = _runtime.get()
|
||||||
@@ -71,33 +42,10 @@ def initialize_database_runtime(*, settings: Settings | None = None) -> Database
|
|||||||
return runtime
|
return runtime
|
||||||
|
|
||||||
active_settings = settings or get_settings()
|
active_settings = settings or get_settings()
|
||||||
engine = _build_engine(active_settings)
|
database_url = get_database_url(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 = DatabaseRuntime(engine=engine, session_factory=session_factory)
|
||||||
_runtime.set(runtime)
|
_runtime.set(runtime)
|
||||||
logger.debug("Initialized async database runtime for database_url=%s", engine.url)
|
logger.debug("Initialized async database runtime for database_url=%s", engine.url)
|
||||||
return runtime
|
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,99 @@
|
|||||||
|
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)
|
||||||
|
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,101 +0,0 @@
|
|||||||
"""SQLModel domain models for the transcription system.
|
|
||||||
|
|
||||||
Core models capture the MVP lifecycle:
|
|
||||||
Document (1) -> (many) Job
|
|
||||||
Job (1) -> (1) Transcript
|
|
||||||
Job (1) -> (many) TranscriptRevision
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 uploaded document image."""
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
filename: str
|
|
||||||
file_path: str
|
|
||||||
uploaded_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
|
|
||||||
# --- relationships ---
|
|
||||||
jobs: list["Job"] = Relationship(back_populates="document")
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
|
|
||||||
# --- relationships ---
|
|
||||||
document: Document = Relationship(back_populates="jobs")
|
|
||||||
transcript: Optional["Transcript"] = Relationship(back_populates="job")
|
|
||||||
transcript_revisions: list["TranscriptRevision"] = Relationship(back_populates="job")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def filename(self) -> str:
|
|
||||||
"""Return the filename of the associated document."""
|
|
||||||
return self.document.filename if self.document else "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
class Transcript(SQLModel, table=True):
|
|
||||||
"""The output of a transcription job."""
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
job_id: UUID = Field(foreign_key="job.id", unique=True)
|
|
||||||
"""ID for the associated job. There's a 1-1 relationship bewteen transcripts and jobs."""
|
|
||||||
provider: str
|
|
||||||
"""Name of the transcription provider used to generate this transcript."""
|
|
||||||
prompt_name: str
|
|
||||||
"""Name of the prompt used to generate this transcript."""
|
|
||||||
model: str | None = None
|
|
||||||
"""Provider model that generated the original AI 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."""
|
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
|
|
||||||
# --- relationships ---
|
|
||||||
job: Job = Relationship(back_populates="transcript")
|
|
||||||
|
|
||||||
|
|
||||||
class TranscriptRevision(SQLModel, table=True):
|
|
||||||
"""Version history entries for a transcription job."""
|
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint("job_id", "version_number", name="uq_transcript_revision_job_version"),)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
job_id: UUID = Field(foreign_key="job.id", index=True)
|
|
||||||
version_number: int = Field(ge=1)
|
|
||||||
provider: str
|
|
||||||
prompt_name: str
|
|
||||||
model: str | None = None
|
|
||||||
source: str = Field(default="ai")
|
|
||||||
text: str | None = None
|
|
||||||
error_detail: str | None = None
|
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
|
|
||||||
# --- relationships ---
|
|
||||||
job: Job = Relationship(back_populates="transcript_revisions")
|
|
||||||
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from ..models import Transcript
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderError(RuntimeError):
|
class ProviderError(RuntimeError):
|
||||||
@@ -27,16 +24,10 @@ class TranscriptionResult:
|
|||||||
provider: str
|
provider: str
|
||||||
prompt_name: str
|
prompt_name: str
|
||||||
model: str
|
model: str
|
||||||
|
finish_reason: str | None = None
|
||||||
def to_transcript(self, job_id: UUID) -> Transcript:
|
usage_input_tokens: int | None = None
|
||||||
"""Convert a TranscriptionResult to a Transcript model instance."""
|
usage_output_tokens: int | None = None
|
||||||
return Transcript(
|
usage_total_tokens: int | None = None
|
||||||
job_id=job_id,
|
|
||||||
provider=self.provider,
|
|
||||||
prompt_name=self.prompt_name,
|
|
||||||
model=self.model,
|
|
||||||
text=self.text,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionProvider(Protocol):
|
class TranscriptionProvider(Protocol):
|
||||||
|
|||||||
@@ -64,8 +64,19 @@ class OpenRouterTranscriptionProvider:
|
|||||||
|
|
||||||
text = self._extract_text(response)
|
text = self._extract_text(response)
|
||||||
model = self._get_optional_attr(response, "model") or self.model
|
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)
|
||||||
logger.info("OpenRouter transcription completed using model=%s", model)
|
logger.info("OpenRouter transcription completed using model=%s", model)
|
||||||
return TranscriptionResult(text=text, provider="openrouter", prompt_name="", model=model)
|
return TranscriptionResult(
|
||||||
|
text=text,
|
||||||
|
provider="openrouter",
|
||||||
|
prompt_name="",
|
||||||
|
model=model,
|
||||||
|
finish_reason=finish_reason,
|
||||||
|
usage_input_tokens=usage_input_tokens,
|
||||||
|
usage_output_tokens=usage_output_tokens,
|
||||||
|
usage_total_tokens=usage_total_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
def _build_request(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> OpenRouterRequest:
|
def _build_request(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> OpenRouterRequest:
|
||||||
image_b64 = base64.b64encode(image_bytes).decode("ascii")
|
image_b64 = base64.b64encode(image_bytes).decode("ascii")
|
||||||
@@ -104,6 +115,34 @@ class OpenRouterTranscriptionProvider:
|
|||||||
raise ProviderResponseError("OpenRouter response contained no transcription text")
|
raise ProviderResponseError("OpenRouter response contained no transcription text")
|
||||||
return 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: Any) -> str:
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
return content.strip()
|
return content.strip()
|
||||||
@@ -127,3 +166,9 @@ class OpenRouterTranscriptionProvider:
|
|||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
return obj.get(key)
|
return obj.get(key)
|
||||||
return getattr(obj, key, None)
|
return getattr(obj, key, None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_int(value: Any) -> int | None:
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
|
|
||||||
from ..config import Settings
|
from ..config import Settings
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..db.runtime import get_session_factory
|
from ..db.session import resolve_session_factory
|
||||||
|
from ..db.session import session_scope
|
||||||
|
|
||||||
|
|
||||||
class ServiceBase(ABC):
|
class ServiceBase(ABC):
|
||||||
@@ -24,19 +25,17 @@ class ServiceBase(ABC):
|
|||||||
queue: asyncio.Queue | None = None,
|
queue: asyncio.Queue | None = None,
|
||||||
):
|
):
|
||||||
self.settings = get_settings()
|
self.settings = get_settings()
|
||||||
self.session_factory = session_factory or get_session_factory()
|
self.session_factory = session_factory or resolve_session_factory()
|
||||||
self.queue = queue or asyncio.Queue()
|
self.queue = queue or asyncio.Queue()
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _session_scope(self, session: AsyncSession | None = None):
|
async def _session_scope(self, session: AsyncSession | None = None):
|
||||||
"""Provide a transactional scope around a series of operations."""
|
"""Provide a transactional scope around a series of operations."""
|
||||||
if session is not None:
|
async with session_scope(
|
||||||
# Reuse the provided session if one is passed in
|
session_factory=self.session_factory,
|
||||||
yield session
|
session=session,
|
||||||
else:
|
) as active_session:
|
||||||
# Otherwise, create a new session for this scope
|
yield active_session
|
||||||
async with self.session_factory() as new_session:
|
|
||||||
yield new_session
|
|
||||||
|
|
||||||
async def _finalize(
|
async def _finalize(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from sqlalchemy.orm import selectinload
|
|||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ..db.models import Document
|
||||||
from ..errors import AppError
|
from ..errors import AppError
|
||||||
from ..errors import ErrorCategory
|
from ..errors import ErrorCategory
|
||||||
from ..models import Document
|
|
||||||
from .base import ServiceBase
|
from .base import ServiceBase
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -21,8 +21,8 @@ class DocumentError(AppError):
|
|||||||
"""Raised when document operations fail."""
|
"""Raised when document operations fail."""
|
||||||
|
|
||||||
|
|
||||||
class MissingImageError(DocumentError):
|
class MissingSourceError(DocumentError):
|
||||||
"""Raised when a required image is missing."""
|
"""Raised when a document has no associated sources."""
|
||||||
|
|
||||||
|
|
||||||
class UploadError(DocumentError):
|
class UploadError(DocumentError):
|
||||||
@@ -30,7 +30,7 @@ class UploadError(DocumentError):
|
|||||||
|
|
||||||
|
|
||||||
class DocumentAlreadyExistsError(DocumentError):
|
class DocumentAlreadyExistsError(DocumentError):
|
||||||
"""Raised when a document with the same filename already exists in the database."""
|
"""Raised when a document with the same name already exists in the database."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -72,13 +72,16 @@ class DocumentService(ServiceBase):
|
|||||||
async def read_document(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
|
async def read_document(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
|
||||||
"""Read an existing document from the database.
|
"""Read an existing document from the database.
|
||||||
|
|
||||||
The selectinload option is used to eagerly load related jobs for the document.
|
The selectinload option is used to eagerly load related jobs and sources.
|
||||||
"""
|
"""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
document = await _session.get(
|
document = await _session.get(
|
||||||
Document,
|
Document,
|
||||||
document_id,
|
document_id,
|
||||||
options=(selectinload(Document.jobs),), # pyright: ignore[reportArgumentType]
|
options=(
|
||||||
|
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
|
||||||
|
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if document is None:
|
if document is None:
|
||||||
raise DocumentError(
|
raise DocumentError(
|
||||||
@@ -86,11 +89,11 @@ class DocumentService(ServiceBase):
|
|||||||
category=ErrorCategory.NOT_FOUND,
|
category=ErrorCategory.NOT_FOUND,
|
||||||
suggestion="Re-upload the source document and retry.",
|
suggestion="Re-upload the source document and retry.",
|
||||||
)
|
)
|
||||||
elif not Path(document.file_path).exists():
|
elif not document.sources:
|
||||||
raise MissingImageError(
|
raise MissingSourceError(
|
||||||
f"Document with id {document_id} is missing its image file in {self.settings.upload_dir:!s}",
|
f"Document with id {document_id} has no associated source records",
|
||||||
category=ErrorCategory.NOT_FOUND,
|
category=ErrorCategory.NOT_FOUND,
|
||||||
suggestion="Re-upload the source document and retry.",
|
suggestion="Upload at least one source for this document and retry.",
|
||||||
)
|
)
|
||||||
return document
|
return document
|
||||||
|
|
||||||
@@ -110,13 +113,13 @@ class DocumentService(ServiceBase):
|
|||||||
# Query Operations
|
# Query Operations
|
||||||
|
|
||||||
async def query_documents(
|
async def query_documents(
|
||||||
self, *, filename: str | None = None, session: AsyncSession | None = None
|
self, *, name: str | None = None, session: AsyncSession | None = None
|
||||||
) -> Sequence[Document]:
|
) -> Sequence[Document]:
|
||||||
"""Query documents from the database based on provided filters."""
|
"""Query documents from the database based on provided filters."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = select(Document)
|
query = select(Document)
|
||||||
if filename is not None:
|
if name is not None:
|
||||||
query = query.where(Document.filename == filename)
|
query = query.where(Document.name == name)
|
||||||
result = await _session.exec(query)
|
result = await _session.exec(query)
|
||||||
return result.all()
|
return result.all()
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from sqlalchemy.orm import selectinload
|
|||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from ..models import Job
|
from ..db.models import Job
|
||||||
from ..models import JobStatus
|
from ..db.models import JobStatus
|
||||||
|
from ..db.models import Source
|
||||||
from .base import ServiceBase
|
from .base import ServiceBase
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +36,10 @@ class JobService(ServiceBase):
|
|||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = (
|
query = (
|
||||||
select(Job)
|
select(Job)
|
||||||
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
.options(
|
||||||
|
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
||||||
|
selectinload(Job.sources).selectinload(Source.revision), # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
.where(Job.id == job_id)
|
.where(Job.id == job_id)
|
||||||
.execution_options(populate_existing=True)
|
.execution_options(populate_existing=True)
|
||||||
)
|
)
|
||||||
@@ -68,11 +72,14 @@ class JobService(ServiceBase):
|
|||||||
) -> Sequence[Job]:
|
) -> Sequence[Job]:
|
||||||
"""Query jobs from the database based on provided filters."""
|
"""Query jobs from the database based on provided filters."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = select(Job).options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
query = select(Job).options(
|
||||||
|
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
||||||
|
selectinload(Job.sources), # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
if status is not None:
|
if status is not None:
|
||||||
query = query.where(Job.status == status)
|
query = query.where(Job.status == status)
|
||||||
if filename is not None:
|
if filename is not None:
|
||||||
query = query.where(Job.document.filename == filename)
|
query = query.where(Job.sources.any(Source.filename == filename))
|
||||||
result = await _session.exec(query)
|
result = await _session.exec(query)
|
||||||
return result.all()
|
return result.all()
|
||||||
|
|
||||||
@@ -85,7 +92,10 @@ class JobService(ServiceBase):
|
|||||||
"""List all jobs in the database with eagerly loaded documents."""
|
"""List all jobs in the database with eagerly loaded documents."""
|
||||||
_ = load_docs
|
_ = load_docs
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = select(Job).options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
query = select(Job).options(
|
||||||
|
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
||||||
|
selectinload(Job.sources), # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
result = await _session.exec(query)
|
result = await _session.exec(query)
|
||||||
return result.all()
|
return result.all()
|
||||||
|
|
||||||
@@ -126,7 +136,7 @@ class JobService(ServiceBase):
|
|||||||
job.status = status
|
job.status = status
|
||||||
if retry_count_increment:
|
if retry_count_increment:
|
||||||
job.retry_count += retry_count_increment
|
job.retry_count += retry_count_increment
|
||||||
job.updated_at = datetime.now(UTC)
|
job.date_updated = datetime.now(UTC)
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
return job
|
return job
|
||||||
|
|
||||||
@@ -139,8 +149,36 @@ class JobService(ServiceBase):
|
|||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = (
|
query = (
|
||||||
select(Job)
|
select(Job)
|
||||||
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
.options(
|
||||||
|
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
||||||
|
selectinload(Job.sources).selectinload(Source.revision), # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
.where(Job.status == JobStatus.QUEUED)
|
.where(Job.status == JobStatus.QUEUED)
|
||||||
.order_by(Job.created_at) # pyright: ignore[reportArgumentType]
|
.order_by(Job.date_created) # pyright: ignore[reportArgumentType]
|
||||||
)
|
)
|
||||||
return (await _session.exec(query)).first()
|
return (await _session.exec(query)).first()
|
||||||
|
|
||||||
|
async def requeue_stale_processing_jobs(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
stale_before: datetime,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Move stale processing jobs back to queued state.
|
||||||
|
|
||||||
|
Jobs with ``status=PROCESSING`` and ``date_updated`` older than
|
||||||
|
``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)
|
||||||
|
stale_jobs = (await _session.exec(query)).all()
|
||||||
|
if not stale_jobs:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for job in stale_jobs:
|
||||||
|
job.status = JobStatus.QUEUED
|
||||||
|
job.date_updated = now
|
||||||
|
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=stale_jobs)
|
||||||
|
return len(stale_jobs)
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ from transcription.config import get_settings
|
|||||||
from transcription.errors import AppError
|
from transcription.errors import AppError
|
||||||
from transcription.errors import ErrorCategory
|
from transcription.errors import ErrorCategory
|
||||||
|
|
||||||
from ..models import Document
|
from ..db.models import Document
|
||||||
from ..models import Job
|
from ..db.models import Job
|
||||||
|
from ..db.models import Source
|
||||||
from .documents import UploadJobResult
|
from .documents import UploadJobResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -69,14 +70,24 @@ async def _create_upload_records(
|
|||||||
stored_path: Path,
|
stored_path: Path,
|
||||||
) -> tuple[Document, Job]:
|
) -> tuple[Document, Job]:
|
||||||
document = Document(
|
document = Document(
|
||||||
filename=Path(original_filename).name,
|
name=Path(original_filename).name,
|
||||||
file_path=str(stored_path),
|
|
||||||
)
|
)
|
||||||
session.add(document)
|
session.add(document)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
job = Job(document_id=document.id)
|
job = Job(document_id=document.id)
|
||||||
session.add(job)
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
job_id=job.id,
|
||||||
|
upload_name=Path(original_filename).name,
|
||||||
|
filename=stored_path.name,
|
||||||
|
file_path=str(stored_path),
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(document)
|
await session.refresh(document)
|
||||||
await session.refresh(job)
|
await session.refresh(job)
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
from collections.abc import Sequence
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -15,10 +18,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.config import get_settings
|
from transcription.config import get_settings
|
||||||
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import Revision
|
||||||
|
from transcription.db.models import Source
|
||||||
from transcription.errors import AppError
|
from transcription.errors import AppError
|
||||||
from transcription.errors import ErrorCategory
|
from transcription.errors import ErrorCategory
|
||||||
from transcription.models import Transcript
|
|
||||||
from transcription.models import TranscriptRevision
|
|
||||||
from transcription.providers import ProviderAuthError
|
from transcription.providers import ProviderAuthError
|
||||||
from transcription.providers import ProviderError
|
from transcription.providers import ProviderError
|
||||||
from transcription.providers import ProviderResponseError
|
from transcription.providers import ProviderResponseError
|
||||||
@@ -43,13 +47,11 @@ class TranscriptionError(AppError):
|
|||||||
|
|
||||||
|
|
||||||
class TranscriptionNotFoundError(TranscriptionError):
|
class TranscriptionNotFoundError(TranscriptionError):
|
||||||
"""Raised when a transcription is not found in the database."""
|
"""Raised when a transcription-related resource is not found."""
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionService(ServiceBase):
|
class TranscriptionService(ServiceBase):
|
||||||
"""Service class for managing transcription operations.
|
"""Service class for job transcription output and optional source revisions."""
|
||||||
|
|
||||||
This is the top-level service that composes functionality from the other services."""
|
|
||||||
|
|
||||||
provider: TranscriptionProvider
|
provider: TranscriptionProvider
|
||||||
|
|
||||||
@@ -57,98 +59,51 @@ class TranscriptionService(ServiceBase):
|
|||||||
super().__init__(session_factory=session_factory)
|
super().__init__(session_factory=session_factory)
|
||||||
self.provider = get_transcription_provider(settings=self.settings)
|
self.provider = get_transcription_provider(settings=self.settings)
|
||||||
|
|
||||||
async def create_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> Transcript:
|
async def create_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> Revision:
|
||||||
"""Create a new transcript in the database."""
|
"""Create a new revision in the database."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
_session.add(transcript)
|
_session.add(revision)
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
|
await self._finalize(session=_session, caller_session=session, refresh=(revision,))
|
||||||
return transcript
|
return revision
|
||||||
|
|
||||||
async def create_transcript_revision(
|
async def read_revision(self, revision_id: UUID, *, session: AsyncSession | None = None) -> Revision:
|
||||||
self,
|
"""Read an existing revision from the database."""
|
||||||
transcript_revision: TranscriptRevision,
|
|
||||||
*,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> TranscriptRevision:
|
|
||||||
"""Create a new transcript revision in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
_session.add(transcript_revision)
|
revision = await _session.get(
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(transcript_revision,))
|
Revision,
|
||||||
return transcript_revision
|
revision_id,
|
||||||
|
options=(selectinload(Revision.source),), # pyright: ignore[reportArgumentType]
|
||||||
async def read_transcript(self, transcript_id: UUID, *, session: AsyncSession | None = None) -> Transcript:
|
|
||||||
"""Read an existing transcript from the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
transcript = await _session.get(
|
|
||||||
Transcript,
|
|
||||||
transcript_id,
|
|
||||||
# Makes the full Job model object available in the return Transcript object
|
|
||||||
options=(selectinload(Transcript.job),), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
)
|
||||||
if transcript is None:
|
if revision is None:
|
||||||
raise TranscriptionNotFoundError(
|
raise TranscriptionNotFoundError(
|
||||||
f"Transcript with id {transcript_id} not found",
|
f"Revision with id {revision_id} not found",
|
||||||
category=ErrorCategory.NOT_FOUND,
|
category=ErrorCategory.NOT_FOUND,
|
||||||
suggestion="Verify the transcript id and retry.",
|
suggestion="Verify the revision id and retry.",
|
||||||
)
|
)
|
||||||
return transcript
|
return revision
|
||||||
|
|
||||||
async def read_transcript_revision(
|
async def update_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> Revision:
|
||||||
self,
|
"""Update an existing revision in the database."""
|
||||||
transcript_revision_id: UUID,
|
|
||||||
*,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> TranscriptRevision:
|
|
||||||
"""Read an existing transcript revision from the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
transcript_revision = await _session.get(
|
merged = await _session.merge(revision)
|
||||||
TranscriptRevision,
|
|
||||||
transcript_revision_id,
|
|
||||||
options=(selectinload(TranscriptRevision.job),), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
if transcript_revision is None:
|
|
||||||
raise TranscriptionNotFoundError(
|
|
||||||
f"Transcript revision with id {transcript_revision_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the transcript revision id and retry.",
|
|
||||||
)
|
|
||||||
return transcript_revision
|
|
||||||
|
|
||||||
async def update_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> Transcript:
|
|
||||||
"""Update an existing transcript in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
merged = await _session.merge(transcript)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
async def update_transcript_revision(
|
async def delete_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> None:
|
||||||
self,
|
"""Delete a revision from the database."""
|
||||||
transcript_revision: TranscriptRevision,
|
|
||||||
*,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> TranscriptRevision:
|
|
||||||
"""Update an existing transcript revision in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
merged = await _session.merge(transcript_revision)
|
await _session.delete(revision)
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
|
||||||
return merged
|
|
||||||
|
|
||||||
async def delete_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> None:
|
|
||||||
"""Delete a transcript from the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
await _session.delete(transcript)
|
|
||||||
await self._finalize(session=_session, caller_session=session)
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
async def delete_transcript_revision(
|
# Temporary compatibility methods for callers still using transcript naming.
|
||||||
self,
|
|
||||||
transcript_revision: TranscriptRevision,
|
async def read_transcript(self, transcript_id: UUID, *, session: AsyncSession | None = None) -> Revision:
|
||||||
*,
|
"""Backward-compatible alias for read_revision."""
|
||||||
session: AsyncSession | None = None,
|
return await self.read_revision(transcript_id, session=session)
|
||||||
) -> None:
|
|
||||||
"""Delete a transcript revision from the database."""
|
async def delete_transcript(self, transcript: Revision, *, session: AsyncSession | None = None) -> None:
|
||||||
async with self._session_scope(session) as _session:
|
"""Backward-compatible alias for delete_revision."""
|
||||||
await _session.delete(transcript_revision)
|
await self.delete_revision(transcript, session=session)
|
||||||
await self._finalize(session=_session, caller_session=session)
|
|
||||||
|
|
||||||
async def transcribe_document(
|
async def transcribe_document(
|
||||||
self,
|
self,
|
||||||
@@ -157,7 +112,7 @@ class TranscriptionService(ServiceBase):
|
|||||||
*,
|
*,
|
||||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
prompt_name: str = DEFAULT_PROMPT_FILE,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
):
|
) -> None:
|
||||||
"""Transcribe a local image using the configured prompt and provider."""
|
"""Transcribe a local image using the configured prompt and provider."""
|
||||||
result = await transcribe_document_image(
|
result = await transcribe_document_image(
|
||||||
image_path=image_path,
|
image_path=image_path,
|
||||||
@@ -165,96 +120,118 @@ class TranscriptionService(ServiceBase):
|
|||||||
settings=self.settings,
|
settings=self.settings,
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
)
|
)
|
||||||
await self.create_transcript(transcript=result.to_transcript(job_id=job_id), session=session)
|
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 upsert_transcript_by_job(
|
async def update_job_transcription(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
job_id: UUID,
|
job_id: UUID,
|
||||||
text: str | None,
|
text: str | None,
|
||||||
error_detail: str | None,
|
error_detail: str | None = None,
|
||||||
provider: str | None = None,
|
provider: str | None = None,
|
||||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
|
prompt_name: str = DEFAULT_PROMPT_FILE,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> Transcript:
|
) -> Job:
|
||||||
"""Create or update a transcript for a job id."""
|
"""Persist original transcription output fields on a job."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
transcript = (await _session.exec(select(Transcript).where(Transcript.job_id == job_id))).first()
|
job = await _session.get(Job, job_id)
|
||||||
if transcript is None:
|
if job is None:
|
||||||
transcript = Transcript(
|
raise TranscriptionNotFoundError(
|
||||||
job_id=job_id,
|
f"Job with id {job_id} not found",
|
||||||
provider=provider or self.settings.provider.value,
|
category=ErrorCategory.NOT_FOUND,
|
||||||
prompt_name=prompt_name,
|
suggestion="Verify the job id and retry.",
|
||||||
)
|
)
|
||||||
|
|
||||||
transcript.text = text
|
job.text = text
|
||||||
transcript.error_detail = error_detail
|
job.error_detail = error_detail
|
||||||
if provider is not None:
|
job.provider = provider or job.provider or self.settings.provider.value
|
||||||
transcript.provider = provider
|
job.model = model or job.model or _resolve_transcript_model(provider=self.provider, settings=self.settings)
|
||||||
transcript.prompt_name = prompt_name
|
job.prompt_name = prompt_name or job.prompt_name or DEFAULT_PROMPT_FILE
|
||||||
if model is not None:
|
job.date_updated = datetime.now(UTC)
|
||||||
transcript.model = model
|
|
||||||
|
|
||||||
_session.add(transcript)
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
|
return job
|
||||||
return transcript
|
|
||||||
|
|
||||||
async def list_transcript_revisions_by_job(
|
async def upsert_revision_for_source(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
job_id: UUID,
|
source_id: UUID,
|
||||||
|
text: str,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> list[TranscriptRevision]:
|
) -> Revision:
|
||||||
"""Return transcript revisions for a job ordered by version number."""
|
"""Create or replace the single optional revision for a source."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
revisions = (
|
source = await _session.get(Source, source_id)
|
||||||
await _session.exec(
|
if source is None:
|
||||||
select(TranscriptRevision)
|
raise TranscriptionNotFoundError(
|
||||||
.where(TranscriptRevision.job_id == job_id)
|
f"Source with id {source_id} not found",
|
||||||
.order_by(TranscriptRevision.version_number)
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Verify the source id and retry.",
|
||||||
)
|
)
|
||||||
).all()
|
|
||||||
return list(revisions)
|
|
||||||
|
|
||||||
async def append_transcript_revision(
|
query = select(Revision).where(Revision.source_id == source_id)
|
||||||
self,
|
existing = (await _session.exec(query)).one_or_none()
|
||||||
*,
|
|
||||||
job_id: UUID,
|
|
||||||
text: str | None,
|
|
||||||
error_detail: str | None,
|
|
||||||
provider: str,
|
|
||||||
prompt_name: str,
|
|
||||||
model: str | None,
|
|
||||||
source: str,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> TranscriptRevision:
|
|
||||||
"""Append a new transcript revision and allocate the next version number."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
latest_version = (
|
|
||||||
await _session.exec(
|
|
||||||
select(TranscriptRevision.version_number)
|
|
||||||
.where(TranscriptRevision.job_id == job_id)
|
|
||||||
.order_by(TranscriptRevision.version_number.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
next_version = 1 if latest_version is None else latest_version + 1
|
|
||||||
|
|
||||||
revision = TranscriptRevision(
|
if existing is None:
|
||||||
job_id=job_id,
|
revision = Revision(source_id=source_id, text=text)
|
||||||
version_number=next_version,
|
|
||||||
provider=provider,
|
|
||||||
prompt_name=prompt_name,
|
|
||||||
model=model,
|
|
||||||
source=source,
|
|
||||||
text=text,
|
|
||||||
error_detail=error_detail,
|
|
||||||
)
|
|
||||||
_session.add(revision)
|
_session.add(revision)
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(revision,))
|
await self._finalize(session=_session, caller_session=session, refresh=(revision,))
|
||||||
return 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(
|
async def transcribe_document_image(
|
||||||
image_path: str | Path,
|
image_path: str | Path,
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
|
|
||||||
from ..config import Settings
|
from ..config import Settings
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
|
from ..db.models import Job
|
||||||
|
from ..db.models import JobStatus
|
||||||
|
from ..db.models import Source
|
||||||
from ..errors import AppError
|
from ..errors import AppError
|
||||||
|
from ..errors import ErrorCategory
|
||||||
from ..errors import classify_unexpected_error
|
from ..errors import classify_unexpected_error
|
||||||
from ..errors import format_error_detail
|
from ..errors import format_error_detail
|
||||||
from ..models import Job
|
|
||||||
from ..models import JobStatus
|
|
||||||
from ..providers import TranscriptionResult
|
from ..providers import TranscriptionResult
|
||||||
from . import ServiceBundle
|
from . import ServiceBundle
|
||||||
from .transcription import DEFAULT_PROMPT_FILE
|
from .transcription import DEFAULT_PROMPT_FILE
|
||||||
@@ -28,7 +30,7 @@ async def advance_job(
|
|||||||
settings = settings or get_settings()
|
settings = settings or get_settings()
|
||||||
match job.status:
|
match job.status:
|
||||||
case JobStatus.QUEUED:
|
case JobStatus.QUEUED:
|
||||||
return await process_queued_job(job=job, services=services, session=session)
|
return await process_queued_job(job=job, services=services, settings=settings, session=session)
|
||||||
case JobStatus.FAILED:
|
case JobStatus.FAILED:
|
||||||
if job.retry_count < settings.worker_max_retries:
|
if job.retry_count < settings.worker_max_retries:
|
||||||
return await services.jobs.update_job_state(
|
return await services.jobs.update_job_state(
|
||||||
@@ -48,9 +50,11 @@ async def process_queued_job(
|
|||||||
*,
|
*,
|
||||||
job: Job,
|
job: Job,
|
||||||
services: ServiceBundle,
|
services: ServiceBundle,
|
||||||
|
settings: Settings | None = None,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> Job | None:
|
) -> Job | None:
|
||||||
"""Process one complete transcription attempt for a queued job."""
|
"""Process one complete transcription attempt for a queued job."""
|
||||||
|
runtime_settings = settings or get_settings()
|
||||||
if job.status != JobStatus.QUEUED:
|
if job.status != JobStatus.QUEUED:
|
||||||
logger.warning(f"Job {job.id} is not queued. Current status: {job.status}")
|
logger.warning(f"Job {job.id} is not queued. Current status: {job.status}")
|
||||||
return
|
return
|
||||||
@@ -64,20 +68,62 @@ async def process_queued_job(
|
|||||||
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING, session=session)
|
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING, session=session)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
document = job.document
|
source_job = await services.jobs.read_job(job_id=job.id, session=session)
|
||||||
assert document is not None, (
|
source = _resolve_primary_source(source_job)
|
||||||
f"Job {job.id} has no associated document or the document failed to be loaded by the job service."
|
assert source is not None, f"Job {job.id} has no associated source record."
|
||||||
)
|
started_at = asyncio.get_running_loop().time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await transcribe_document_image(document.file_path)
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_transcription_quality(result=result, settings=runtime_settings)
|
||||||
|
|
||||||
job = await _finalize_transcribed(job=job, services=services, result=result, session=session)
|
job = await _finalize_transcribed(job=job, services=services, result=result, session=session)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Job transcribed operation=worker.process_job job_id=%s document_id=%s provider=%s",
|
"Job transcribed operation=worker.process_job job_id=%s document_id=%s source_id=%s provider=%s",
|
||||||
job.id,
|
job.id,
|
||||||
document.id,
|
job.document_id,
|
||||||
|
source.id,
|
||||||
result.provider,
|
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
|
except Exception as exc: # noqa: BLE001
|
||||||
match exc:
|
match exc:
|
||||||
case AppError() as error:
|
case AppError() as error:
|
||||||
@@ -87,9 +133,10 @@ async def process_queued_job(
|
|||||||
|
|
||||||
job = await _finalize_failed(job=job, services=services, error=error, session=session)
|
job = await _finalize_failed(job=job, services=services, error=error, session=session)
|
||||||
logger.error(
|
logger.error(
|
||||||
"Job failed operation=worker.process_job job_id=%s document_id=%s error_id=%s category=%s",
|
"Job failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
|
||||||
job.id,
|
job.id,
|
||||||
document.id,
|
job.document_id,
|
||||||
|
source.id,
|
||||||
error.error_id,
|
error.error_id,
|
||||||
error.category.value,
|
error.category.value,
|
||||||
)
|
)
|
||||||
@@ -118,27 +165,16 @@ async def _finalize_transcribed(
|
|||||||
result: TranscriptionResult,
|
result: TranscriptionResult,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> Job:
|
) -> Job:
|
||||||
"""Transaction B: transcript + TRANSCRIBED in one commit."""
|
"""Transaction B: job transcription output + TRANSCRIBED in one commit."""
|
||||||
if session is None:
|
if session is None:
|
||||||
async with services.jobs._session_scope() as local_session:
|
async with services.jobs._session_scope() as local_session:
|
||||||
prompt_name = result.prompt_name or DEFAULT_PROMPT_FILE
|
await services.transcriptions.update_job_transcription(
|
||||||
await services.transcriptions.upsert_transcript_by_job(
|
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
text=result.text,
|
text=result.text,
|
||||||
error_detail=None,
|
error_detail=None,
|
||||||
provider=result.provider,
|
provider=result.provider,
|
||||||
prompt_name=prompt_name,
|
|
||||||
model=result.model,
|
model=result.model,
|
||||||
session=local_session,
|
prompt_name=result.prompt_name,
|
||||||
)
|
|
||||||
await services.transcriptions.append_transcript_revision(
|
|
||||||
job_id=job.id,
|
|
||||||
text=result.text,
|
|
||||||
error_detail=None,
|
|
||||||
provider=result.provider,
|
|
||||||
prompt_name=prompt_name,
|
|
||||||
model=result.model,
|
|
||||||
source="ai",
|
|
||||||
session=local_session,
|
session=local_session,
|
||||||
)
|
)
|
||||||
updated_job = await services.jobs.mark_job_status(
|
updated_job = await services.jobs.mark_job_status(
|
||||||
@@ -149,24 +185,13 @@ async def _finalize_transcribed(
|
|||||||
await local_session.commit()
|
await local_session.commit()
|
||||||
return updated_job
|
return updated_job
|
||||||
|
|
||||||
prompt_name = result.prompt_name or DEFAULT_PROMPT_FILE
|
await services.transcriptions.update_job_transcription(
|
||||||
await services.transcriptions.upsert_transcript_by_job(
|
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
text=result.text,
|
text=result.text,
|
||||||
error_detail=None,
|
error_detail=None,
|
||||||
provider=result.provider,
|
provider=result.provider,
|
||||||
prompt_name=prompt_name,
|
|
||||||
model=result.model,
|
model=result.model,
|
||||||
session=session,
|
prompt_name=result.prompt_name,
|
||||||
)
|
|
||||||
await services.transcriptions.append_transcript_revision(
|
|
||||||
job_id=job.id,
|
|
||||||
text=result.text,
|
|
||||||
error_detail=None,
|
|
||||||
provider=result.provider,
|
|
||||||
prompt_name=prompt_name,
|
|
||||||
model=result.model,
|
|
||||||
source="ai",
|
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
updated_job = await services.jobs.mark_job_status(
|
updated_job = await services.jobs.mark_job_status(
|
||||||
@@ -186,27 +211,14 @@ async def _finalize_retry(
|
|||||||
settings: Settings,
|
settings: Settings,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> Job:
|
) -> Job:
|
||||||
"""Transaction C: transcript error + QUEUED + retry increment in one commit."""
|
"""Transaction C: job error detail + QUEUED + retry increment in one commit."""
|
||||||
if session is None:
|
if session is None:
|
||||||
async with services.jobs._session_scope() as local_session:
|
async with services.jobs._session_scope() as local_session:
|
||||||
provider_name = services.transcriptions.settings.provider.value
|
await services.transcriptions.update_job_transcription(
|
||||||
await services.transcriptions.upsert_transcript_by_job(
|
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
text=None,
|
text=None,
|
||||||
error_detail=format_error_detail(error),
|
error_detail=format_error_detail(error),
|
||||||
provider=provider_name,
|
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
prompt_name=DEFAULT_PROMPT_FILE,
|
||||||
model=services.transcriptions.settings.provider_model,
|
|
||||||
session=local_session,
|
|
||||||
)
|
|
||||||
await services.transcriptions.append_transcript_revision(
|
|
||||||
job_id=job.id,
|
|
||||||
text=None,
|
|
||||||
error_detail=format_error_detail(error),
|
|
||||||
provider=provider_name,
|
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
|
||||||
model=services.transcriptions.settings.provider_model,
|
|
||||||
source="ai",
|
|
||||||
session=local_session,
|
session=local_session,
|
||||||
)
|
)
|
||||||
updated_job = await services.jobs.update_job_state(
|
updated_job = await services.jobs.update_job_state(
|
||||||
@@ -217,24 +229,11 @@ async def _finalize_retry(
|
|||||||
)
|
)
|
||||||
await local_session.commit()
|
await local_session.commit()
|
||||||
else:
|
else:
|
||||||
provider_name = services.transcriptions.settings.provider.value
|
await services.transcriptions.update_job_transcription(
|
||||||
await services.transcriptions.upsert_transcript_by_job(
|
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
text=None,
|
text=None,
|
||||||
error_detail=format_error_detail(error),
|
error_detail=format_error_detail(error),
|
||||||
provider=provider_name,
|
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
prompt_name=DEFAULT_PROMPT_FILE,
|
||||||
model=services.transcriptions.settings.provider_model,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
await services.transcriptions.append_transcript_revision(
|
|
||||||
job_id=job.id,
|
|
||||||
text=None,
|
|
||||||
error_detail=format_error_detail(error),
|
|
||||||
provider=provider_name,
|
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
|
||||||
model=services.transcriptions.settings.provider_model,
|
|
||||||
source="ai",
|
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
updated_job = await services.jobs.update_job_state(
|
updated_job = await services.jobs.update_job_state(
|
||||||
@@ -257,27 +256,14 @@ async def _finalize_failed(
|
|||||||
error: AppError,
|
error: AppError,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> Job:
|
) -> Job:
|
||||||
"""Transaction B: transcript error + FAILED in one commit."""
|
"""Transaction B: job error detail + FAILED in one commit."""
|
||||||
if session is None:
|
if session is None:
|
||||||
async with services.jobs._session_scope() as local_session:
|
async with services.jobs._session_scope() as local_session:
|
||||||
provider_name = services.transcriptions.settings.provider.value
|
await services.transcriptions.update_job_transcription(
|
||||||
await services.transcriptions.upsert_transcript_by_job(
|
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
text=None,
|
text=None,
|
||||||
error_detail=format_error_detail(error),
|
error_detail=format_error_detail(error),
|
||||||
provider=provider_name,
|
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
prompt_name=DEFAULT_PROMPT_FILE,
|
||||||
model=services.transcriptions.settings.provider_model,
|
|
||||||
session=local_session,
|
|
||||||
)
|
|
||||||
await services.transcriptions.append_transcript_revision(
|
|
||||||
job_id=job.id,
|
|
||||||
text=None,
|
|
||||||
error_detail=format_error_detail(error),
|
|
||||||
provider=provider_name,
|
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
|
||||||
model=services.transcriptions.settings.provider_model,
|
|
||||||
source="ai",
|
|
||||||
session=local_session,
|
session=local_session,
|
||||||
)
|
)
|
||||||
updated_job = await services.jobs.mark_job_status(
|
updated_job = await services.jobs.mark_job_status(
|
||||||
@@ -288,24 +274,11 @@ async def _finalize_failed(
|
|||||||
await local_session.commit()
|
await local_session.commit()
|
||||||
return updated_job
|
return updated_job
|
||||||
|
|
||||||
provider_name = services.transcriptions.settings.provider.value
|
await services.transcriptions.update_job_transcription(
|
||||||
await services.transcriptions.upsert_transcript_by_job(
|
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
text=None,
|
text=None,
|
||||||
error_detail=format_error_detail(error),
|
error_detail=format_error_detail(error),
|
||||||
provider=provider_name,
|
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
prompt_name=DEFAULT_PROMPT_FILE,
|
||||||
model=services.transcriptions.settings.provider_model,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
await services.transcriptions.append_transcript_revision(
|
|
||||||
job_id=job.id,
|
|
||||||
text=None,
|
|
||||||
error_detail=format_error_detail(error),
|
|
||||||
provider=provider_name,
|
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
|
||||||
model=services.transcriptions.settings.provider_model,
|
|
||||||
source="ai",
|
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
updated_job = await services.jobs.mark_job_status(
|
updated_job = await services.jobs.mark_job_status(
|
||||||
@@ -315,3 +288,58 @@ async def _finalize_failed(
|
|||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return updated_job
|
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:
|
||||||
|
text_chars = len(result.text)
|
||||||
|
text_lines = _line_count(result.text)
|
||||||
|
|
||||||
|
if settings.worker_fail_on_finish_reason_length and (result.finish_reason or "").lower() == "length":
|
||||||
|
raise AppError(
|
||||||
|
"Provider output appears truncated (finish_reason=length)",
|
||||||
|
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||||
|
suggestion=(
|
||||||
|
"Retry the job. If this repeats, use a faster model, reduce input complexity, "
|
||||||
|
"or increase provider output budget."
|
||||||
|
),
|
||||||
|
retriable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.worker_min_transcription_chars > 0 and text_chars < settings.worker_min_transcription_chars:
|
||||||
|
raise AppError(
|
||||||
|
(
|
||||||
|
"Transcription output below configured minimum character threshold "
|
||||||
|
f"({text_chars} < {settings.worker_min_transcription_chars})"
|
||||||
|
),
|
||||||
|
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||||
|
suggestion=(
|
||||||
|
"Retry the job. If this repeats, switch model or raise minimum thresholds based on document type."
|
||||||
|
),
|
||||||
|
retriable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.worker_min_transcription_lines > 0 and text_lines < settings.worker_min_transcription_lines:
|
||||||
|
raise AppError(
|
||||||
|
(
|
||||||
|
"Transcription output below configured minimum line threshold "
|
||||||
|
f"({text_lines} < {settings.worker_min_transcription_lines})"
|
||||||
|
),
|
||||||
|
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||||
|
suggestion=(
|
||||||
|
"Retry the job. If this repeats, switch model or raise minimum thresholds based on document type."
|
||||||
|
),
|
||||||
|
retriable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _line_count(text: str) -> int:
|
||||||
|
stripped = text.strip()
|
||||||
|
if not stripped:
|
||||||
|
return 0
|
||||||
|
return sum(1 for line in stripped.splitlines() if line.strip())
|
||||||
|
|||||||
@@ -5,10 +5,23 @@ from nicegui import ui
|
|||||||
|
|
||||||
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
||||||
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
||||||
|
from transcription.ui.resources import read_css
|
||||||
|
|
||||||
|
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
|
||||||
|
|
||||||
|
|
||||||
|
def _register_global_styles(app: FastAPI) -> None:
|
||||||
|
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.add_css(read_css("theme.css"), shared=True)
|
||||||
|
|
||||||
|
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
|
||||||
|
|
||||||
|
|
||||||
def register_pages(app: FastAPI) -> None:
|
def register_pages(app: FastAPI) -> None:
|
||||||
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
||||||
|
_register_global_styles(app)
|
||||||
register_upload_page()
|
register_upload_page()
|
||||||
register_jobs_page()
|
register_jobs_page()
|
||||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False)
|
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""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.page_content import render_page_content
|
||||||
|
from transcription.ui.components.page_header import render_page_header
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"NAV_ITEMS",
|
||||||
|
"render_app_shell",
|
||||||
|
"render_document_panzoom",
|
||||||
|
"render_navigation_header",
|
||||||
|
"render_page_content",
|
||||||
|
"render_page_header",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Reusable app shell primitives for page-level layout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.ui.resources import read_css
|
||||||
|
|
||||||
|
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||||
|
("Upload", "/upload", "upload_file"),
|
||||||
|
("Jobs", "/jobs", "work_history"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_active_path(*, current_path: str, item_path: str) -> bool:
|
||||||
|
if item_path == "/jobs":
|
||||||
|
return current_path == "/jobs" or current_path.startswith("/jobs/")
|
||||||
|
return current_path == item_path
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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),
|
||||||
|
).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 normalized.rstrip("/") or "/"
|
||||||
|
|
||||||
|
|
||||||
|
def render_app_shell(*, current_path: str | None = None) -> None:
|
||||||
|
"""Render the shared application shell header."""
|
||||||
|
ui.add_css(read_css("components/app_shell.css"))
|
||||||
|
normalized_path = _normalize_path(current_path)
|
||||||
|
|
||||||
|
with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"):
|
||||||
|
with ui.row().classes("app-shell__brand no-wrap"):
|
||||||
|
ui.label("VS").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,211 @@
|
|||||||
|
"""Panzoom-backed document preview component."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.config import get_settings
|
||||||
|
from transcription.db.models import Source
|
||||||
|
|
||||||
|
PANGOZOOM_CDN_URL = "https://unpkg.com/@panzoom/[email protected]/dist/panzoom.min.js"
|
||||||
|
UPLOADS_URL_PREFIX = "/uploads"
|
||||||
|
|
||||||
|
|
||||||
|
def render_document_panzoom(*, source: Source) -> None:
|
||||||
|
"""Render a source preview with pan and zoom interactions."""
|
||||||
|
_register_panzoom_assets()
|
||||||
|
|
||||||
|
host_id = f"document-panzoom-{uuid4().hex}"
|
||||||
|
document_url = _document_url(source)
|
||||||
|
document_kind = _document_kind(source)
|
||||||
|
|
||||||
|
with ui.card().classes("w-full q-pa-md vibe-card"):
|
||||||
|
with ui.row().classes("w-full items-center justify-between no-wrap"):
|
||||||
|
ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
|
||||||
|
ui.label(source.filename).classes("text-caption vibe-text-muted ellipsis").style(
|
||||||
|
"max-width: 60%; text-align: right;"
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
ui.element("div").classes("w-full document-panzoom-host rounded-borders q-mt-md")
|
||||||
|
# .style(f"height: {height};")
|
||||||
|
) as host:
|
||||||
|
host.props(f"id={host_id}")
|
||||||
|
with ui.element("div").classes("document-panzoom-surface"):
|
||||||
|
if document_kind == "pdf":
|
||||||
|
ui.html(
|
||||||
|
f'<iframe class="document-panzoom-iframe" '
|
||||||
|
f'src="{document_url}" title="{source.filename}" '
|
||||||
|
"data-panzoom-target></iframe>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ui.html(
|
||||||
|
f'<img class="document-panzoom-media" '
|
||||||
|
f'src="{document_url}" alt="{source.filename}" '
|
||||||
|
"data-panzoom-target data-panzoom-media />"
|
||||||
|
)
|
||||||
|
|
||||||
|
_attach_panzoom(host_id)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _register_panzoom_assets() -> None:
|
||||||
|
ui.add_head_html(
|
||||||
|
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: var(--theme-surface-raised);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
""",
|
||||||
|
shared=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _document_url(source: Source) -> str:
|
||||||
|
file_path = Path(source.file_path)
|
||||||
|
upload_dir = get_settings().upload_dir
|
||||||
|
|
||||||
|
relative_path: Path
|
||||||
|
try:
|
||||||
|
relative_path = file_path.resolve().relative_to(upload_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
parts = file_path.parts
|
||||||
|
if "uploads" in parts:
|
||||||
|
uploads_index = parts.index("uploads")
|
||||||
|
relative_path = Path(*parts[uploads_index + 1 :])
|
||||||
|
else:
|
||||||
|
relative_path = Path(file_path.name)
|
||||||
|
|
||||||
|
encoded_relative_path = "/".join(quote(part) for part in relative_path.parts)
|
||||||
|
return f"{UPLOADS_URL_PREFIX}/{encoded_relative_path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _document_kind(source: Source) -> str:
|
||||||
|
suffix = Path(source.file_path).suffix.lower()
|
||||||
|
if suffix == ".pdf":
|
||||||
|
return "pdf"
|
||||||
|
return "image"
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_panzoom(host_id: str) -> None:
|
||||||
|
ui.run_javascript(
|
||||||
|
f"""
|
||||||
|
(function() {{
|
||||||
|
if (!window.Panzoom) return;
|
||||||
|
window.__transcriptionPanzoom = window.__transcriptionPanzoom || {{}};
|
||||||
|
const host = document.getElementById({host_id!r});
|
||||||
|
if (!host) return;
|
||||||
|
const target = host.querySelector('[data-panzoom-target]');
|
||||||
|
const media = host.querySelector('[data-panzoom-media]');
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const cleanup = () => {{
|
||||||
|
const existing = window.__transcriptionPanzoom[{host_id!r}];
|
||||||
|
if (existing?.resizeObserver) existing.resizeObserver.disconnect();
|
||||||
|
if (existing?.wheelHandler) host.removeEventListener('wheel', existing.wheelHandler);
|
||||||
|
if (existing?.instance) existing.instance.destroy();
|
||||||
|
}};
|
||||||
|
|
||||||
|
const computeFitScale = () => {{
|
||||||
|
const hostRect = host.getBoundingClientRect();
|
||||||
|
if (hostRect.width <= 0 || hostRect.height <= 0) return null;
|
||||||
|
return 1;
|
||||||
|
}};
|
||||||
|
|
||||||
|
const buildInstance = () => {{
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const fitScale = computeFitScale();
|
||||||
|
if (fitScale === null) return false;
|
||||||
|
|
||||||
|
const minScale = Math.min(fitScale, 0.01);
|
||||||
|
const instance = Panzoom(target, {{
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
startScale: fitScale,
|
||||||
|
minScale: minScale,
|
||||||
|
maxScale: 256,
|
||||||
|
step: 0.2,
|
||||||
|
roundPixels: false,
|
||||||
|
panOnlyWhenZoomed: true,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}});
|
||||||
|
|
||||||
|
const wheelHandler = (event) => instance.zoomWithWheel(event);
|
||||||
|
host.addEventListener('wheel', wheelHandler, {{ passive: false }});
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {{
|
||||||
|
instance.reset({{ animate: false }});
|
||||||
|
}});
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {{
|
||||||
|
const nextFitScale = computeFitScale();
|
||||||
|
if (nextFitScale === null) return;
|
||||||
|
instance.setOptions({{
|
||||||
|
startScale: nextFitScale,
|
||||||
|
minScale: Math.min(nextFitScale, 0.01),
|
||||||
|
}});
|
||||||
|
instance.reset({{ animate: false }});
|
||||||
|
}});
|
||||||
|
resizeObserver.observe(host);
|
||||||
|
|
||||||
|
window.__transcriptionPanzoom[{host_id!r}] = {{
|
||||||
|
instance,
|
||||||
|
wheelHandler,
|
||||||
|
resizeObserver,
|
||||||
|
}};
|
||||||
|
return true;
|
||||||
|
}};
|
||||||
|
|
||||||
|
const initWhenReady = (retries = 15) => {{
|
||||||
|
if (buildInstance()) return;
|
||||||
|
if (retries <= 0) return;
|
||||||
|
requestAnimationFrame(() => initWhenReady(retries - 1));
|
||||||
|
}};
|
||||||
|
|
||||||
|
if (media && media.tagName === 'IMG' && !media.complete) {{
|
||||||
|
media.addEventListener('load', () => initWhenReady(), {{ once: true }});
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
initWhenReady();
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
)
|
||||||
@@ -26,7 +26,7 @@ def show_error(exc: Exception, *, title: str, operation: str) -> None:
|
|||||||
close_button="Dismiss",
|
close_button="Dismiss",
|
||||||
)
|
)
|
||||||
|
|
||||||
with ui.card().classes("bg-red-1 text-red-10 q-mt-md q-pa-md"):
|
with ui.card().classes("vibe-card--error q-mt-md q-pa-md"):
|
||||||
ui.label(title).classes("text-subtitle1")
|
ui.label(title).classes("text-subtitle1")
|
||||||
ui.label(error.message)
|
ui.label(error.message)
|
||||||
ui.label(f"Suggested action: {error.suggestion}").classes("text-weight-medium")
|
ui.label(f"Suggested action: {error.suggestion}").classes("text-weight-medium")
|
||||||
|
|||||||
@@ -2,303 +2,90 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import logging
|
||||||
import mimetypes
|
|
||||||
from collections.abc import Awaitable
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.models import Document
|
from transcription.db.models import Job
|
||||||
from transcription.models import Job
|
from transcription.db.models import Revision
|
||||||
from transcription.models import Transcript
|
from transcription.db.models import Source
|
||||||
from transcription.models import TranscriptRevision
|
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__)
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RevisionDisplayRow:
|
|
||||||
id: str
|
|
||||||
created: str
|
|
||||||
version: str
|
|
||||||
text: str
|
|
||||||
error_detail: str | None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_row_id(args: object) -> 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):
|
|
||||||
for value in args:
|
|
||||||
if isinstance(value, dict):
|
|
||||||
row_id = value.get("id")
|
|
||||||
if row_id is not None:
|
|
||||||
return str(row_id)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _status_chip_classes(status: str) -> str:
|
def _status_chip_classes(status: str) -> str:
|
||||||
if status == "queued":
|
if status == "queued":
|
||||||
return "bg-blue-1 text-blue-10"
|
return "vibe-status--queued"
|
||||||
if status == "processing":
|
if status == "processing":
|
||||||
return "bg-amber-1 text-amber-10"
|
return "vibe-status--processing"
|
||||||
if status == "transcribed":
|
if status == "transcribed":
|
||||||
return "bg-green-1 text-green-10"
|
return "vibe-status--transcribed"
|
||||||
if status == "failed":
|
if status == "failed":
|
||||||
return "bg-red-1 text-red-10"
|
return "vibe-status--failed"
|
||||||
return "bg-grey-2 text-grey-9"
|
return "vibe-status--default"
|
||||||
|
|
||||||
|
|
||||||
def _metadata_row(label: str, value: str) -> None:
|
def _metadata_row(label: str, value: str) -> None:
|
||||||
with ui.row().classes("w-full items-start justify-between no-wrap q-gutter-x-md"):
|
with ui.row().classes("w-full items-start justify-between q-gutter-x-md"):
|
||||||
ui.label(label).classes("text-caption text-grey-7 text-uppercase")
|
ui.label(label).classes("text-caption vibe-text-muted text-uppercase w-28")
|
||||||
ui.label(value).classes("text-body2 text-right")
|
ui.label(value).classes("text-body2 text-right break-all")
|
||||||
|
|
||||||
|
|
||||||
def _render_document_section(document: Document) -> None:
|
def _render_source_section(source: Source) -> None:
|
||||||
with ui.card().classes("w-full bg-grey-1 q-pa-md"):
|
with ui.card().classes("w-full q-pa-md vibe-card"):
|
||||||
ui.label("Document").classes("text-subtitle1 text-weight-medium")
|
ui.label("Source").classes("text-subtitle1 text-weight-medium")
|
||||||
ui.separator().classes("q-my-sm")
|
ui.separator().classes("q-my-sm")
|
||||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||||
_metadata_row("Filename", document.filename)
|
_metadata_row("Upload name", source.upload_name)
|
||||||
_metadata_row("File path", document.file_path)
|
_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 _document_data_url(document: Document) -> tuple[str | None, str | None]:
|
def _render_revision_section(revision: Revision | None) -> None:
|
||||||
path = Path(document.file_path)
|
with ui.card().classes("w-full q-pa-md vibe-card"):
|
||||||
if not path.exists() or not path.is_file():
|
ui.label("Revision").classes("text-subtitle1 text-weight-medium")
|
||||||
return None, "Document preview unavailable: file not found"
|
|
||||||
|
|
||||||
suffix = path.suffix.lower()
|
|
||||||
mime_type, _ = mimetypes.guess_type(path.name)
|
|
||||||
if suffix in {".tif", ".tiff"}:
|
|
||||||
mime_type = "image/tiff"
|
|
||||||
if mime_type is None:
|
|
||||||
return None, "Document preview unavailable: unsupported MIME type"
|
|
||||||
|
|
||||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
||||||
return f"data:{mime_type};base64,{encoded}", None
|
|
||||||
|
|
||||||
|
|
||||||
def _render_document_preview(document: Document) -> None:
|
|
||||||
source, error = _document_data_url(document)
|
|
||||||
if error is not None or source is None:
|
|
||||||
ui.label(error or "Document preview unavailable").classes("text-caption text-grey-7")
|
|
||||||
return
|
|
||||||
|
|
||||||
suffix = Path(document.file_path).suffix.lower()
|
|
||||||
if suffix == ".pdf":
|
|
||||||
ui.html(
|
|
||||||
(
|
|
||||||
'<iframe title="Document preview" '
|
|
||||||
f'src="{source}" '
|
|
||||||
'style="width:100%;height:520px;border:1px solid #ddd;border-radius:8px;"></iframe>'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ui.label("Zoom controls are currently available for image files.").classes("text-caption text-grey-7 q-mt-sm")
|
|
||||||
return
|
|
||||||
|
|
||||||
zoom_percent = {"value": 100}
|
|
||||||
|
|
||||||
with ui.element("div").style(
|
|
||||||
"width:100%;height:520px;overflow:auto;border:1px solid #ddd;border-radius:8px;padding:8px;background:#fafafa;"
|
|
||||||
):
|
|
||||||
image = ui.image(source).classes("rounded-borders").style("width:100%;max-width:none;")
|
|
||||||
|
|
||||||
zoom_label = ui.label("Zoom: 100%").classes("text-caption text-grey-7 q-mt-sm")
|
|
||||||
|
|
||||||
def _apply_zoom() -> None:
|
|
||||||
image.style(f"width:{zoom_percent['value']}%;max-width:none;")
|
|
||||||
image.update()
|
|
||||||
zoom_label.text = f"Zoom: {zoom_percent['value']}%"
|
|
||||||
zoom_label.update()
|
|
||||||
|
|
||||||
def _zoom_in() -> None:
|
|
||||||
zoom_percent["value"] = min(300, zoom_percent["value"] + 25)
|
|
||||||
_apply_zoom()
|
|
||||||
|
|
||||||
def _zoom_out() -> None:
|
|
||||||
zoom_percent["value"] = max(50, zoom_percent["value"] - 25)
|
|
||||||
_apply_zoom()
|
|
||||||
|
|
||||||
def _zoom_reset() -> None:
|
|
||||||
zoom_percent["value"] = 100
|
|
||||||
_apply_zoom()
|
|
||||||
|
|
||||||
with ui.row().classes("q-gutter-sm q-mt-xs"):
|
|
||||||
ui.button("-", on_click=_zoom_out)
|
|
||||||
ui.button("+", on_click=_zoom_in)
|
|
||||||
ui.button("Reset", on_click=_zoom_reset)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_display_rows(transcript: Transcript, revisions: list[TranscriptRevision]) -> list[RevisionDisplayRow]:
|
|
||||||
ordered = sorted(revisions, key=lambda revision: revision.version_number)
|
|
||||||
rows: list[RevisionDisplayRow] = []
|
|
||||||
|
|
||||||
if ordered:
|
|
||||||
first = ordered[0]
|
|
||||||
rows.append(
|
|
||||||
RevisionDisplayRow(
|
|
||||||
id="original",
|
|
||||||
created=first.created_at.isoformat(),
|
|
||||||
version="original",
|
|
||||||
text=first.text or "",
|
|
||||||
error_detail=first.error_detail,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for revision in ordered[1:]:
|
|
||||||
rows.append(
|
|
||||||
RevisionDisplayRow(
|
|
||||||
id=str(revision.version_number),
|
|
||||||
created=revision.created_at.isoformat(),
|
|
||||||
version=str(revision.version_number),
|
|
||||||
text=revision.text or "",
|
|
||||||
error_detail=revision.error_detail,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
rows.append(
|
|
||||||
RevisionDisplayRow(
|
|
||||||
id="original",
|
|
||||||
created=transcript.created_at.isoformat(),
|
|
||||||
version="original",
|
|
||||||
text=transcript.text or "",
|
|
||||||
error_detail=transcript.error_detail,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _render_transcript_versioned_section(
|
|
||||||
*,
|
|
||||||
document: Document | None,
|
|
||||||
transcript: Transcript | None,
|
|
||||||
revisions: list[TranscriptRevision],
|
|
||||||
on_update: Callable[[str], Awaitable[None]] | None,
|
|
||||||
) -> None:
|
|
||||||
with ui.card().classes("w-full q-pa-md"):
|
|
||||||
ui.label("Transcript").classes("text-subtitle1 text-weight-medium")
|
|
||||||
ui.separator().classes("q-my-sm")
|
ui.separator().classes("q-my-sm")
|
||||||
|
|
||||||
if transcript is None:
|
if revision is None:
|
||||||
ui.label("Transcript not available yet.").classes("text-body2 text-grey-8")
|
ui.label("No revision exists for this source.").classes("text-body2 vibe-text-muted")
|
||||||
return
|
return
|
||||||
|
|
||||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
render_revision_row(revision=revision, initially_expanded=True)
|
||||||
model_name = transcript.model
|
|
||||||
if model_name is None and revisions:
|
|
||||||
model_name = revisions[0].model
|
|
||||||
_metadata_row("Provider", transcript.provider)
|
|
||||||
_metadata_row("Model", model_name or "unknown")
|
|
||||||
_metadata_row("Prompt", transcript.prompt_name)
|
|
||||||
|
|
||||||
display_rows = _build_display_rows(transcript, revisions)
|
|
||||||
rows_by_id = {row.id: row for row in display_rows}
|
|
||||||
|
|
||||||
ui.separator().classes("q-my-sm")
|
|
||||||
ui.label("Versions").classes("text-subtitle2 text-weight-medium")
|
|
||||||
table = ui.table(
|
|
||||||
columns=[
|
|
||||||
{"name": "created", "label": "Created", "field": "created", "align": "left"},
|
|
||||||
{"name": "version", "label": "Version", "field": "version", "align": "left"},
|
|
||||||
],
|
|
||||||
rows=[
|
|
||||||
{
|
|
||||||
"id": row.id,
|
|
||||||
"created": row.created,
|
|
||||||
"version": row.version,
|
|
||||||
}
|
|
||||||
for row in display_rows
|
|
||||||
],
|
|
||||||
row_key="id",
|
|
||||||
).classes("w-full")
|
|
||||||
|
|
||||||
default_selected = display_rows[-1].id
|
|
||||||
selected_label = ui.label(f"Selected version: {rows_by_id[default_selected].version}").classes(
|
|
||||||
"text-caption text-grey-7"
|
|
||||||
)
|
|
||||||
|
|
||||||
ui.separator().classes("q-my-sm")
|
|
||||||
with ui.row().classes("w-full no-wrap items-start q-gutter-md"):
|
|
||||||
if document is not None:
|
|
||||||
with ui.column().classes("w-1/2"):
|
|
||||||
ui.label("Document Preview").classes("text-subtitle2 text-weight-medium")
|
|
||||||
_render_document_preview(document)
|
|
||||||
|
|
||||||
with ui.column().classes("w-1/2"):
|
|
||||||
editor = (
|
|
||||||
ui.textarea(label="Transcript text", value=rows_by_id[default_selected].text)
|
|
||||||
.props("autogrow outlined")
|
|
||||||
.classes("w-full")
|
|
||||||
)
|
|
||||||
error_label = ui.label("").classes("text-body2 text-red-10")
|
|
||||||
|
|
||||||
def _set_selected(version_id: str) -> None:
|
|
||||||
selected = rows_by_id.get(version_id)
|
|
||||||
if selected is None:
|
|
||||||
return
|
|
||||||
selected_label.text = f"Selected version: {selected.version}"
|
|
||||||
editor.value = selected.text
|
|
||||||
editor.update()
|
|
||||||
error_label.text = selected.error_detail or ""
|
|
||||||
error_label.update()
|
|
||||||
|
|
||||||
def _on_row_click(event) -> None: # noqa: ANN001
|
|
||||||
row_id = _extract_row_id(event.args)
|
|
||||||
if row_id is None:
|
|
||||||
return
|
|
||||||
_set_selected(row_id)
|
|
||||||
|
|
||||||
table.on("rowClick", _on_row_click)
|
|
||||||
_set_selected(default_selected)
|
|
||||||
|
|
||||||
if on_update is not None:
|
|
||||||
ui.button("Update", on_click=lambda: on_update(editor.value or ""))
|
|
||||||
|
|
||||||
|
|
||||||
def render_job_detail(
|
def render_job_detail(*, job: Job, source: Source | None, revision: Revision | None) -> None:
|
||||||
*,
|
|
||||||
job: Job,
|
|
||||||
document: Document | None,
|
|
||||||
transcript: Transcript | None,
|
|
||||||
revisions: list[TranscriptRevision],
|
|
||||||
on_update: Callable[[str], Awaitable[None]] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Render all sections for the job detail page."""
|
"""Render all sections for the job detail page."""
|
||||||
|
logger.debug("Rendering job detail for job ID %s", job.id)
|
||||||
status_text = job.status.value
|
status_text = job.status.value
|
||||||
with ui.column().classes("w-full max-w-4xl q-gutter-md"):
|
with ui.column().classes("w-full max-w-4xl q-gutter-md"):
|
||||||
with ui.card().classes("w-full q-pa-lg"):
|
with ui.card().classes("w-full q-pa-lg vibe-card"):
|
||||||
with ui.row().classes("w-full items-center justify-between q-gutter-md"):
|
with ui.row().classes("w-full items-center justify-between q-gutter-md"):
|
||||||
with ui.column().classes("q-gutter-none"):
|
with ui.column().classes("q-gutter-none"):
|
||||||
ui.label("Job overview").classes("text-h6 text-weight-bold")
|
ui.label("Job overview").classes("text-h6 text-weight-bold")
|
||||||
ui.label(str(job.id)).classes("text-caption text-grey-7")
|
ui.label(str(job.id)).classes("text-caption vibe-text-muted")
|
||||||
status_chip_classes = (
|
status_chip_classes = (
|
||||||
"q-px-sm q-py-xs rounded-borders "
|
"q-px-sm q-py-xs rounded-borders "
|
||||||
"text-weight-medium text-capitalize "
|
"vibe-status text-weight-medium text-capitalize "
|
||||||
f"{_status_chip_classes(status_text)}"
|
f"{_status_chip_classes(status_text)}"
|
||||||
)
|
)
|
||||||
ui.label(status_text).classes(status_chip_classes)
|
ui.label(status_text).classes(status_chip_classes)
|
||||||
|
|
||||||
ui.separator().classes("q-my-md")
|
ui.separator().classes("q-my-md vibe-separator")
|
||||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||||
_metadata_row("Created", job.created_at.isoformat())
|
_metadata_row("Created", job.date_created.isoformat())
|
||||||
_metadata_row("Updated", job.updated_at.isoformat())
|
_metadata_row("Updated", job.date_updated.isoformat())
|
||||||
_metadata_row("Retries", str(job.retry_count))
|
_metadata_row("Retries", str(job.retry_count))
|
||||||
|
|
||||||
if document is not None:
|
render_original_transcription_card(job=job)
|
||||||
_render_document_section(document)
|
|
||||||
|
|
||||||
_render_transcript_versioned_section(
|
if source is not None:
|
||||||
document=document,
|
_render_source_section(source)
|
||||||
transcript=transcript,
|
|
||||||
revisions=revisions,
|
_render_revision_section(revision)
|
||||||
on_update=on_update,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""High-level placeholder content for a transcription workspace."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.ui.resources import read_css
|
||||||
|
|
||||||
|
|
||||||
|
def render_page_content(
|
||||||
|
*,
|
||||||
|
source_name: str = "document-placeholder.jpg",
|
||||||
|
raw_text: str = "AI transcription output will appear here.",
|
||||||
|
revised_text: str = "Human revision text will appear here.",
|
||||||
|
) -> None:
|
||||||
|
"""Render the primary editor workspace and supporting context sidebar."""
|
||||||
|
ui.add_css(read_css("components/page_content.css"))
|
||||||
|
|
||||||
|
with ui.element("div").classes("page-content"):
|
||||||
|
with ui.element("section").classes("page-content__editor"):
|
||||||
|
with ui.row().classes("page-content__heading"):
|
||||||
|
with ui.column().classes("gap-0"):
|
||||||
|
ui.label("Active source").classes("page-content__kicker")
|
||||||
|
ui.label("Page transcription").classes("page-content__title")
|
||||||
|
ui.badge("Page 1 of 1").classes("page-content__badge")
|
||||||
|
|
||||||
|
with ui.element("div").classes("page-content__workspace"):
|
||||||
|
with ui.element("section").classes("source-placeholder"):
|
||||||
|
with ui.row().classes("source-placeholder__toolbar"):
|
||||||
|
ui.label(source_name)
|
||||||
|
ui.icon("image", size="1.25rem")
|
||||||
|
with ui.column().classes("source-placeholder__body"):
|
||||||
|
ui.icon("description", size="4rem")
|
||||||
|
ui.label("Source preview")
|
||||||
|
|
||||||
|
with ui.column().classes("transcription-placeholder"):
|
||||||
|
with ui.element("section").classes("transcription-placeholder__section"):
|
||||||
|
ui.label("AI raw output").classes("transcription-placeholder__title")
|
||||||
|
ui.label(raw_text).classes("transcription-placeholder__text")
|
||||||
|
|
||||||
|
with ui.element("section").classes("transcription-placeholder__section"):
|
||||||
|
ui.label("Human revision").classes("transcription-placeholder__title")
|
||||||
|
ui.textarea(value=revised_text).props("outlined autogrow").classes("w-full")
|
||||||
|
|
||||||
|
with ui.element("aside").props('aria-label="Document context"').classes("page-content__sidebar"):
|
||||||
|
with ui.element("section").classes("page-content__sidebar-section"):
|
||||||
|
ui.label("People").classes("page-content__sidebar-title")
|
||||||
|
ui.label("Author · Placeholder Person")
|
||||||
|
ui.label("Recipient · Placeholder Person")
|
||||||
|
|
||||||
|
with ui.element("section").classes("page-content__sidebar-section"):
|
||||||
|
ui.label("AI processing").classes("page-content__sidebar-title")
|
||||||
|
ui.badge("Completed", color="positive")
|
||||||
|
ui.label("Provider · Placeholder provider")
|
||||||
|
ui.label("Model · Placeholder model")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""High-level page header for document-oriented views."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.ui.resources import read_css
|
||||||
|
|
||||||
|
type PageHeaderAction = Callable[[], Awaitable[None] | None]
|
||||||
|
|
||||||
|
|
||||||
|
def render_page_header(
|
||||||
|
*,
|
||||||
|
eyebrow: str = "Letter · Placeholder Collection",
|
||||||
|
title: str = "Untitled archival document",
|
||||||
|
metadata: tuple[str, ...] = ("Date unknown", "Location unknown"),
|
||||||
|
on_details: PageHeaderAction | None = None,
|
||||||
|
on_review: PageHeaderAction | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Render document identity, metadata, and page-level actions."""
|
||||||
|
ui.add_css(read_css("components/page_header.css"))
|
||||||
|
|
||||||
|
with ui.element("section").classes("page-header"):
|
||||||
|
with ui.column().classes("page-header__identity"):
|
||||||
|
ui.label(eyebrow).classes("page-header__eyebrow")
|
||||||
|
ui.label(title).classes("page-header__title")
|
||||||
|
with ui.row().classes("page-header__metadata"):
|
||||||
|
for value in metadata:
|
||||||
|
ui.label(value)
|
||||||
|
|
||||||
|
with ui.row().classes("page-header__actions"):
|
||||||
|
ui.button("Document details", icon="info", on_click=on_details).props("outline no-caps")
|
||||||
|
ui.button("Mark reviewed", icon="task_alt", on_click=on_review).props("unelevated no-caps")
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
"""Common logic for generating table widgets."""
|
"""Common logic for generating table widgets."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nicegui import events
|
from nicegui import events
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _extract_row_id(args: Any) -> str | None:
|
def _extract_row_id(args: Any) -> str | None:
|
||||||
if isinstance(args, dict):
|
if isinstance(args, dict):
|
||||||
@@ -37,6 +40,7 @@ def _bind_row_click_handler(
|
|||||||
on_row_click_id(row_id)
|
on_row_click_id(row_id)
|
||||||
|
|
||||||
table.on("rowClick", handle_row_click)
|
table.on("rowClick", handle_row_click)
|
||||||
|
logger.debug("Row click handler bound to table")
|
||||||
|
|
||||||
|
|
||||||
def build_table(
|
def build_table(
|
||||||
@@ -63,6 +67,7 @@ def build_table(
|
|||||||
.classes(classes)
|
.classes(classes)
|
||||||
.props('table-style="table-layout: fixed; width: 100%;"')
|
.props('table-style="table-layout: fixed; width: 100%;"')
|
||||||
)
|
)
|
||||||
|
logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
|
||||||
if on_row_click_id is not None:
|
if on_row_click_id is not None:
|
||||||
_bind_row_click_handler(table, on_row_click_id=on_row_click_id)
|
_bind_row_click_handler(table, on_row_click_id=on_row_click_id)
|
||||||
return table
|
return table
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ class JobTableRow:
|
|||||||
status: str
|
status: str
|
||||||
filename: str
|
filename: str
|
||||||
retry_count: int
|
retry_count: int
|
||||||
created_at: str
|
date_created: str
|
||||||
updated_at: str
|
date_updated: str
|
||||||
|
|
||||||
|
|
||||||
def _format_timestamp(value: str) -> str:
|
def _format_timestamp(value: str) -> str:
|
||||||
@@ -43,10 +43,10 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
|
|||||||
"status": row.status,
|
"status": row.status,
|
||||||
"filename": row.filename,
|
"filename": row.filename,
|
||||||
"retry_count": row.retry_count,
|
"retry_count": row.retry_count,
|
||||||
"created_at": _format_timestamp(row.created_at),
|
"date_created": _format_timestamp(row.date_created),
|
||||||
"updated_at": _format_timestamp(row.updated_at),
|
"date_updated": _format_timestamp(row.date_updated),
|
||||||
"created_sort": row.created_at,
|
"created_sort": row.date_created,
|
||||||
"updated_sort": row.updated_at,
|
"updated_sort": row.date_updated,
|
||||||
}
|
}
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
@@ -65,8 +65,8 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
|
|||||||
{"name": "status", "label": "Status", "field": "status", "sortable": True},
|
{"name": "status", "label": "Status", "field": "status", "sortable": True},
|
||||||
{"name": "filename", "label": "Filename", "field": "filename", "sortable": True},
|
{"name": "filename", "label": "Filename", "field": "filename", "sortable": True},
|
||||||
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
|
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
|
||||||
{"name": "created_at", "label": "Created", "field": "created_at", "sortable": True},
|
{"name": "date_created", "label": "Created", "field": "date_created", "sortable": True},
|
||||||
{"name": "updated_at", "label": "Updated", "field": "updated_at", "sortable": True},
|
{"name": "date_updated", "label": "Updated", "field": "date_updated", "sortable": True},
|
||||||
],
|
],
|
||||||
default_sort_by="created_sort",
|
default_sort_by="created_sort",
|
||||||
default_descending=True,
|
default_descending=True,
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Reusable transcript UI components."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import Revision
|
||||||
|
|
||||||
|
type RevisionAction = Callable[[Revision], 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"
|
||||||
|
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 vibe-card")
|
||||||
|
with card, ui.column().classes("w-full q-gutter-y-sm"):
|
||||||
|
ui.label(header).classes("text-subtitle1 text-weight-medium")
|
||||||
|
ui.label(caption).classes("text-caption vibe-text-muted")
|
||||||
|
_metadata_row(label="Prompt", value=job.prompt_name or "unknown")
|
||||||
|
_metadata_row(label="Updated", value=_format_created_at(job.date_updated))
|
||||||
|
|
||||||
|
if job.text:
|
||||||
|
with ui.card().classes("w-full q-pa-sm"):
|
||||||
|
ui.markdown(job.text)
|
||||||
|
|
||||||
|
if job.error_detail:
|
||||||
|
with ui.card().classes("w-full vibe-card--error q-pa-sm"):
|
||||||
|
ui.label("Failure detail").classes("text-caption text-uppercase")
|
||||||
|
ui.label(job.error_detail).classes("text-body2")
|
||||||
|
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
def render_revision_row(
|
||||||
|
*,
|
||||||
|
revision: Revision,
|
||||||
|
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)
|
||||||
|
|
||||||
|
expansion = ui.expansion(value=initially_expanded, group="group").classes(f"{classes} rounded-borders vibe-card")
|
||||||
|
|
||||||
|
with expansion, ui.column().classes("w-full q-gutter-y-sm q-pa-sm"):
|
||||||
|
with expansion.add_slot("header"), ui.row().classes("w-full items-start justify-between q-gutter-md"):
|
||||||
|
with ui.column().classes("q-gutter-none"):
|
||||||
|
ui.label(header).classes("text-subtitle1 text-weight-medium")
|
||||||
|
ui.label(caption).classes("text-caption vibe-text-muted")
|
||||||
|
|
||||||
|
if on_delete is not None:
|
||||||
|
with ui.dialog() as delete_dialog, ui.card().classes("q-pa-md"):
|
||||||
|
ui.label("Delete this transcript revision?").classes("text-body1")
|
||||||
|
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(
|
||||||
|
'unelevated color="negative"'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_current_transcript() -> None:
|
||||||
|
delete_dialog.open()
|
||||||
|
confirmed = await delete_dialog
|
||||||
|
if not confirmed:
|
||||||
|
return
|
||||||
|
|
||||||
|
maybe_awaitable = on_delete(revision)
|
||||||
|
if isinstance(maybe_awaitable, Awaitable):
|
||||||
|
await maybe_awaitable
|
||||||
|
|
||||||
|
with ui.column(align_items="center").classes("self-center q-gutter-none"):
|
||||||
|
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))
|
||||||
|
|
||||||
|
if revision.text:
|
||||||
|
with ui.card().classes("w-full q-pa-sm"):
|
||||||
|
ui.markdown(revision.text)
|
||||||
|
|
||||||
|
return expansion
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
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 vibe-text-muted text-uppercase")
|
||||||
|
ui.label(value).classes("text-body2 text-right break-all")
|
||||||
@@ -5,159 +5,155 @@ from __future__ import annotations
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from sqlmodel import desc
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from transcription.db import get_session
|
from transcription.db.models import Job
|
||||||
from transcription.models import Document
|
from transcription.db.models import JobStatus
|
||||||
from transcription.models import Job
|
from transcription.db.models import Source
|
||||||
from transcription.models import Transcript
|
from transcription.services.jobs import JobService
|
||||||
from transcription.models import TranscriptRevision
|
from transcription.services.transcription import TranscriptionService
|
||||||
from transcription.services import ServiceBundle
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.error_presenter import summarize_error
|
|
||||||
from transcription.ui.components.job_detail import render_job_detail
|
|
||||||
from transcription.ui.components.table.jobs import JobTableRow
|
|
||||||
from transcription.ui.components.table.jobs import render_jobs_table
|
from transcription.ui.components.table.jobs import render_jobs_table
|
||||||
|
|
||||||
|
from ...db.session import SessionFactoryDep
|
||||||
|
from ..components.document_panzoom import render_document_panzoom
|
||||||
|
from ..components.table.jobs import JobTableRow
|
||||||
|
from ..components.transcript import render_original_transcription_card
|
||||||
|
from ..components.transcript import render_revision_row
|
||||||
|
|
||||||
async def fetch_job_rows() -> list[JobTableRow]:
|
|
||||||
"""Return jobs for display in most-recent-first order."""
|
def register_page() -> None: # noqa: PLR0915
|
||||||
async with get_session() as session:
|
"""Register jobs list and detail routes."""
|
||||||
jobs = (
|
|
||||||
await session.exec(
|
@ui.page("/jobs")
|
||||||
select(Job)
|
async def jobs_page(session_factory: SessionFactoryDep) -> None:
|
||||||
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
.order_by(desc(Job.created_at))
|
render_navigation_header(current_path="/jobs")
|
||||||
)
|
|
||||||
).all()
|
@ui.refreshable
|
||||||
return [
|
async def render_table() -> None:
|
||||||
|
jobs = [
|
||||||
JobTableRow(
|
JobTableRow(
|
||||||
id=job.id,
|
id=job.id,
|
||||||
status=job.status.value,
|
status=job.status.value,
|
||||||
filename=job.filename,
|
filename=job.filename,
|
||||||
retry_count=job.retry_count,
|
retry_count=job.retry_count,
|
||||||
created_at=job.created_at.isoformat(),
|
date_created=job.date_created.isoformat(),
|
||||||
updated_at=job.updated_at.isoformat(),
|
date_updated=job.date_updated.isoformat(),
|
||||||
)
|
)
|
||||||
for job in jobs
|
for job in await jobs_service.list_jobs()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def fetch_job_detail(job_id: UUID) -> tuple[Job | None, Document | None, Transcript | None, list[TranscriptRevision]]:
|
|
||||||
"""Return job, document, transcript snapshot, and revisions for detail view."""
|
|
||||||
async with get_session() as session:
|
|
||||||
job = await session.get(Job, job_id)
|
|
||||||
if job is None:
|
|
||||||
return None, None, None, []
|
|
||||||
document = await session.get(Document, job.document_id)
|
|
||||||
transcript = (await session.exec(select(Transcript).where(Transcript.job_id == job.id))).first()
|
|
||||||
revisions = (
|
|
||||||
await session.exec(
|
|
||||||
select(TranscriptRevision)
|
|
||||||
.where(TranscriptRevision.job_id == job.id)
|
|
||||||
.order_by(TranscriptRevision.version_number)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
return job, document, transcript, list(revisions)
|
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None:
|
|
||||||
"""Register jobs list and detail routes."""
|
|
||||||
|
|
||||||
@ui.page("/jobs")
|
|
||||||
async def jobs_page() -> None:
|
|
||||||
ui.label("Transcription Jobs")
|
|
||||||
status = ui.label("Ready")
|
|
||||||
|
|
||||||
@ui.refreshable
|
|
||||||
async def render_table() -> None:
|
|
||||||
jobs = await fetch_job_rows()
|
|
||||||
render_jobs_table(jobs)
|
render_jobs_table(jobs)
|
||||||
|
|
||||||
async def refresh() -> None:
|
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
|
||||||
status.text = "Refreshing..."
|
|
||||||
try:
|
|
||||||
await render_table.refresh()
|
|
||||||
status.text = "Refreshed"
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
status.text = f"Refresh failed: {summarize_error(exc, operation='jobs.refresh')}"
|
|
||||||
show_error(exc, title="Jobs refresh failed", operation="jobs.refresh")
|
|
||||||
|
|
||||||
ui.button("Refresh", on_click=refresh)
|
|
||||||
await render_table()
|
await render_table()
|
||||||
ui.link("Back to upload", "/upload")
|
|
||||||
|
|
||||||
@ui.page("/jobs/{job_id}")
|
@ui.page("/jobs/{job_id}")
|
||||||
async def job_detail_page(job_id: str) -> None:
|
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915
|
||||||
ui.label("Job Detail")
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
content = ui.column().classes("w-full")
|
transcription_service = TranscriptionService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed_id = UUID(job_id)
|
parsed_job_id = UUID(job_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
ui.label("Invalid job id")
|
ui.label("Invalid job id").classes("text-h6 text-negative")
|
||||||
ui.link("Back to jobs", "/jobs")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
async def refresh_content() -> None:
|
|
||||||
content.clear()
|
|
||||||
job, document, transcript, revisions = await fetch_job_detail(parsed_id)
|
|
||||||
if job is None:
|
|
||||||
with content:
|
|
||||||
ui.label("Job not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
services = ServiceBundle()
|
|
||||||
|
|
||||||
async def update_transcript_text(value: str) -> None:
|
|
||||||
try:
|
try:
|
||||||
update_text = value.strip()
|
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||||
async with get_session() as session:
|
except ValueError:
|
||||||
current_transcript = (
|
ui.label("Job not found").classes("text-h6 text-negative")
|
||||||
await session.exec(select(Transcript).where(Transcript.job_id == parsed_id))
|
return
|
||||||
).first()
|
|
||||||
provider_name = current_transcript.provider if current_transcript is not None else "openrouter"
|
|
||||||
prompt_name = (
|
|
||||||
current_transcript.prompt_name if current_transcript is not None else "transcribe_document.md"
|
|
||||||
)
|
|
||||||
model_name = current_transcript.model if current_transcript is not None else None
|
|
||||||
|
|
||||||
await services.transcriptions.upsert_transcript_by_job(
|
source = _resolve_primary_source(job)
|
||||||
job_id=parsed_id,
|
|
||||||
text=update_text,
|
|
||||||
error_detail=None,
|
|
||||||
provider=provider_name,
|
|
||||||
prompt_name=prompt_name,
|
|
||||||
model=model_name,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
await services.transcriptions.append_transcript_revision(
|
|
||||||
job_id=parsed_id,
|
|
||||||
text=update_text,
|
|
||||||
error_detail=None,
|
|
||||||
provider=provider_name,
|
|
||||||
prompt_name=prompt_name,
|
|
||||||
model=model_name,
|
|
||||||
source="user",
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
ui.notify("Transcript updated", type="positive")
|
with ui.splitter(value=30).classes("w-full h-[calc(100vh-64px)]") as splitter:
|
||||||
await refresh_content()
|
with splitter.before, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
|
||||||
|
if source is not None:
|
||||||
|
render_document_panzoom(source=source)
|
||||||
|
else:
|
||||||
|
ui.label("No source preview is available for this job.").classes("text-body2 vibe-text-muted")
|
||||||
|
with splitter.after, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
|
||||||
|
with ui.row():
|
||||||
|
ui.button(icon="arrow_back", on_click=ui.navigate.back)
|
||||||
|
with ui.row().classes("w-full items-center justify-between"):
|
||||||
|
ui.label(f"{job.id}").classes("text-h6 text-weight-bold")
|
||||||
|
match job.status:
|
||||||
|
case JobStatus.TRANSCRIBED:
|
||||||
|
ui.chip(job.status.value.upper(), color="positive", text_color="white").props("outline")
|
||||||
|
case _:
|
||||||
|
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
|
||||||
|
|
||||||
|
render_original_transcription_card(job=job)
|
||||||
|
|
||||||
|
async def delete_revision_by_id(revision_id: UUID) -> None:
|
||||||
|
try:
|
||||||
|
revision = await transcription_service.read_revision(revision_id=revision_id)
|
||||||
|
await transcription_service.delete_revision(revision)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
show_error(exc, title="Transcript update failed", operation="jobs.detail.update")
|
show_error(exc, title="Delete failed", operation="jobs.delete_revision")
|
||||||
|
return
|
||||||
|
|
||||||
with content:
|
ui.notify("Deleted revision", type="positive")
|
||||||
render_job_detail(
|
await render_revision_panel.refresh()
|
||||||
job=job,
|
|
||||||
document=document,
|
@ui.refreshable
|
||||||
transcript=transcript,
|
async def render_revision_panel() -> None:
|
||||||
revisions=revisions,
|
refreshed_job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||||
on_update=update_transcript_text,
|
refreshed_source = _resolve_primary_source(refreshed_job)
|
||||||
|
if refreshed_source is None:
|
||||||
|
ui.label("No source is available for revision editing.").classes("text-body2 vibe-text-muted")
|
||||||
|
return
|
||||||
|
|
||||||
|
current_revision = refreshed_source.revision
|
||||||
|
default_revision_text = (
|
||||||
|
current_revision.text if current_revision is not None else (refreshed_job.text or "")
|
||||||
)
|
)
|
||||||
|
|
||||||
await refresh_content()
|
ui.label("Revision Editor").classes("text-subtitle1 text-weight-medium")
|
||||||
|
editor = ui.textarea(label="Revision text", value=default_revision_text).props("autogrow outlined")
|
||||||
|
editor.classes("w-full")
|
||||||
|
|
||||||
ui.link("Back to jobs", "/jobs")
|
async def save_revision() -> None:
|
||||||
|
candidate = (editor.value or "").strip()
|
||||||
|
if not candidate:
|
||||||
|
ui.notify("Revision text is required.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await transcription_service.upsert_revision_for_source(
|
||||||
|
source_id=refreshed_source.id,
|
||||||
|
text=candidate,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Save failed", operation="jobs.save_revision")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Revision saved", type="positive")
|
||||||
|
await render_revision_panel.refresh()
|
||||||
|
|
||||||
|
with ui.row().classes("w-full justify-end"):
|
||||||
|
ui.button(
|
||||||
|
"Create revision" if current_revision is None else "Update revision",
|
||||||
|
on_click=save_revision,
|
||||||
|
icon="save",
|
||||||
|
).props('unelevated color="primary"')
|
||||||
|
|
||||||
|
if current_revision is None:
|
||||||
|
ui.label("No revision exists for this source.").classes("text-body2 vibe-text-muted")
|
||||||
|
return
|
||||||
|
|
||||||
|
render_revision_row(
|
||||||
|
revision=current_revision,
|
||||||
|
initially_expanded=True,
|
||||||
|
on_delete=lambda _revision, rid=current_revision.id: delete_revision_by_id(rid),
|
||||||
|
)
|
||||||
|
|
||||||
|
await render_revision_panel()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_primary_source(job: Job) -> Source | None:
|
||||||
|
if not job.sources:
|
||||||
|
return None
|
||||||
|
return job.sources[0]
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ from __future__ import annotations
|
|||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.app_state import resolve_session_factory
|
from transcription.db import session_scope
|
||||||
from transcription.db import get_session
|
|
||||||
from transcription.services.store import create_upload_job
|
from transcription.services.store import create_upload_job
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.upload import render_upload_widget
|
from transcription.ui.components.upload import render_upload_widget
|
||||||
from transcription.worker import resolve_worker_notifier
|
from transcription.worker import resolve_worker_notifier
|
||||||
|
|
||||||
@@ -17,10 +17,10 @@ def register_page() -> None:
|
|||||||
|
|
||||||
@ui.page("/upload", title="Upload Document")
|
@ui.page("/upload", title="Upload Document")
|
||||||
def upload_page(request: Request) -> None:
|
def upload_page(request: Request) -> None:
|
||||||
session_factory = resolve_session_factory(request.app.state)
|
render_navigation_header(current_path="/upload")
|
||||||
|
|
||||||
async def submit_upload(filename: str, file_bytes: bytes):
|
async def submit_upload(filename: str, file_bytes: bytes):
|
||||||
async with get_session(session_factory=session_factory) as session:
|
async with session_scope() as session:
|
||||||
return await create_upload_job(
|
return await create_upload_job(
|
||||||
filename=filename,
|
filename=filename,
|
||||||
file_bytes=file_bytes,
|
file_bytes=file_bytes,
|
||||||
@@ -29,6 +29,3 @@ def register_page() -> None:
|
|||||||
|
|
||||||
notify_worker = resolve_worker_notifier(request.app.state)
|
notify_worker = resolve_worker_notifier(request.app.state)
|
||||||
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
|
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
|
||||||
|
|
||||||
with ui.row():
|
|
||||||
ui.link("View jobs", "/jobs")
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Package resource helpers for UI presentation assets."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import cache
|
||||||
|
from importlib.resources import files
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def read_css(relative_path: str) -> str:
|
||||||
|
"""Read and cache a CSS resource relative to ``ui/static``."""
|
||||||
|
resource_path = PurePosixPath(relative_path)
|
||||||
|
if resource_path.is_absolute() or ".." in resource_path.parts or resource_path.suffix != ".css":
|
||||||
|
msg = f"Invalid CSS resource path: {relative_path}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
resource = files("transcription.ui").joinpath("static", *resource_path.parts)
|
||||||
|
return resource.read_text(encoding="utf-8")
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
.app-shell {
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-bottom: 1px solid var(--theme-border);
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__inner {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1fr) auto minmax(180px, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__brand,
|
||||||
|
.app-shell__actions {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__brand {
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--theme-text);
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__brand-mark {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--theme-inverse-text);
|
||||||
|
background: var(--theme-primary);
|
||||||
|
font-family: "Trebuchet MS", sans-serif;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__nav-item {
|
||||||
|
min-height: 40px;
|
||||||
|
color: var(--theme-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__nav-item--active {
|
||||||
|
color: var(--theme-primary-hover);
|
||||||
|
border-bottom: 3px solid var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__save-state {
|
||||||
|
color: var(--theme-text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.app-shell {
|
||||||
|
padding-inline: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__inner {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__nav {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 2;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__brand-name,
|
||||||
|
.app-shell__save-state {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
.page-content {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(260px, 320px);
|
||||||
|
align-items: start;
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-surface-raised);
|
||||||
|
box-shadow: var(--theme-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content__editor {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content__heading {
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content__kicker {
|
||||||
|
color: var(--theme-text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content__title,
|
||||||
|
.page-content__sidebar-title {
|
||||||
|
color: var(--theme-text);
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content__title {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content__badge {
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content__workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 0.85fr) minmax(320px, 1.15fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-placeholder {
|
||||||
|
min-height: 440px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
border: 1px solid var(--theme-viewer-border);
|
||||||
|
background: var(--theme-viewer);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-placeholder__toolbar {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: var(--theme-inverse-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-placeholder__body {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--theme-viewer-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcription-placeholder {
|
||||||
|
min-width: 0;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcription-placeholder__section {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcription-placeholder__title {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcription-placeholder__text {
|
||||||
|
min-height: 160px;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
background: var(--theme-surface);
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content__sidebar {
|
||||||
|
min-width: 0;
|
||||||
|
border-left: 1px solid var(--theme-border);
|
||||||
|
background: var(--theme-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content__sidebar-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--theme-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content__sidebar-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.page-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content__sidebar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
border-top: 1px solid var(--theme-border);
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.page-content__workspace,
|
||||||
|
.page-content__sidebar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content__editor,
|
||||||
|
.page-content__sidebar-section {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
.page-header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 2rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__identity {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__eyebrow {
|
||||||
|
color: var(--theme-text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__title {
|
||||||
|
color: var(--theme-text);
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__metadata {
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--theme-text-muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__metadata > * + *::before {
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
content: "·";
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.page-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__title {
|
||||||
|
font-size: 1.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__actions,
|
||||||
|
.page-header__actions .q-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
:root {
|
||||||
|
--palette-carbon-black: #1c2321;
|
||||||
|
--palette-cool-steel: #7d98a1;
|
||||||
|
--palette-blue-slate: #5e6572;
|
||||||
|
--palette-powder-blue: #a9b4c2;
|
||||||
|
--palette-platinum: #eef1ef;
|
||||||
|
|
||||||
|
--theme-text: var(--palette-carbon-black);
|
||||||
|
--theme-text-muted: var(--palette-blue-slate);
|
||||||
|
--theme-page: var(--palette-platinum);
|
||||||
|
--theme-surface: color-mix(in srgb, var(--palette-platinum) 88%, var(--palette-powder-blue));
|
||||||
|
--theme-surface-raised: var(--palette-platinum);
|
||||||
|
--theme-surface-muted: color-mix(in srgb, var(--palette-platinum) 68%, var(--palette-powder-blue));
|
||||||
|
--theme-border: var(--palette-powder-blue);
|
||||||
|
--theme-primary: var(--palette-blue-slate);
|
||||||
|
--theme-primary-hover: var(--palette-carbon-black);
|
||||||
|
--theme-secondary: var(--palette-cool-steel);
|
||||||
|
--theme-focus: var(--palette-cool-steel);
|
||||||
|
--theme-inverse-text: var(--palette-platinum);
|
||||||
|
--theme-viewer: var(--palette-carbon-black);
|
||||||
|
--theme-viewer-border: var(--palette-blue-slate);
|
||||||
|
--theme-viewer-muted: var(--palette-powder-blue);
|
||||||
|
--theme-shadow: 0 10px 28px color-mix(in srgb, var(--palette-carbon-black) 14%, transparent);
|
||||||
|
|
||||||
|
--q-primary: var(--palette-blue-slate);
|
||||||
|
--q-secondary: var(--palette-cool-steel);
|
||||||
|
--q-accent: var(--palette-powder-blue);
|
||||||
|
--q-dark: var(--palette-carbon-black);
|
||||||
|
--q-dark-page: var(--palette-carbon-black);
|
||||||
|
--q-positive: var(--palette-cool-steel);
|
||||||
|
--q-negative: var(--palette-carbon-black);
|
||||||
|
--q-info: var(--palette-cool-steel);
|
||||||
|
--q-warning: var(--palette-powder-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
.q-layout,
|
||||||
|
.q-page-container {
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Aptos", "Trebuchet MS", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-card,
|
||||||
|
.vibe-card {
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-surface-raised);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vibe-card--error {
|
||||||
|
border-color: var(--palette-carbon-black);
|
||||||
|
color: var(--theme-inverse-text);
|
||||||
|
background: var(--palette-carbon-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vibe-text-muted {
|
||||||
|
color: var(--theme-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vibe-separator {
|
||||||
|
background: var(--theme-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vibe-status {
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vibe-status--queued {
|
||||||
|
color: var(--palette-blue-slate);
|
||||||
|
background: var(--palette-platinum);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vibe-status--processing {
|
||||||
|
color: var(--palette-carbon-black);
|
||||||
|
background: var(--palette-powder-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vibe-status--transcribed {
|
||||||
|
color: var(--palette-carbon-black);
|
||||||
|
background: var(--palette-cool-steel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vibe-status--failed {
|
||||||
|
color: var(--palette-platinum);
|
||||||
|
background: var(--palette-carbon-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vibe-status--default {
|
||||||
|
color: var(--palette-blue-slate);
|
||||||
|
background: var(--theme-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
a:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
[tabindex="0"]:focus-visible {
|
||||||
|
outline: 3px solid var(--theme-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ from uuid import UUID
|
|||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from transcription.db import get_session
|
from transcription.db import session_scope
|
||||||
from transcription.errors import AppError
|
from transcription.errors import AppError
|
||||||
from transcription.errors import classify_unexpected_error
|
from transcription.errors import classify_unexpected_error
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ async def process_next_queued_job(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if session is None:
|
if session is None:
|
||||||
async with get_session(session_factory=session_factory) as local_session:
|
async with session_scope(session_factory=session_factory) as local_session:
|
||||||
return await process_next_queued_job_workflow(services=services, session=local_session)
|
return await process_next_queued_job_workflow(services=services, session=local_session)
|
||||||
|
|
||||||
return await process_next_queued_job_workflow(services=services, session=session)
|
return await process_next_queued_job_workflow(services=services, session=session)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""Tests for API error response envelope handlers."""
|
"""Tests for API error response envelope handlers."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
import pytest
|
|
||||||
|
|
||||||
from transcription.api.errors import register_error_handlers
|
from transcription.api.errors import register_error_handlers
|
||||||
from transcription.errors import AppError, ErrorCategory
|
from transcription.errors import AppError
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ BOOK 1 had 54 pages; 14 chapters. BOOK 2 has 70 pages; 18 chapters. BOOK 1 con-
|
|||||||
sisted largely of first generation family history. BOOK 2 throws more light on
|
sisted largely of first generation family history. BOOK 2 throws more light on
|
||||||
the second generation. Sidney promises a BOOK 3 and that may begin to do justice
|
the second generation. Sidney promises a BOOK 3 and that may begin to do justice
|
||||||
to the third generation. We suggest that Sidney get the help of Louis Shinn
|
to the third generation. We suggest that Sidney get the help of Louis Shinn
|
||||||
who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumeeq
|
who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumecq
|
||||||
Plains. Louis has the gift of seeing, recalling and telling. One sentence in
|
Plains. Louis has the gift of seeing, recalling and telling. One sentence in
|
||||||
his chapter gives a great tribute to the Doumeeqers - so far as he knows no one
|
his chapter gives a great tribute to the Doumeccqers--so [sic] far as he knows no one
|
||||||
on the Doumeeq Plains went on relief during the depression. That in a nutshell
|
on the Doumecq Plains went on relief during the depression. That in a nutshell
|
||||||
shows the sturdy character of the residents of the Doumeeq Plains.
|
shows the sturdy character of the residents of the Doumecq Plains.
|
||||||
|
|
||||||
We promised in BOOK 1 that in BOOK 2 we would give the story of the trip of
|
We promised in BOOK 1 that in BOOK 2 we would give the story of the trip of
|
||||||
John E. Cochran and wife to Tennessee, Cuba and the Panama Canal. You will see
|
John E. Cochran and wife to Tennessee, Cuba and the Panama Canal. You will see
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ the family newsletter two years ago.
|
|||||||
|
|
||||||
Nome Alaska August 26, 1923
|
Nome Alaska August 26, 1923
|
||||||
My Dear Ethel et al.
|
My Dear Ethel et al.
|
||||||
|
|
||||||
I don't know when I did write or when you did
|
I don't know when I did write or when you did
|
||||||
but I am going to write now however and never
|
but I am going to write now however and never
|
||||||
the less. But I wish I could talk (I can yet but I
|
the less. But I wish I could talk (I can yet but I
|
||||||
@@ -27,7 +26,6 @@ and Polly sit up and listen and that little black
|
|||||||
rascal of yours would fairly sparkle with
|
rascal of yours would fairly sparkle with
|
||||||
listening. Can't I see him listening now to all the
|
listening. Can't I see him listening now to all the
|
||||||
yarns we told last summer?
|
yarns we told last summer?
|
||||||
|
|
||||||
You see, we-Miss Saville and I, took a trip north
|
You see, we-Miss Saville and I, took a trip north
|
||||||
on the Buford and it was very interesting. We
|
on the Buford and it was very interesting. We
|
||||||
went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain
|
went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain
|
||||||
@@ -43,14 +41,14 @@ all around it similar to a currycomb in coarseness; no ears but huge tusks of iv
|
|||||||
the most repulsive looking animals imaginable and tho I have always read about them I never
|
the most repulsive looking animals imaginable and tho I have always read about them I never
|
||||||
expect such disagreeable looking creatures. They had a rough brown hairy skin and some of
|
expect such disagreeable looking creatures. They had a rough brown hairy skin and some of
|
||||||
them looked warty. They must have weighed two ton at least. Ere we got them back to Nome
|
them looked warty. They must have weighed two ton at least. Ere we got them back to Nome
|
||||||
to the natives they were getting extremely odiferous–in fact, you could scarcely stay on the
|
to the natives they were getting extremely odiferous—in fact, you could scarcely stay on the
|
||||||
ship with any degree of comfort unless you had per chance lost your sense of smell.
|
ship with any degree of comfort unless you had per chance lost your sense of smell.
|
||||||
|
|
||||||
Then we went north to a few minutes beyond the 70th degree of latitude and thot [sic] for awhile
|
Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile
|
||||||
we would go to Wrangell Island where some men from Stefflonsons [sic] ship were supposed to be
|
we would go to Wrangell Island where some men from Steffonsons ship were supposed to be
|
||||||
stranded but we didn't get there and instead stopped at a small native village at Cape Serdz [sic] in
|
stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in
|
||||||
Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23
|
Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23
|
||||||
years. He was a Swede–who else could. Their houses were circular and built up with dirt 2 or
|
years. He was a Swede--who else could. Their houses were circular and built up with dirt 2 or
|
||||||
3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room
|
3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room
|
||||||
was partitioned off at the sides with skins for sleeping quarters. In the main part they had the
|
was partitioned off at the sides with skins for sleeping quarters. In the main part they had the
|
||||||
fire on the ground and the fish drying on lines and the skins hanging around and the dogs and
|
fire on the ground and the fish drying on lines and the skins hanging around and the dogs and
|
||||||
@@ -65,22 +63,22 @@ The other place we stopped was at Whalen, a trading post in Siberia. There these
|
|||||||
went wild. They rushed helter-skelter, hither and thither, here and there, trying to find
|
went wild. They rushed helter-skelter, hither and thither, here and there, trying to find
|
||||||
something to buy. Prices raised right before your eyes. One would but something for $1.00
|
something to buy. Prices raised right before your eyes. One would but something for $1.00
|
||||||
and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to
|
and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to
|
||||||
have it. One man I was sort of taking care of, tho [sic] he had his son along for the purpose,
|
have it. One man I was sort of taking care of, tho he had his son along for the purpose,
|
||||||
bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billi[illegible]s, 6 or 8 ivory and silver rings, one
|
bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billikens, 6 or 8 ivory and silver rings, one
|
||||||
fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the
|
fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the
|
||||||
little natives throw them at the flocks of geese and ducks which fly close over the village and
|
little natives throw them at the flocks of geese and ducks which fly close over the village and
|
||||||
the slings entangle their wings and legs, sometimes more than one, and they can't fly. They
|
the slings entangle their wings and legs, sometimes more than one, and they can't fly. They
|
||||||
come down and the natives capture them. There was more junk brot [sic] aboard than baggage, I
|
come down and the natives capture them. There was more junk brot aboard than baggage, I
|
||||||
do believe. And they say that at the first stop it was worse than here. The red flag was flying
|
do believe. And they say that at the first stop it was worse than here. The red flag was flying
|
||||||
over Whalen and the Russian soldiers were there–a few, one or two or three, I forget the
|
over Whalen and the Russian soldiers were there—a few, one or two or three, I forget the
|
||||||
number.
|
number.
|
||||||
|
|
||||||
We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out
|
We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out
|
||||||
until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let
|
until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let
|
||||||
us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is
|
us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is
|
||||||
all I had to write about and I know Buster'd [sic] listen anyway and I'd soak ole Peter's head if he
|
all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he
|
||||||
didn't and Polly would in my lap and I don't know much about the youngest one of yours so
|
didn't and Polly would in my lap and I don't know much about the youngest one of yours so
|
||||||
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf [sic].
|
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf.
|
||||||
|
|
||||||
I expect there were 150 passengers on board and almost or more of the crew and helpers. We
|
I expect there were 150 passengers on board and almost or more of the crew and helpers. We
|
||||||
had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times.
|
had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times.
|
||||||
@@ -109,7 +107,7 @@ Ome
|
|||||||
|
|
||||||
Reprinted from Cochran Chronicles, Volume 9, Number 1, November 1986
|
Reprinted from Cochran Chronicles, Volume 9, Number 1, November 1986
|
||||||
|
|
||||||
© [inserted: JECFA] 1986
|
© JECFA 1986
|
||||||
|
|
||||||
Up
|
Up
|
||||||
|
|
||||||
|
|||||||
@@ -8,24 +8,23 @@ ISBILL & MOSER
|
|||||||
DEALERS IN
|
DEALERS IN
|
||||||
GENERAL MERCHANDISE
|
GENERAL MERCHANDISE
|
||||||
|
|
||||||
Vonore, Tenn., Jany 27- 1913
|
Vonore, Tenn. Jany 27- 1913
|
||||||
Dear Much Aunt Louie
|
Dear Much Aunt Adeline
|
||||||
How are you a
|
Was at home a
|
||||||
few nights ago I sewed a
|
few nights ago & saw a
|
||||||
letter from your folks, so
|
letter from your folks. So
|
||||||
I decided to write you
|
I decided to write you
|
||||||
a few lines myself ok
|
a few lines myself ok
|
||||||
I am contemplateing a
|
I am contemplateing [sic] a
|
||||||
trip out west next summer
|
trip out west next summer
|
||||||
& I want Some Olders to go
|
& [inserted: I] want some of them to go
|
||||||
where I and them.
|
when I am [inserted: a] them.
|
||||||
|
Am getting
|
||||||
I am getting
|
|
||||||
up in years & unmarried
|
up in years & unmarried
|
||||||
so you see the object of
|
so you see the object of
|
||||||
my trip, is to get a bunch
|
my trip, is to get a wife
|
||||||
of Young & old maids
|
& if there is any old maids
|
||||||
& widows out there. I
|
or widows out there, I
|
||||||
want you to kiss them
|
want you to kiss them
|
||||||
at my fans [sic] mug as they
|
at my [hand?] me at there
|
||||||
as soon as I get there
|
as soon as I get there.
|
||||||
|
|||||||
+12
-8
@@ -13,11 +13,12 @@ from sqlmodel.pool import StaticPool
|
|||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.config import get_settings
|
from transcription.config import get_settings
|
||||||
|
from transcription.db.engine import get_database_url
|
||||||
|
from transcription.db.engine import get_engine
|
||||||
from transcription.db.operations import create_all
|
from transcription.db.operations import create_all
|
||||||
from transcription.db.runtime import dispose_database_runtime
|
from transcription.db.session import dispose_session_factory
|
||||||
from transcription.db.runtime import get_engine
|
from transcription.db.session import get_session_factory
|
||||||
from transcription.db.runtime import get_session
|
from transcription.db.session import session_scope
|
||||||
from transcription.db.runtime import get_session_factory
|
|
||||||
from transcription.services.documents import DocumentService
|
from transcription.services.documents import DocumentService
|
||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
|
|
||||||
@@ -39,23 +40,26 @@ def session():
|
|||||||
async def default_settings():
|
async def default_settings():
|
||||||
"""Provide default settings for tests."""
|
"""Provide default settings for tests."""
|
||||||
settings = get_settings(database_url="sqlite:///:memory:")
|
settings = get_settings(database_url="sqlite:///:memory:")
|
||||||
await create_all(engine=get_engine(settings=settings))
|
db_url = get_database_url(settings)
|
||||||
|
await create_all(engine=get_engine(database_url=db_url))
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def async_session(default_settings: Settings):
|
async def async_session(default_settings: Settings):
|
||||||
"""Provide a clean asynchronous database session for async tests."""
|
"""Provide a clean asynchronous database session for async tests."""
|
||||||
async with get_session(settings=default_settings) as async_session:
|
db_url = get_database_url(default_settings)
|
||||||
|
async with session_scope(database_url=db_url) as async_session:
|
||||||
yield async_session
|
yield async_session
|
||||||
|
|
||||||
await dispose_database_runtime()
|
await dispose_session_factory(db_url)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def default_session_factory(default_settings: Settings):
|
def default_session_factory(default_settings: Settings):
|
||||||
"""Provide a base fixture for tests that require database access."""
|
"""Provide a base fixture for tests that require database access."""
|
||||||
session_factory = get_session_factory(settings=default_settings)
|
db_url = get_database_url(default_settings)
|
||||||
|
session_factory = get_session_factory(database_url=db_url)
|
||||||
return session_factory
|
return session_factory
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.models import Job, JobStatus, Transcript, TranscriptRevision
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobStatus
|
||||||
from transcription.providers.base import TranscriptionResult
|
from transcription.providers.base import TranscriptionResult
|
||||||
from transcription.services.store import create_upload_job
|
from transcription.services.store import create_upload_job
|
||||||
from transcription.worker import process_next_queued_job
|
from transcription.worker import process_next_queued_job
|
||||||
@@ -17,8 +17,10 @@ class TestPipelineSuccessFlow:
|
|||||||
"""Verify end-to-end success lifecycle behavior."""
|
"""Verify end-to-end success lifecycle behavior."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upload_then_worker_persists_transcribed_terminal_state(self, async_session, tmp_path: Path, monkeypatch):
|
async def test_upload_then_worker_persists_transcribed_terminal_state(
|
||||||
"""Upload followed by worker processing persists transcript and transcribed status."""
|
self, async_session, tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Upload followed by worker processing persists job transcription and transcribed status."""
|
||||||
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
||||||
upload_result = await create_upload_job(
|
upload_result = await create_upload_job(
|
||||||
filename="pipeline.jpg",
|
filename="pipeline.jpg",
|
||||||
@@ -27,38 +29,43 @@ class TestPipelineSuccessFlow:
|
|||||||
settings=settings,
|
settings=settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _fake_transcribe(_path: str) -> TranscriptionResult:
|
async def _fake_transcribe(*, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
|
||||||
|
_ = (prompt_text, image_bytes, mime_type)
|
||||||
return TranscriptionResult(
|
return TranscriptionResult(
|
||||||
text="Pipeline transcript",
|
text="Pipeline transcript",
|
||||||
provider="openrouter",
|
provider="openrouter",
|
||||||
prompt_name="transcribe_document.md",
|
|
||||||
model="test-model",
|
model="test-model",
|
||||||
|
prompt_name="transcribe_document.md",
|
||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _fake_transcribe)
|
async def _fake_transcribe_document_image(
|
||||||
|
image_path,
|
||||||
|
*,
|
||||||
|
prompt_name="transcribe_document.md",
|
||||||
|
settings=None,
|
||||||
|
provider=None,
|
||||||
|
) -> TranscriptionResult:
|
||||||
|
_ = (image_path, prompt_name, settings, provider)
|
||||||
|
return TranscriptionResult(
|
||||||
|
text="Pipeline transcript",
|
||||||
|
provider="openrouter",
|
||||||
|
model="test-model",
|
||||||
|
prompt_name="transcribe_document.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"transcription.services.workflows.transcribe_document_image",
|
||||||
|
_fake_transcribe_document_image,
|
||||||
|
)
|
||||||
|
|
||||||
processed = await process_next_queued_job(session=async_session)
|
processed = await process_next_queued_job(session=async_session)
|
||||||
job = await async_session.get(Job, upload_result.job_id)
|
job = await async_session.get(Job, upload_result.job_id)
|
||||||
transcript = (await async_session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id))).first()
|
|
||||||
revisions = (
|
|
||||||
await async_session.exec(
|
|
||||||
select(TranscriptRevision)
|
|
||||||
.where(TranscriptRevision.job_id == upload_result.job_id)
|
|
||||||
.order_by(TranscriptRevision.version_number)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
|
|
||||||
assert processed is True
|
assert processed is True
|
||||||
assert job is not None
|
assert job is not None
|
||||||
assert job.status == JobStatus.TRANSCRIBED
|
assert job.status == JobStatus.TRANSCRIBED
|
||||||
assert transcript is not None
|
assert job.text == "Pipeline transcript"
|
||||||
assert transcript.text == "Pipeline transcript"
|
assert job.error_detail is None
|
||||||
assert transcript.error_detail is None
|
|
||||||
assert transcript.model == "test-model"
|
|
||||||
assert len(revisions) == 1
|
|
||||||
assert revisions[0].version_number == 1
|
|
||||||
assert revisions[0].source == "ai"
|
|
||||||
assert revisions[0].text == "Pipeline transcript"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -67,7 +74,7 @@ class TestPipelineFailureFlow:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upload_then_worker_persists_failed_terminal_state(self, async_session, tmp_path: Path, monkeypatch):
|
async def test_upload_then_worker_persists_failed_terminal_state(self, async_session, tmp_path: Path, monkeypatch):
|
||||||
"""Upload followed by worker processing persists error detail and failed status."""
|
"""Upload followed by worker processing persists error detail and failed status on the job."""
|
||||||
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
||||||
upload_result = await create_upload_job(
|
upload_result = await create_upload_job(
|
||||||
filename="pipeline.jpg",
|
filename="pipeline.jpg",
|
||||||
@@ -76,32 +83,29 @@ class TestPipelineFailureFlow:
|
|||||||
settings=settings,
|
settings=settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _fake_transcribe(_path: str) -> TranscriptionResult:
|
async def _fake_transcribe_document_image(
|
||||||
|
image_path,
|
||||||
|
*,
|
||||||
|
prompt_name="transcribe_document.md",
|
||||||
|
settings=None,
|
||||||
|
provider=None,
|
||||||
|
) -> TranscriptionResult:
|
||||||
|
_ = (image_path, prompt_name, settings, provider)
|
||||||
raise RuntimeError("pipeline provider failure")
|
raise RuntimeError("pipeline provider failure")
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _fake_transcribe)
|
monkeypatch.setattr(
|
||||||
|
"transcription.services.workflows.transcribe_document_image",
|
||||||
|
_fake_transcribe_document_image,
|
||||||
|
)
|
||||||
|
|
||||||
processed = await process_next_queued_job(session=async_session)
|
processed = await process_next_queued_job(session=async_session)
|
||||||
job = await async_session.get(Job, upload_result.job_id)
|
job = await async_session.get(Job, upload_result.job_id)
|
||||||
transcript = (await async_session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id))).first()
|
|
||||||
revisions = (
|
|
||||||
await async_session.exec(
|
|
||||||
select(TranscriptRevision)
|
|
||||||
.where(TranscriptRevision.job_id == upload_result.job_id)
|
|
||||||
.order_by(TranscriptRevision.version_number)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
|
|
||||||
assert processed is True
|
assert processed is True
|
||||||
assert job is not None
|
assert job is not None
|
||||||
assert job.status == JobStatus.FAILED
|
assert job.status == JobStatus.FAILED
|
||||||
assert transcript is not None
|
assert job.text is None
|
||||||
assert transcript.text is None
|
assert job.error_detail is not None
|
||||||
assert "pipeline provider failure" in transcript.error_detail
|
assert "pipeline provider failure" in job.error_detail
|
||||||
assert "[internal_unexpected_error]" in transcript.error_detail
|
assert "[internal_unexpected_error]" in job.error_detail
|
||||||
assert "error_id=" in transcript.error_detail
|
assert "error_id=" in job.error_detail
|
||||||
assert len(revisions) == 1
|
|
||||||
assert revisions[0].version_number == 1
|
|
||||||
assert revisions[0].source == "ai"
|
|
||||||
assert revisions[0].text is None
|
|
||||||
assert "pipeline provider failure" in (revisions[0].error_detail or "")
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ from types import SimpleNamespace
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.providers.base import ProviderError, ProviderResponseError
|
from transcription.providers.base import ProviderError
|
||||||
from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL, OpenRouterTranscriptionProvider
|
from transcription.providers.base import ProviderResponseError
|
||||||
|
from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL
|
||||||
|
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
|
||||||
|
|
||||||
|
|
||||||
class _FakeChat:
|
class _FakeChat:
|
||||||
@@ -15,7 +17,7 @@ class _FakeChat:
|
|||||||
self._error = error
|
self._error = error
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
|
||||||
def send(self, **kwargs):
|
async def send_async(self, **kwargs):
|
||||||
self.calls.append(kwargs)
|
self.calls.append(kwargs)
|
||||||
if self._error:
|
if self._error:
|
||||||
raise self._error
|
raise self._error
|
||||||
@@ -48,7 +50,8 @@ class TestOpenRouterProviderInit:
|
|||||||
class TestOpenRouterProviderTranscribe:
|
class TestOpenRouterProviderTranscribe:
|
||||||
"""Verify OpenRouter request construction and response parsing."""
|
"""Verify OpenRouter request construction and response parsing."""
|
||||||
|
|
||||||
def test_includes_optional_referer_and_title_when_set(self):
|
@pytest.mark.asyncio
|
||||||
|
async def test_includes_optional_referer_and_title_when_set(self):
|
||||||
"""Transcribe sends app attribution fields when configured."""
|
"""Transcribe sends app attribution fields when configured."""
|
||||||
response = {"model": "vendor/model-a", "choices": [{"message": {"content": "Transcript text"}}]}
|
response = {"model": "vendor/model-a", "choices": [{"message": {"content": "Transcript text"}}]}
|
||||||
client = _FakeClient(response=response)
|
client = _FakeClient(response=response)
|
||||||
@@ -59,7 +62,7 @@ class TestOpenRouterProviderTranscribe:
|
|||||||
)
|
)
|
||||||
provider = OpenRouterTranscriptionProvider(settings=settings, client=client)
|
provider = OpenRouterTranscriptionProvider(settings=settings, client=client)
|
||||||
|
|
||||||
result = provider.transcribe(
|
result = await provider.transcribe(
|
||||||
prompt_text="Prompt body",
|
prompt_text="Prompt body",
|
||||||
image_bytes=b"img-bytes",
|
image_bytes=b"img-bytes",
|
||||||
mime_type="image/png",
|
mime_type="image/png",
|
||||||
@@ -70,7 +73,8 @@ class TestOpenRouterProviderTranscribe:
|
|||||||
assert send_call["x_open_router_title"] == "Transcription App"
|
assert send_call["x_open_router_title"] == "Transcription App"
|
||||||
assert result.text == "Transcript text"
|
assert result.text == "Transcript text"
|
||||||
|
|
||||||
def test_parses_successful_response_text(self):
|
@pytest.mark.asyncio
|
||||||
|
async def test_parses_successful_response_text(self):
|
||||||
"""Transcribe returns normalized text from a valid response payload."""
|
"""Transcribe returns normalized text from a valid response payload."""
|
||||||
response = {
|
response = {
|
||||||
"model": "vendor/model-b",
|
"model": "vendor/model-b",
|
||||||
@@ -81,7 +85,7 @@ class TestOpenRouterProviderTranscribe:
|
|||||||
client=_FakeClient(response=response),
|
client=_FakeClient(response=response),
|
||||||
)
|
)
|
||||||
|
|
||||||
result = provider.transcribe(
|
result = await provider.transcribe(
|
||||||
prompt_text="Prompt body",
|
prompt_text="Prompt body",
|
||||||
image_bytes=b"img-bytes",
|
image_bytes=b"img-bytes",
|
||||||
mime_type="image/jpeg",
|
mime_type="image/jpeg",
|
||||||
@@ -91,7 +95,8 @@ class TestOpenRouterProviderTranscribe:
|
|||||||
assert result.provider == "openrouter"
|
assert result.provider == "openrouter"
|
||||||
assert result.model == "vendor/model-b"
|
assert result.model == "vendor/model-b"
|
||||||
|
|
||||||
def test_maps_sdk_exception_to_provider_error(self):
|
@pytest.mark.asyncio
|
||||||
|
async def test_maps_sdk_exception_to_provider_error(self):
|
||||||
"""Transcribe converts SDK failures to ProviderError."""
|
"""Transcribe converts SDK failures to ProviderError."""
|
||||||
provider = OpenRouterTranscriptionProvider(
|
provider = OpenRouterTranscriptionProvider(
|
||||||
settings=Settings(openrouter_api_key="test-key"),
|
settings=Settings(openrouter_api_key="test-key"),
|
||||||
@@ -99,13 +104,14 @@ class TestOpenRouterProviderTranscribe:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ProviderError):
|
with pytest.raises(ProviderError):
|
||||||
provider.transcribe(
|
await provider.transcribe(
|
||||||
prompt_text="Prompt body",
|
prompt_text="Prompt body",
|
||||||
image_bytes=b"img-bytes",
|
image_bytes=b"img-bytes",
|
||||||
mime_type="image/png",
|
mime_type="image/png",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_raises_on_empty_or_invalid_response(self):
|
@pytest.mark.asyncio
|
||||||
|
async def test_raises_on_empty_or_invalid_response(self):
|
||||||
"""Transcribe raises ProviderResponseError for missing completion text."""
|
"""Transcribe raises ProviderResponseError for missing completion text."""
|
||||||
provider = OpenRouterTranscriptionProvider(
|
provider = OpenRouterTranscriptionProvider(
|
||||||
settings=Settings(openrouter_api_key="test-key"),
|
settings=Settings(openrouter_api_key="test-key"),
|
||||||
@@ -113,7 +119,7 @@ class TestOpenRouterProviderTranscribe:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ProviderResponseError):
|
with pytest.raises(ProviderResponseError):
|
||||||
provider.transcribe(
|
await provider.transcribe(
|
||||||
prompt_text="Prompt body",
|
prompt_text="Prompt body",
|
||||||
image_bytes=b"img-bytes",
|
image_bytes=b"img-bytes",
|
||||||
mime_type="image/png",
|
mime_type="image/png",
|
||||||
|
|||||||
@@ -2,87 +2,99 @@ from uuid import uuid4
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from transcription.models import Document
|
from transcription.db.models import Document
|
||||||
from transcription.models import Job
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobStatus
|
||||||
|
from transcription.db.models import Source
|
||||||
from transcription.services.documents import DocumentService
|
from transcription.services.documents import DocumentService
|
||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
from transcription.services.jobs import JobStatus
|
|
||||||
|
|
||||||
|
|
||||||
class TestJobService:
|
class TestJobService:
|
||||||
class TestBasicCRUD:
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_job(self, job_service: JobService):
|
async def test_create_and_read_job(self, job_service: JobService, document_service: DocumentService):
|
||||||
"""Test creating a job."""
|
document = Document(id=uuid4(), name="test-bundle")
|
||||||
|
|
||||||
def fake_job_factory():
|
|
||||||
return Job(document_id=uuid4())
|
|
||||||
|
|
||||||
await job_service.create_job(job=fake_job_factory())
|
|
||||||
|
|
||||||
async with job_service._session_scope() as session:
|
|
||||||
for _ in range(10):
|
|
||||||
await job_service.create_job(job=fake_job_factory(), session=session)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_backpropagation(self, job_service: JobService, document_service: DocumentService):
|
|
||||||
"""Test that creating a job backpropagates to the related document."""
|
|
||||||
doc_id = uuid4()
|
|
||||||
document = Document(
|
|
||||||
id=doc_id,
|
|
||||||
filename="test.txt",
|
|
||||||
file_path="/path/to/test.txt",
|
|
||||||
)
|
|
||||||
await document_service.create_document(document=document)
|
await document_service.create_document(document=document)
|
||||||
job = Job(document_id=doc_id)
|
|
||||||
|
job = Job(document_id=document.id)
|
||||||
await job_service.create_job(job=job)
|
await job_service.create_job(job=job)
|
||||||
|
|
||||||
read_job = await job_service.read_job(job_id=job.id)
|
fetched = await job_service.read_job(job_id=job.id)
|
||||||
assert isinstance(read_job.document, Document)
|
assert fetched.id == job.id
|
||||||
assert read_job.document.id == document.id
|
assert fetched.document is not None
|
||||||
|
assert fetched.document.id == document.id
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reading_job(self, job_service: JobService):
|
async def test_update_job_state_updates_status_and_retry(
|
||||||
"""Test reading a job."""
|
self,
|
||||||
uuid = uuid4()
|
job_service: JobService,
|
||||||
await job_service.create_job(job=Job(id=uuid, document_id=uuid4()))
|
document_service: DocumentService,
|
||||||
job = await job_service.read_job(job_id=uuid)
|
):
|
||||||
assert job.id == uuid
|
document = Document(id=uuid4(), name="test-bundle")
|
||||||
|
await document_service.create_document(document=document)
|
||||||
|
|
||||||
|
job = Job(document_id=document.id)
|
||||||
|
await job_service.create_job(job=job)
|
||||||
|
|
||||||
|
updated = await job_service.update_job_state(
|
||||||
|
job_id=job.id,
|
||||||
|
status=JobStatus.PROCESSING,
|
||||||
|
retry_count_increment=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.status == JobStatus.PROCESSING
|
||||||
|
assert updated.retry_count == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_updating_job(self, job_service: JobService):
|
async def test_query_jobs_by_status(self, job_service: JobService, document_service: DocumentService):
|
||||||
"""Test updating a job."""
|
document = Document(id=uuid4(), name="query-doc")
|
||||||
uuid = uuid4()
|
await document_service.create_document(document=document)
|
||||||
job = Job(id=uuid, document_id=uuid4())
|
|
||||||
async with job_service._session_scope() as session:
|
|
||||||
await job_service.create_job(job=job, session=session)
|
|
||||||
job.status = JobStatus.PROCESSING
|
|
||||||
await job_service.update_job(job=job, session=session)
|
|
||||||
read_job = await job_service.read_job(job_id=uuid, session=session)
|
|
||||||
assert read_job == job
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
await job_service.create_job(job=Job(document_id=document.id, status=JobStatus.PROCESSING))
|
||||||
async def test_deleting_job(self, job_service: JobService):
|
await job_service.create_job(job=Job(document_id=document.id, status=JobStatus.QUEUED))
|
||||||
"""Test deleting a job."""
|
|
||||||
|
|
||||||
class TestServiceMethods:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_query_jobs(self, job_service: JobService):
|
|
||||||
"""Test querying jobs."""
|
|
||||||
await job_service.create_job(job=Job(document_id=uuid4(), status=JobStatus.PROCESSING))
|
|
||||||
result = await job_service.query_jobs(status=JobStatus.PROCESSING)
|
result = await job_service.query_jobs(status=JobStatus.PROCESSING)
|
||||||
jobs = {str(job.id).split("-")[0]: job.status for job in result}
|
assert len(result) == 1
|
||||||
assert len(jobs) == 1
|
assert result[0].status == JobStatus.PROCESSING
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_jobs(self, job_service: JobService):
|
async def test_query_jobs_by_source_filename(self, job_service: JobService, document_service: DocumentService):
|
||||||
"""Test listing jobs."""
|
document = Document(id=uuid4(), name="source-doc")
|
||||||
n = 5
|
await document_service.create_document(document=document)
|
||||||
for _ in range(n):
|
|
||||||
await job_service.create_job(job=Job(document_id=uuid4()))
|
job = Job(document_id=document.id)
|
||||||
jobs = await job_service.list_jobs()
|
await job_service.create_job(job=job)
|
||||||
assert len(jobs) == n
|
|
||||||
|
async with job_service._session_scope() as session:
|
||||||
|
session.add(
|
||||||
|
Source(
|
||||||
|
document_id=document.id,
|
||||||
|
job_id=job.id,
|
||||||
|
upload_name="letter.jpg",
|
||||||
|
filename="stored-letter.jpg",
|
||||||
|
file_path="/uploads/stored-letter.jpg",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
result = await job_service.query_jobs(filename="stored-letter.jpg")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].id == job.id
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mark_job_status(self, job_service: JobService):
|
async def test_read_next_queued_job_orders_by_created_date(
|
||||||
"""Test marking a job with a new status."""
|
self,
|
||||||
|
job_service: JobService,
|
||||||
|
document_service: DocumentService,
|
||||||
|
):
|
||||||
|
document = Document(id=uuid4(), name="ordered-doc")
|
||||||
|
await document_service.create_document(document=document)
|
||||||
|
|
||||||
|
first = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||||
|
second = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||||
|
await job_service.create_job(job=first)
|
||||||
|
await job_service.create_job(job=second)
|
||||||
|
|
||||||
|
next_job = await job_service.read_next_queued_job()
|
||||||
|
assert next_job is not None
|
||||||
|
assert next_job.id == first.id
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import pytest
|
|||||||
|
|
||||||
from transcription.services.transcription import transcribe_document_image
|
from transcription.services.transcription import transcribe_document_image
|
||||||
|
|
||||||
|
|
||||||
HAS_OPENROUTER_KEY = bool(os.getenv("OPENROUTER_API_KEY"))
|
HAS_OPENROUTER_KEY = bool(os.getenv("OPENROUTER_API_KEY"))
|
||||||
|
|
||||||
REAL_IMAGES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "real"
|
REAL_IMAGES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "real"
|
||||||
@@ -49,10 +48,11 @@ class TestRealImageExternalTranscription:
|
|||||||
assert REAL_IMAGES_DIR.exists()
|
assert REAL_IMAGES_DIR.exists()
|
||||||
assert _real_image_paths()
|
assert _real_image_paths()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("image_path", _real_image_paths(), ids=lambda p: p.name)
|
@pytest.mark.parametrize("image_path", _real_image_paths(), ids=lambda p: p.name)
|
||||||
def test_transcribes_real_image_fixture(self, image_path: Path):
|
async def test_transcribes_real_image_fixture(self, image_path: Path):
|
||||||
"""Real fixture image produces a non-empty transcription result."""
|
"""Real fixture image produces a non-empty transcription result."""
|
||||||
result = transcribe_document_image(image_path)
|
result = await transcribe_document_image(image_path)
|
||||||
assert result.provider == "openrouter"
|
assert result.provider == "openrouter"
|
||||||
assert isinstance(result.model, str) and result.model.strip()
|
assert isinstance(result.model, str) and result.model.strip()
|
||||||
assert isinstance(result.text, str) and result.text.strip()
|
assert isinstance(result.text, str) and result.text.strip()
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Tests for revision behavior in TranscriptionService."""
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobStatus
|
||||||
|
from transcription.db.models import Source
|
||||||
|
from transcription.services.documents import DocumentService
|
||||||
|
from transcription.services.jobs import JobService
|
||||||
|
from transcription.services.transcription import TranscriptionService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestTranscriptionServiceRevisionUpsert:
|
||||||
|
"""Verify optional single-revision create/update semantics."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upsert_revision_creates_new_revision(self, default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
jobs = JobService(session_factory=default_session_factory)
|
||||||
|
transcriptions = TranscriptionService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = Document(id=uuid4(), name="revision-create")
|
||||||
|
await documents.create_document(document=document)
|
||||||
|
|
||||||
|
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED, text="Original text")
|
||||||
|
await jobs.create_job(job=job)
|
||||||
|
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
job_id=job.id,
|
||||||
|
upload_name="source.jpg",
|
||||||
|
filename="source.jpg",
|
||||||
|
file_path="uploads/source.jpg",
|
||||||
|
)
|
||||||
|
async with transcriptions._session_scope() as session:
|
||||||
|
session.add(source)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(source)
|
||||||
|
|
||||||
|
revision = await transcriptions.upsert_revision_for_source(source_id=source.id, text="User revision")
|
||||||
|
fetched = await transcriptions.read_revision_by_source(source.id)
|
||||||
|
|
||||||
|
assert revision.source_id == source.id
|
||||||
|
assert revision.text == "User revision"
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.id == revision.id
|
||||||
|
assert fetched.text == "User revision"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upsert_revision_updates_existing_single_revision(self, default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
jobs = JobService(session_factory=default_session_factory)
|
||||||
|
transcriptions = TranscriptionService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = Document(id=uuid4(), name="revision-update")
|
||||||
|
await documents.create_document(document=document)
|
||||||
|
|
||||||
|
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED, text="Original text")
|
||||||
|
await jobs.create_job(job=job)
|
||||||
|
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
job_id=job.id,
|
||||||
|
upload_name="source.jpg",
|
||||||
|
filename="source.jpg",
|
||||||
|
file_path="uploads/source.jpg",
|
||||||
|
)
|
||||||
|
async with transcriptions._session_scope() as session:
|
||||||
|
session.add(source)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(source)
|
||||||
|
|
||||||
|
first = await transcriptions.upsert_revision_for_source(source_id=source.id, text="Revision v1")
|
||||||
|
second = await transcriptions.upsert_revision_for_source(source_id=source.id, text="Revision v2")
|
||||||
|
revisions = await transcriptions.list_revisions_by_job(job.id)
|
||||||
|
|
||||||
|
assert first.id == second.id
|
||||||
|
assert second.text == "Revision v2"
|
||||||
|
assert len(revisions) == 1
|
||||||
|
assert revisions[0].id == first.id
|
||||||
|
assert revisions[0].text == "Revision v2"
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Reliability tests for worker workflow timeout behavior."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobStatus
|
||||||
|
from transcription.db.models import Source
|
||||||
|
from transcription.services import ServiceBundle
|
||||||
|
from transcription.services.workflows import process_queued_job
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestWorkflowReliability:
|
||||||
|
"""Verify timeout and terminal-state reliability behavior."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_queued_job_timeout_marks_job_failed(self, default_session_factory, monkeypatch):
|
||||||
|
"""Provider timeout transitions a queued job to failed with error detail."""
|
||||||
|
services = ServiceBundle()
|
||||||
|
object.__setattr__(
|
||||||
|
services,
|
||||||
|
"documents",
|
||||||
|
services.documents.__class__(session_factory=default_session_factory),
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
services,
|
||||||
|
"jobs",
|
||||||
|
services.jobs.__class__(session_factory=default_session_factory),
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
services,
|
||||||
|
"transcriptions",
|
||||||
|
services.transcriptions.__class__(session_factory=default_session_factory),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with services.jobs._session_scope() as session:
|
||||||
|
document = Document(id=uuid4(), name="timeout-doc")
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
job = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||||
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
job_id=job.id,
|
||||||
|
upload_name="timeout.jpg",
|
||||||
|
filename="timeout.jpg",
|
||||||
|
file_path=str(Path("tests/fixtures/images/real/Book Two - page 02.jpg")),
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
loaded = await services.jobs.read_job(job_id=job.id, session=session)
|
||||||
|
|
||||||
|
async def _never_returns(image_path, *, prompt_name="transcribe_document.md", settings=None, provider=None):
|
||||||
|
_ = (image_path, prompt_name, settings, provider)
|
||||||
|
raise TimeoutError("simulated provider timeout")
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _never_returns)
|
||||||
|
|
||||||
|
timeout_settings = Settings(openrouter_api_key="test-key", worker_provider_timeout_seconds=20.0)
|
||||||
|
result = await process_queued_job(job=loaded, services=services, settings=timeout_settings)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.status == JobStatus.FAILED
|
||||||
|
assert result.error_detail is not None
|
||||||
|
assert "timed out" in result.error_detail.lower()
|
||||||
|
assert "20.0s" in result.error_detail
|
||||||
+64
-29
@@ -1,5 +1,7 @@
|
|||||||
"""Tests for transcription.app."""
|
"""Tests for transcription.app."""
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -21,28 +23,43 @@ class TestAppFactory:
|
|||||||
class TestAppLifespan:
|
class TestAppLifespan:
|
||||||
"""Verify startup and shutdown lifecycle behavior."""
|
"""Verify startup and shutdown lifecycle behavior."""
|
||||||
|
|
||||||
def test_startup_initializes_runtime_dependencies(self, monkeypatch):
|
def test_startup_initializes_runtime_dependencies(self, monkeypatch, tmp_path):
|
||||||
"""Startup initializes logging, schema, directories, and worker resources."""
|
"""Startup initializes logging, schema, directories, and worker resources."""
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.app.setup_logging", lambda: calls.append("logging"))
|
monkeypatch.setattr("transcription.app.configure_logging", lambda _settings: calls.append("logging"))
|
||||||
monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: calls.append("schema"))
|
|
||||||
|
async def _create_all(**_kwargs):
|
||||||
|
calls.append("schema")
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.app.create_all", _create_all)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"transcription.app.initialize_database_runtime",
|
"transcription.app.initialize_database_runtime",
|
||||||
lambda **_kwargs: type("_Runtime", (), {"engine": object()})(),
|
lambda **_kwargs: type("_Runtime", (), {"engine": object(), "session_factory": object()})(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("transcription.app.dispose_database_runtime", lambda: calls.append("dispose_db"))
|
|
||||||
monkeypatch.setattr("transcription.app.should_bootstrap_schema", lambda _settings: True)
|
|
||||||
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
|
|
||||||
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
|
|
||||||
|
|
||||||
class _Dir:
|
async def _dispose_runtime():
|
||||||
def mkdir(self, parents: bool, exist_ok: bool):
|
calls.append("dispose_db")
|
||||||
calls.append("mkdir")
|
|
||||||
|
monkeypatch.setattr("transcription.app.dispose_database_runtime", _dispose_runtime)
|
||||||
|
|
||||||
|
async def _recover_stale(_app):
|
||||||
|
calls.append("recover")
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.app._recover_stale_processing_jobs", _recover_stale)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _worker_lifespan(**_kwargs):
|
||||||
|
calls.append("worker_start")
|
||||||
|
yield object(), object()
|
||||||
|
calls.append("worker_stop")
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.app.worker_consumer_lifespan", _worker_lifespan)
|
||||||
|
|
||||||
class _Settings:
|
class _Settings:
|
||||||
upload_dir = _Dir()
|
should_bootstrap_schema = True
|
||||||
prompt_dir = _Dir()
|
upload_dir = tmp_path / "uploads"
|
||||||
|
prompt_dir = tmp_path / "prompts"
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
|
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
|
||||||
|
|
||||||
@@ -52,32 +69,50 @@ class TestAppLifespan:
|
|||||||
|
|
||||||
assert "logging" in calls
|
assert "logging" in calls
|
||||||
assert "schema" in calls
|
assert "schema" in calls
|
||||||
assert "mkdir" in calls
|
assert "recover" in calls
|
||||||
assert "start_worker" in calls
|
assert "worker_start" in calls
|
||||||
|
assert "worker_stop" in calls
|
||||||
assert "dispose_db" in calls
|
assert "dispose_db" in calls
|
||||||
|
assert _Settings.upload_dir.exists()
|
||||||
|
assert _Settings.prompt_dir.exists()
|
||||||
|
|
||||||
def test_shutdown_stops_worker_resources(self, monkeypatch):
|
def test_shutdown_stops_worker_resources(self, monkeypatch, tmp_path):
|
||||||
"""Shutdown signals and stops worker resources cleanly."""
|
"""Shutdown signals and stops worker resources cleanly."""
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.app.setup_logging", lambda: None)
|
monkeypatch.setattr("transcription.app.configure_logging", lambda _settings: calls.append("logging"))
|
||||||
monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: None)
|
|
||||||
|
async def _create_all(**_kwargs):
|
||||||
|
calls.append("schema")
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.app.create_all", _create_all)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"transcription.app.initialize_database_runtime",
|
"transcription.app.initialize_database_runtime",
|
||||||
lambda **_kwargs: type("_Runtime", (), {"engine": object()})(),
|
lambda **_kwargs: type("_Runtime", (), {"engine": object(), "session_factory": object()})(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("transcription.app.dispose_database_runtime", lambda: calls.append("dispose_db"))
|
|
||||||
monkeypatch.setattr("transcription.app.should_bootstrap_schema", lambda _settings: True)
|
|
||||||
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
|
|
||||||
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
|
|
||||||
|
|
||||||
class _Dir:
|
async def _dispose_runtime():
|
||||||
def mkdir(self, parents: bool, exist_ok: bool):
|
calls.append("dispose_db")
|
||||||
return None
|
|
||||||
|
monkeypatch.setattr("transcription.app.dispose_database_runtime", _dispose_runtime)
|
||||||
|
|
||||||
|
async def _recover_stale(_app):
|
||||||
|
calls.append("recover")
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.app._recover_stale_processing_jobs", _recover_stale)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _worker_lifespan(**_kwargs):
|
||||||
|
calls.append("worker_start")
|
||||||
|
yield object(), object()
|
||||||
|
calls.append("worker_stop")
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.app.worker_consumer_lifespan", _worker_lifespan)
|
||||||
|
|
||||||
class _Settings:
|
class _Settings:
|
||||||
upload_dir = _Dir()
|
should_bootstrap_schema = True
|
||||||
prompt_dir = _Dir()
|
upload_dir = tmp_path / "uploads"
|
||||||
|
prompt_dir = tmp_path / "prompts"
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
|
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
|
||||||
|
|
||||||
@@ -85,4 +120,4 @@ class TestAppLifespan:
|
|||||||
with TestClient(app):
|
with TestClient(app):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
assert calls == ["start_worker", "stop_worker", "dispose_db"]
|
assert calls == ["logging", "schema", "recover", "worker_start", "worker_stop", "dispose_db"]
|
||||||
|
|||||||
+27
-1
@@ -5,7 +5,9 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from transcription.config import Provider, Settings
|
from transcription.config import Provider
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.config import parse_cli_settings
|
||||||
|
|
||||||
|
|
||||||
def _make_settings(**overrides) -> Settings:
|
def _make_settings(**overrides) -> Settings:
|
||||||
@@ -30,6 +32,30 @@ class TestSettingsLoading:
|
|||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
Settings(_env_file=None)
|
Settings(_env_file=None)
|
||||||
|
|
||||||
|
def test_ignores_process_cli_arguments(self, monkeypatch):
|
||||||
|
"""Ordinary settings construction does not consume tooling arguments."""
|
||||||
|
monkeypatch.setattr("sys.argv", ["pytest", "--rootdir=/tmp/project"])
|
||||||
|
|
||||||
|
settings = _make_settings()
|
||||||
|
|
||||||
|
assert settings.port == 8000
|
||||||
|
|
||||||
|
def test_explicit_cli_parser_reads_arguments(self):
|
||||||
|
"""The executable settings boundary accepts application CLI flags."""
|
||||||
|
settings = parse_cli_settings(
|
||||||
|
[
|
||||||
|
"--openrouter-api-key",
|
||||||
|
"test-key",
|
||||||
|
"--port",
|
||||||
|
"8123",
|
||||||
|
"--reload",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert settings.openrouter_api_key == "test-key"
|
||||||
|
assert settings.port == 8123
|
||||||
|
assert settings.reload is True
|
||||||
|
|
||||||
|
|
||||||
class TestProviderSettings:
|
class TestProviderSettings:
|
||||||
"""Verify provider enum defaults and validation."""
|
"""Verify provider enum defaults and validation."""
|
||||||
|
|||||||
+41
-44
@@ -1,68 +1,65 @@
|
|||||||
"""Tests for transcription.db — async schema bootstrap/runtime behavior."""
|
"""Tests for transcription.db runtime and schema bootstrap behavior."""
|
||||||
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
from sqlalchemy import text
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.config import SqliteSettings
|
||||||
|
from transcription.db import create_all
|
||||||
|
from transcription.db import dispose_database_runtime
|
||||||
|
from transcription.db import initialize_database_runtime
|
||||||
|
from transcription.db import session_scope
|
||||||
|
|
||||||
|
|
||||||
class TestSchemaBootstrap:
|
@pytest.mark.asyncio
|
||||||
"""Verify async create_all produces the expected table set."""
|
async def test_create_all_creates_expected_tables(tmp_path):
|
||||||
|
settings = Settings(
|
||||||
|
openrouter_api_key="test-key",
|
||||||
|
database=SqliteSettings(path=str(tmp_path / "schema.db")),
|
||||||
|
environment="test",
|
||||||
|
)
|
||||||
|
runtime = initialize_database_runtime(settings=settings)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
try:
|
||||||
async def test_create_all_creates_expected_tables(self, default_settings):
|
await create_all(engine=runtime.engine)
|
||||||
"""After async create_all(), document/job/transcript/revision tables exist."""
|
async with runtime.engine.connect() as conn:
|
||||||
# Ensure models are imported so metadata is populated.
|
table_names = set(await conn.run_sync(lambda c: inspect(c).get_table_names()))
|
||||||
from transcription.models import Document, Job, Transcript, TranscriptRevision # noqa: F401
|
|
||||||
|
|
||||||
from transcription.db.operations import create_all
|
|
||||||
from transcription.db.runtime import get_engine
|
|
||||||
|
|
||||||
engine = get_engine(settings=default_settings)
|
|
||||||
await create_all(engine=engine)
|
|
||||||
|
|
||||||
async with engine.begin() as connection:
|
|
||||||
table_names = set(await connection.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names()))
|
|
||||||
|
|
||||||
assert "document" in table_names
|
assert "document" in table_names
|
||||||
assert "job" in table_names
|
assert "job" in table_names
|
||||||
assert "transcript" in table_names
|
assert "source" in table_names
|
||||||
assert "transcriptrevision" in table_names
|
assert "revision" in table_names
|
||||||
|
finally:
|
||||||
|
await dispose_database_runtime()
|
||||||
|
|
||||||
|
|
||||||
class TestSessionFactory:
|
@pytest.mark.asyncio
|
||||||
"""Verify async get_session yields a usable AsyncSession."""
|
async def test_get_session_yields_async_session(tmp_path):
|
||||||
|
settings = Settings(
|
||||||
|
openrouter_api_key="test-key",
|
||||||
|
database=SqliteSettings(path=str(tmp_path / "session.db")),
|
||||||
|
environment="test",
|
||||||
|
)
|
||||||
|
initialize_database_runtime(settings=settings)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
try:
|
||||||
async def test_get_session_yields_session(self, default_settings):
|
async with session_scope(settings=settings) as session:
|
||||||
"""get_session() yields an AsyncSession with a live connection."""
|
assert session is not None
|
||||||
from transcription.db.runtime import get_session
|
finally:
|
||||||
|
await dispose_database_runtime()
|
||||||
async with get_session(settings=default_settings) as session:
|
|
||||||
result = await session.exec(text("SELECT 1"))
|
|
||||||
assert result.first()[0] == 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestBootstrapPolicy:
|
def test_bootstrap_policy_production_defaults_false():
|
||||||
"""Verify startup schema bootstrap policy via Settings property."""
|
|
||||||
|
|
||||||
def test_production_defaults_to_no_bootstrap(self):
|
|
||||||
"""Production defaults to explicit non-bootstrap startup behavior."""
|
|
||||||
from transcription.config import Settings
|
|
||||||
|
|
||||||
settings = Settings(openrouter_api_key="test-key", environment="production")
|
settings = Settings(openrouter_api_key="test-key", environment="production")
|
||||||
assert settings.should_bootstrap_schema is False
|
assert settings.should_bootstrap_schema is False
|
||||||
|
|
||||||
def test_development_defaults_to_bootstrap(self):
|
|
||||||
"""Development defaults to schema bootstrap for local workflows."""
|
|
||||||
from transcription.config import Settings
|
|
||||||
|
|
||||||
|
def test_bootstrap_policy_development_defaults_true():
|
||||||
settings = Settings(openrouter_api_key="test-key", environment="development")
|
settings = Settings(openrouter_api_key="test-key", environment="development")
|
||||||
assert settings.should_bootstrap_schema is True
|
assert settings.should_bootstrap_schema is True
|
||||||
|
|
||||||
def test_explicit_override_wins(self):
|
|
||||||
"""Explicit bootstrap_schema_on_startup overrides environment default."""
|
|
||||||
from transcription.config import Settings
|
|
||||||
|
|
||||||
|
def test_bootstrap_policy_explicit_override_true():
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
openrouter_api_key="test-key",
|
openrouter_api_key="test-key",
|
||||||
environment="production",
|
environment="production",
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from transcription.errors import AppError, ErrorCategory, classify_unexpected_error, new_error_id
|
from transcription.errors import AppError
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
from transcription.errors import classify_unexpected_error
|
||||||
|
from transcription.errors import new_error_id
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Tests for the executable application entry point."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from transcription import __main__ as entrypoint
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_main_passes_constructed_app_to_uvicorn(monkeypatch):
|
||||||
|
"""Non-reload execution keeps the parsed settings instance in the app."""
|
||||||
|
settings = SimpleNamespace(host="127.0.0.1", port=8123, log_level="debug", reload=False)
|
||||||
|
application = object()
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def create_app(*, settings: object) -> object:
|
||||||
|
assert settings is expected_settings
|
||||||
|
return application
|
||||||
|
|
||||||
|
expected_settings = settings
|
||||||
|
monkeypatch.setattr(entrypoint, "parse_cli_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(entrypoint, "create_app", create_app)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
entrypoint.uvicorn,
|
||||||
|
"run",
|
||||||
|
lambda app, **kwargs: captured.update(application=app, **kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
entrypoint.main()
|
||||||
|
|
||||||
|
assert captured == {
|
||||||
|
"application": application,
|
||||||
|
"factory": False,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8123,
|
||||||
|
"log_level": "debug",
|
||||||
|
"reload": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_main_uses_cli_factory_for_reload(monkeypatch):
|
||||||
|
"""Reload execution gives Uvicorn an importable CLI-aware factory."""
|
||||||
|
settings = SimpleNamespace(host="127.0.0.1", port=8123, log_level="info", reload=True)
|
||||||
|
captured = {}
|
||||||
|
monkeypatch.setattr(entrypoint, "parse_cli_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
entrypoint.uvicorn,
|
||||||
|
"run",
|
||||||
|
lambda app, **kwargs: captured.update(application=app, **kwargs),
|
||||||
|
)
|
||||||
|
|
||||||
|
entrypoint.main()
|
||||||
|
|
||||||
|
assert captured["application"] == "transcription.__main__:create_cli_app"
|
||||||
|
assert captured["factory"] is True
|
||||||
|
assert captured["reload"] is True
|
||||||
+84
-222
@@ -1,31 +1,32 @@
|
|||||||
"""Tests for transcription.models — Document, Job, Transcript, TranscriptRevision models."""
|
"""Tests for transcription.models — Document, Source, Job, Revision persistence and relationships."""
|
||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobStatus
|
||||||
|
from transcription.db.models import Revision
|
||||||
|
from transcription.db.models import Source
|
||||||
|
|
||||||
|
|
||||||
def _make_document(**overrides) -> Document:
|
def _make_document(**overrides) -> Document:
|
||||||
"""Create a Document with sensible defaults."""
|
defaults = {"name": "letter bundle"}
|
||||||
defaults = {"filename": "letter.jpg", "file_path": "/uploads/letter.jpg"}
|
|
||||||
defaults.update(overrides)
|
defaults.update(overrides)
|
||||||
return Document(**defaults)
|
return Document(**defaults)
|
||||||
|
|
||||||
|
|
||||||
def _persist_document(session) -> Document:
|
def _persist_document(session) -> Document:
|
||||||
"""Create, persist, and return a Document."""
|
document = _make_document()
|
||||||
doc = _make_document()
|
session.add(document)
|
||||||
session.add(doc)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(doc)
|
session.refresh(document)
|
||||||
return doc
|
return document
|
||||||
|
|
||||||
|
|
||||||
def _persist_job(session, document: Document) -> Job:
|
def _persist_job(session, document: Document) -> Job:
|
||||||
"""Create, persist, and return a Job linked to a Document."""
|
|
||||||
job = Job(document_id=document.id)
|
job = Job(document_id=document.id)
|
||||||
session.add(job)
|
session.add(job)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -33,271 +34,132 @@ def _persist_job(session, document: Document) -> Job:
|
|||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentModel:
|
def _persist_source(session, document: Document, job: Job, **overrides) -> Source:
|
||||||
"""Verify Document creation and default field population."""
|
defaults = {
|
||||||
|
"document_id": document.id,
|
||||||
|
"job_id": job.id,
|
||||||
|
"upload_name": "letter.jpg",
|
||||||
|
"filename": "stored-letter.jpg",
|
||||||
|
"file_path": "/uploads/stored-letter.jpg",
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
source = Source(**defaults)
|
||||||
|
session.add(source)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(source)
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
class TestDocumentModel:
|
||||||
def test_can_be_persisted(self, session):
|
def test_can_be_persisted(self, session):
|
||||||
"""A Document round-trips through the database with correct fields."""
|
document = _persist_document(session)
|
||||||
doc = _persist_document(session)
|
fetched = session.get(Document, document.id)
|
||||||
fetched = session.get(Document, doc.id)
|
|
||||||
assert fetched is not None
|
assert fetched is not None
|
||||||
assert fetched.filename == "letter.jpg"
|
assert fetched.name == "letter bundle"
|
||||||
assert fetched.file_path == "/uploads/letter.jpg"
|
|
||||||
|
|
||||||
def test_defaults_are_populated(self, session):
|
def test_defaults_are_populated(self, session):
|
||||||
"""id is a UUID and uploaded_at is populated on creation."""
|
document = _persist_document(session)
|
||||||
doc = _persist_document(session)
|
assert isinstance(document.id, UUID)
|
||||||
assert isinstance(doc.id, UUID)
|
|
||||||
assert doc.uploaded_at is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestJobModel:
|
class TestJobModel:
|
||||||
"""Verify Job creation, defaults, and status transitions."""
|
|
||||||
|
|
||||||
def test_can_be_created_for_document(self, session):
|
def test_can_be_created_for_document(self, session):
|
||||||
"""A Job linked to a Document via FK persists correctly."""
|
document = _persist_document(session)
|
||||||
doc = _persist_document(session)
|
job = _persist_job(session, document)
|
||||||
job = _persist_job(session, doc)
|
|
||||||
fetched = session.get(Job, job.id)
|
fetched = session.get(Job, job.id)
|
||||||
assert fetched is not None
|
assert fetched is not None
|
||||||
assert fetched.document_id == doc.id
|
assert fetched.document_id == document.id
|
||||||
|
|
||||||
def test_defaults_are_populated(self, session):
|
def test_defaults_are_populated(self, session):
|
||||||
"""Default status is queued; created_at and updated_at are populated."""
|
document = _persist_document(session)
|
||||||
doc = _persist_document(session)
|
job = _persist_job(session, document)
|
||||||
job = _persist_job(session, doc)
|
|
||||||
assert job.status == JobStatus.QUEUED
|
assert job.status == JobStatus.QUEUED
|
||||||
assert job.retry_count == 0
|
assert job.retry_count == 0
|
||||||
assert job.created_at is not None
|
assert job.date_created is not None
|
||||||
assert job.updated_at is not None
|
assert job.date_updated is not None
|
||||||
|
|
||||||
def test_transitions_to_transcribed(self, session):
|
def test_transitions_to_transcribed(self, session):
|
||||||
"""Status updates from queued to processing to transcribed."""
|
document = _persist_document(session)
|
||||||
doc = _persist_document(session)
|
job = _persist_job(session, document)
|
||||||
job = _persist_job(session, doc)
|
|
||||||
assert job.status == JobStatus.QUEUED
|
|
||||||
|
|
||||||
job.status = JobStatus.PROCESSING
|
job.status = JobStatus.PROCESSING
|
||||||
session.add(job)
|
session.add(job)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(job)
|
session.refresh(job)
|
||||||
assert job.status == JobStatus.PROCESSING
|
|
||||||
|
|
||||||
job.status = JobStatus.TRANSCRIBED
|
job.status = JobStatus.TRANSCRIBED
|
||||||
session.add(job)
|
session.add(job)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(job)
|
session.refresh(job)
|
||||||
|
|
||||||
assert job.status == JobStatus.TRANSCRIBED
|
assert job.status == JobStatus.TRANSCRIBED
|
||||||
|
|
||||||
def test_transitions_to_failed(self, session):
|
|
||||||
"""Status updates from processing to failed."""
|
|
||||||
doc = _persist_document(session)
|
|
||||||
job = _persist_job(session, doc)
|
|
||||||
|
|
||||||
job.status = JobStatus.PROCESSING
|
class TestSourceModel:
|
||||||
session.add(job)
|
def test_can_be_created_for_document_and_job(self, session):
|
||||||
session.commit()
|
document = _persist_document(session)
|
||||||
session.refresh(job)
|
job = _persist_job(session, document)
|
||||||
|
source = _persist_source(session, document, job)
|
||||||
|
|
||||||
job.status = JobStatus.FAILED
|
fetched = session.get(Source, source.id)
|
||||||
session.add(job)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(job)
|
|
||||||
assert job.status == JobStatus.FAILED
|
|
||||||
|
|
||||||
|
|
||||||
class TestTranscriptModel:
|
|
||||||
"""Verify Transcript persistence for success and failure cases."""
|
|
||||||
|
|
||||||
def test_success_record_persists(self, session):
|
|
||||||
"""A Transcript with text set and error_detail None persists correctly."""
|
|
||||||
doc = _persist_document(session)
|
|
||||||
job = _persist_job(session, doc)
|
|
||||||
transcript = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="Dear Sir, ...")
|
|
||||||
session.add(transcript)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(transcript)
|
|
||||||
|
|
||||||
fetched = session.get(Transcript, transcript.id)
|
|
||||||
assert fetched is not None
|
assert fetched is not None
|
||||||
assert fetched.text == "Dear Sir, ..."
|
assert fetched.document_id == document.id
|
||||||
assert fetched.error_detail is None
|
assert fetched.job_id == job.id
|
||||||
|
assert fetched.date_uploaded is not None
|
||||||
def test_failure_record_persists(self, session):
|
|
||||||
"""A Transcript with text None and error_detail set persists correctly."""
|
|
||||||
doc = _persist_document(session)
|
|
||||||
job = _persist_job(session, doc)
|
|
||||||
transcript = Transcript(
|
|
||||||
job_id=job.id,
|
|
||||||
provider="openrouter",
|
|
||||||
prompt_name="transcribe_document.md",
|
|
||||||
error_detail="Provider timeout",
|
|
||||||
)
|
|
||||||
session.add(transcript)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(transcript)
|
|
||||||
|
|
||||||
fetched = session.get(Transcript, transcript.id)
|
|
||||||
assert fetched is not None
|
|
||||||
assert fetched.text is None
|
|
||||||
assert fetched.error_detail == "Provider timeout"
|
|
||||||
|
|
||||||
def test_job_id_is_unique(self, session):
|
|
||||||
"""Inserting two transcripts with the same job_id raises an integrity error."""
|
|
||||||
doc = _persist_document(session)
|
|
||||||
job = _persist_job(session, doc)
|
|
||||||
|
|
||||||
t1 = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="First")
|
|
||||||
session.add(t1)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
t2 = Transcript(job_id=job.id, provider="openrouter", prompt_name="transcribe_document.md", text="Duplicate")
|
|
||||||
session.add(t2)
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class TestTranscriptRevisionModel:
|
class TestRevisionModel:
|
||||||
"""Verify TranscriptRevision persistence and version uniqueness constraints."""
|
def test_revision_persists_for_source(self, session):
|
||||||
|
document = _persist_document(session)
|
||||||
|
job = _persist_job(session, document)
|
||||||
|
source = _persist_source(session, document, job)
|
||||||
|
|
||||||
def test_revision_record_persists(self, session):
|
revision = Revision(source_id=source.id, text="Edited revision text")
|
||||||
"""A TranscriptRevision with version metadata persists correctly."""
|
|
||||||
doc = _persist_document(session)
|
|
||||||
job = _persist_job(session, doc)
|
|
||||||
revision = TranscriptRevision(
|
|
||||||
job_id=job.id,
|
|
||||||
version_number=1,
|
|
||||||
provider="openrouter",
|
|
||||||
prompt_name="transcribe_document.md",
|
|
||||||
model="google/gemini-2.5-flash",
|
|
||||||
source="ai",
|
|
||||||
text="Initial text",
|
|
||||||
)
|
|
||||||
session.add(revision)
|
session.add(revision)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(revision)
|
session.refresh(revision)
|
||||||
|
|
||||||
fetched = session.get(TranscriptRevision, revision.id)
|
fetched = session.get(Revision, revision.id)
|
||||||
assert fetched is not None
|
assert fetched is not None
|
||||||
assert fetched.version_number == 1
|
assert fetched.text == "Edited revision text"
|
||||||
assert fetched.text == "Initial text"
|
assert fetched.date_created is not None
|
||||||
assert fetched.source == "ai"
|
|
||||||
|
|
||||||
def test_job_version_pair_is_unique(self, session):
|
def test_source_id_is_unique(self, session):
|
||||||
"""Duplicate version_number for same job raises integrity error."""
|
document = _persist_document(session)
|
||||||
doc = _persist_document(session)
|
job = _persist_job(session, document)
|
||||||
job = _persist_job(session, doc)
|
source = _persist_source(session, document, job)
|
||||||
|
|
||||||
first = TranscriptRevision(
|
first = Revision(source_id=source.id, text="First")
|
||||||
job_id=job.id,
|
|
||||||
version_number=1,
|
|
||||||
provider="openrouter",
|
|
||||||
prompt_name="transcribe_document.md",
|
|
||||||
source="ai",
|
|
||||||
text="Initial",
|
|
||||||
)
|
|
||||||
duplicate = TranscriptRevision(
|
|
||||||
job_id=job.id,
|
|
||||||
version_number=1,
|
|
||||||
provider="openrouter",
|
|
||||||
prompt_name="transcribe_document.md",
|
|
||||||
source="user",
|
|
||||||
text="Edited",
|
|
||||||
)
|
|
||||||
session.add(first)
|
session.add(first)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
duplicate = Revision(source_id=source.id, text="Duplicate")
|
||||||
session.add(duplicate)
|
session.add(duplicate)
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
def test_same_version_number_allowed_for_different_jobs(self, session):
|
|
||||||
"""Version numbers are scoped per job, not globally."""
|
|
||||||
doc1 = _persist_document(session)
|
|
||||||
job1 = _persist_job(session, doc1)
|
|
||||||
doc2 = _make_document(filename="letter2.jpg", file_path="/uploads/letter2.jpg")
|
|
||||||
session.add(doc2)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(doc2)
|
|
||||||
job2 = _persist_job(session, doc2)
|
|
||||||
|
|
||||||
r1 = TranscriptRevision(
|
|
||||||
job_id=job1.id,
|
|
||||||
version_number=1,
|
|
||||||
provider="openrouter",
|
|
||||||
prompt_name="transcribe_document.md",
|
|
||||||
source="ai",
|
|
||||||
text="Job1 v1",
|
|
||||||
)
|
|
||||||
r2 = TranscriptRevision(
|
|
||||||
job_id=job2.id,
|
|
||||||
version_number=1,
|
|
||||||
provider="openrouter",
|
|
||||||
prompt_name="transcribe_document.md",
|
|
||||||
source="ai",
|
|
||||||
text="Job2 v1",
|
|
||||||
)
|
|
||||||
session.add(r1)
|
|
||||||
session.add(r2)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class TestRelationships:
|
class TestRelationships:
|
||||||
"""Verify SQLModel relationship navigation between models."""
|
def test_document_exposes_jobs_and_sources(self, session):
|
||||||
|
document = _persist_document(session)
|
||||||
|
job = _persist_job(session, document)
|
||||||
|
_persist_source(session, document, job)
|
||||||
|
|
||||||
def test_document_exposes_jobs(self, session):
|
session.refresh(document)
|
||||||
"""document.jobs returns the linked Job list."""
|
assert len(document.jobs) == 1
|
||||||
doc = _persist_document(session)
|
assert len(document.sources) == 1
|
||||||
_persist_job(session, doc)
|
|
||||||
_persist_job(session, doc)
|
|
||||||
|
|
||||||
session.refresh(doc)
|
def test_source_exposes_optional_single_revision(self, session):
|
||||||
assert len(doc.jobs) == 2
|
document = _persist_document(session)
|
||||||
assert all(isinstance(j, Job) for j in doc.jobs)
|
job = _persist_job(session, document)
|
||||||
|
source = _persist_source(session, document, job)
|
||||||
|
|
||||||
def test_job_exposes_transcript(self, session):
|
assert source.revision is None
|
||||||
"""job.transcript returns the linked Transcript."""
|
|
||||||
doc = _persist_document(session)
|
revision = Revision(source_id=source.id, text="Edited")
|
||||||
job = _persist_job(session, doc)
|
session.add(revision)
|
||||||
transcript = Transcript(
|
|
||||||
job_id=job.id,
|
|
||||||
provider="openrouter",
|
|
||||||
prompt_name="transcribe_document.md",
|
|
||||||
text="Transcribed text",
|
|
||||||
)
|
|
||||||
session.add(transcript)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
session.refresh(job)
|
session.refresh(source)
|
||||||
assert job.transcript is not None
|
assert source.revision is not None
|
||||||
assert isinstance(job.transcript, Transcript)
|
assert source.revision.text == "Edited"
|
||||||
assert job.transcript.text == "Transcribed text"
|
|
||||||
|
|
||||||
def test_job_exposes_transcript_revisions(self, session):
|
|
||||||
"""job.transcript_revisions returns all linked revisions."""
|
|
||||||
doc = _persist_document(session)
|
|
||||||
job = _persist_job(session, doc)
|
|
||||||
session.add(
|
|
||||||
TranscriptRevision(
|
|
||||||
job_id=job.id,
|
|
||||||
version_number=1,
|
|
||||||
provider="openrouter",
|
|
||||||
prompt_name="transcribe_document.md",
|
|
||||||
source="ai",
|
|
||||||
text="v1",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
session.add(
|
|
||||||
TranscriptRevision(
|
|
||||||
job_id=job.id,
|
|
||||||
version_number=2,
|
|
||||||
provider="openrouter",
|
|
||||||
prompt_name="transcribe_document.md",
|
|
||||||
source="user",
|
|
||||||
text="v2",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
session.refresh(job)
|
|
||||||
assert len(job.transcript_revisions) == 2
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
PROMPT_PATH = Path("prompts/transcribe_document.md")
|
PROMPT_PATH = Path("prompts/transcribe_document.md")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,19 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
|
|||||||
"tests/integration/test_pipeline_flow.py",
|
"tests/integration/test_pipeline_flow.py",
|
||||||
],
|
],
|
||||||
"REQ-1": [
|
"REQ-1": [
|
||||||
"tests/services/test_upload.py",
|
"tests/integration/test_pipeline_flow.py",
|
||||||
"tests/ui/test_upload_page.py",
|
"tests/ui/test_upload_page.py",
|
||||||
],
|
],
|
||||||
"REQ-2": [
|
"REQ-2": [
|
||||||
"tests/services/test_worker.py",
|
"tests/services/test_workflows_reliability.py",
|
||||||
"tests/integration/test_pipeline_flow.py",
|
"tests/integration/test_pipeline_flow.py",
|
||||||
],
|
],
|
||||||
"REQ-3": [
|
"REQ-3": [
|
||||||
"tests/services/test_worker.py",
|
"tests/services/test_job_service.py",
|
||||||
"tests/ui/test_jobs_page.py",
|
"tests/ui/test_jobs_page.py",
|
||||||
],
|
],
|
||||||
"REQ-4": [
|
"REQ-4": [
|
||||||
"tests/services/test_worker.py",
|
"tests/services/test_workflows_reliability.py",
|
||||||
"tests/integration/test_pipeline_flow.py",
|
"tests/integration/test_pipeline_flow.py",
|
||||||
],
|
],
|
||||||
"REQ-5": [
|
"REQ-5": [
|
||||||
@@ -30,7 +30,7 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
|
|||||||
],
|
],
|
||||||
"REQ-6": [
|
"REQ-6": [
|
||||||
"tests/test_app.py",
|
"tests/test_app.py",
|
||||||
"tests/services/test_worker.py",
|
"tests/services/test_workflows_reliability.py",
|
||||||
],
|
],
|
||||||
"REQ-8": [
|
"REQ-8": [
|
||||||
"tests/test_app.py",
|
"tests/test_app.py",
|
||||||
@@ -38,7 +38,7 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
|
|||||||
],
|
],
|
||||||
"REQ-12": [
|
"REQ-12": [
|
||||||
"tests/test_prompts.py",
|
"tests/test_prompts.py",
|
||||||
"tests/services/test_transcription.py",
|
"tests/services/test_transcription_external.py",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Tests for global UI theme registration."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from transcription.ui import register_pages
|
||||||
|
from transcription.ui.resources import read_css
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_page_registration_uses_vibescribe_theme(monkeypatch):
|
||||||
|
"""Global UI registration loads the standalone VibeScribe theme in light mode."""
|
||||||
|
registered_css: list[str] = []
|
||||||
|
run_options: dict[str, object] = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.ui.ui.add_css", lambda css, **_kwargs: registered_css.append(css))
|
||||||
|
monkeypatch.setattr("transcription.ui.register_upload_page", lambda: None)
|
||||||
|
monkeypatch.setattr("transcription.ui.register_jobs_page", lambda: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"transcription.ui.ui.run_with",
|
||||||
|
lambda _app, **options: run_options.update(options),
|
||||||
|
)
|
||||||
|
|
||||||
|
register_pages(FastAPI())
|
||||||
|
|
||||||
|
theme_css = read_css("theme.css")
|
||||||
|
assert registered_css == [theme_css]
|
||||||
|
assert set(re.findall(r"#[0-9a-fA-F]{6}", theme_css)) == {
|
||||||
|
"#1c2321",
|
||||||
|
"#7d98a1",
|
||||||
|
"#5e6572",
|
||||||
|
"#a9b4c2",
|
||||||
|
"#eef1ef",
|
||||||
|
}
|
||||||
|
assert "--q-primary" in theme_css
|
||||||
|
assert run_options["dark"] is False
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Shared fixtures for UI integration tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
from collections.abc import Generator
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import delete
|
||||||
|
|
||||||
|
from transcription.app import create_app
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.config import SqliteSettings
|
||||||
|
from transcription.db import create_all
|
||||||
|
from transcription.db import initialize_database_runtime
|
||||||
|
from transcription.db import session_scope
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobStatus
|
||||||
|
from transcription.db.models import Revision
|
||||||
|
from transcription.db.models import Source
|
||||||
|
|
||||||
|
RevisionSeed = str
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def app_client(tmp_path_factory: pytest.TempPathFactory) -> Generator[tuple[FastAPI, TestClient]]:
|
||||||
|
"""Provide a real application and test client backed by in-memory SQLite."""
|
||||||
|
tmp_path = tmp_path_factory.mktemp("ui")
|
||||||
|
settings = Settings(
|
||||||
|
openrouter_api_key="test-key",
|
||||||
|
database=SqliteSettings(path=":memory:"),
|
||||||
|
environment="test",
|
||||||
|
bootstrap_schema_on_startup=True,
|
||||||
|
upload_dir=tmp_path / "uploads",
|
||||||
|
prompt_dir=tmp_path / "prompts",
|
||||||
|
)
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||||
|
asyncio.run(create_all(engine=app.state.runtime.engine))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield app, client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
|
||||||
|
"""Reset UI-facing tables before each test for isolation."""
|
||||||
|
app, _ = app_client
|
||||||
|
|
||||||
|
async def _clear() -> None:
|
||||||
|
async with session_scope() as session:
|
||||||
|
await session.exec(delete(Revision))
|
||||||
|
await session.exec(delete(Source))
|
||||||
|
await session.exec(delete(Job))
|
||||||
|
await session.exec(delete(Document))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
asyncio.run(_clear())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
||||||
|
"""Return a helper for inserting a document/job/source/(optional revision) tuple."""
|
||||||
|
app, _ = app_client
|
||||||
|
fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid"
|
||||||
|
|
||||||
|
def _seed(
|
||||||
|
*,
|
||||||
|
filename: str = "sample.pdf",
|
||||||
|
status: JobStatus = JobStatus.TRANSCRIBED,
|
||||||
|
transcription_text: str | None = "Sample transcript text",
|
||||||
|
error_detail: str | None = None,
|
||||||
|
revision_text: RevisionSeed | None = None,
|
||||||
|
source_file: Path | None = None,
|
||||||
|
) -> UUID:
|
||||||
|
async def _insert() -> UUID:
|
||||||
|
async with session_scope() as session:
|
||||||
|
stored_path = app.state.settings.upload_dir / filename
|
||||||
|
stored_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
source_path = source_file or fixtures_dir / "small_png.png"
|
||||||
|
stored_path.write_bytes(source_path.read_bytes())
|
||||||
|
|
||||||
|
document = Document(name=filename)
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
job = Job(
|
||||||
|
document_id=document.id,
|
||||||
|
status=status,
|
||||||
|
retry_count=0,
|
||||||
|
text=transcription_text,
|
||||||
|
error_detail=error_detail,
|
||||||
|
provider="openrouter",
|
||||||
|
model="google/gemini-2.5-flash",
|
||||||
|
prompt_name="transcribe_document.md",
|
||||||
|
)
|
||||||
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
job_id=job.id,
|
||||||
|
upload_name=filename,
|
||||||
|
filename=filename,
|
||||||
|
file_path=str(stored_path),
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
if revision_text is not None:
|
||||||
|
session.add(
|
||||||
|
Revision(
|
||||||
|
source_id=source.id,
|
||||||
|
text=revision_text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return job.id
|
||||||
|
|
||||||
|
return asyncio.run(_insert())
|
||||||
|
|
||||||
|
return _seed
|
||||||
+95
-25
@@ -1,37 +1,107 @@
|
|||||||
"""Tests for the jobs page route."""
|
"""Tests for the jobs page route."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from transcription.ui import register_pages
|
from transcription.db.models import JobStatus
|
||||||
from transcription.ui.pages import jobs_page
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client(monkeypatch):
|
|
||||||
"""Provide a minimal app client with jobs data patched for rendering."""
|
|
||||||
|
|
||||||
async def _fetch_jobs_stub():
|
|
||||||
return []
|
|
||||||
|
|
||||||
monkeypatch.setattr(jobs_page, "fetch_jobs", _fetch_jobs_stub)
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
register_pages(app)
|
|
||||||
with TestClient(app) as test_client:
|
|
||||||
yield test_client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
class TestPageRendering:
|
class TestPageRendering:
|
||||||
"""Verify the jobs page is available and includes the main controls."""
|
"""Verify jobs routes render correctly with real app wiring."""
|
||||||
|
|
||||||
def test_jobs_page_renders_expected_controls(self, client):
|
def test_jobs_page_renders_empty_state(self, app_client):
|
||||||
"""GET /ui/jobs returns the page shell and jobs controls."""
|
"""GET /ui/jobs renders the page and empty-state text when no jobs exist."""
|
||||||
|
_, client = app_client
|
||||||
response = client.get("/ui/jobs")
|
response = client.get("/ui/jobs")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Transcription Jobs" in response.text
|
assert "No jobs yet." in response.text
|
||||||
assert "Refresh" in response.text
|
|
||||||
assert "Back to upload" in response.text
|
def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
|
||||||
|
"""GET /ui/jobs lists seeded jobs from the in-memory database."""
|
||||||
|
_, client = app_client
|
||||||
|
seed_job(filename="sample.pdf", status=JobStatus.TRANSCRIBED, transcription_text="done")
|
||||||
|
|
||||||
|
response = client.get("/ui/jobs")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "sample.pdf" in response.text
|
||||||
|
assert "transcribed" in response.text
|
||||||
|
|
||||||
|
def test_job_detail_page_renders_seeded_job(self, app_client, seed_job):
|
||||||
|
"""GET /ui/jobs/{job_id} renders detail content for a real seeded job."""
|
||||||
|
_, client = app_client
|
||||||
|
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "single_page_pdf.pdf"
|
||||||
|
job_id = seed_job(
|
||||||
|
filename="detail.pdf",
|
||||||
|
status=JobStatus.TRANSCRIBED,
|
||||||
|
transcription_text="original text",
|
||||||
|
revision_text="hello",
|
||||||
|
source_file=fixture_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(f"/ui/jobs/{job_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Original Transcription" in response.text
|
||||||
|
assert "detail.pdf" in response.text
|
||||||
|
assert "Revision" in response.text
|
||||||
|
assert "Revision" in response.text
|
||||||
|
assert "hello" in response.text
|
||||||
|
assert "original text" in response.text
|
||||||
|
assert "Document preview" in response.text
|
||||||
|
assert "/uploads/detail.pdf" in response.text
|
||||||
|
|
||||||
|
def test_job_detail_page_rejects_invalid_id(self, app_client):
|
||||||
|
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
|
||||||
|
_, client = app_client
|
||||||
|
response = client.get("/ui/jobs/not-a-uuid")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Invalid job id" in response.text
|
||||||
|
|
||||||
|
def test_job_detail_page_handles_missing_job(self, app_client):
|
||||||
|
"""GET /ui/jobs/{job_id} shows not-found state for unknown IDs."""
|
||||||
|
_, client = app_client
|
||||||
|
missing_id = uuid4()
|
||||||
|
response = client.get(f"/ui/jobs/{missing_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Job not found" in response.text
|
||||||
|
|
||||||
|
def test_job_detail_page_shows_revision_editor_when_none_exists(self, app_client, seed_job):
|
||||||
|
"""GET /ui/jobs/{job_id} renders revision editor and create action for sources with no revision."""
|
||||||
|
_, client = app_client
|
||||||
|
job_id = seed_job(
|
||||||
|
filename="no-revision.pdf",
|
||||||
|
status=JobStatus.TRANSCRIBED,
|
||||||
|
transcription_text="original text",
|
||||||
|
revision_text=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(f"/ui/jobs/{job_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Revision Editor" in response.text
|
||||||
|
assert "Create revision" in response.text
|
||||||
|
assert "No revision exists for this source." in response.text
|
||||||
|
|
||||||
|
def test_job_detail_page_shows_update_action_for_existing_revision(self, app_client, seed_job):
|
||||||
|
"""GET /ui/jobs/{job_id} renders revision editor with update action when revision exists."""
|
||||||
|
_, client = app_client
|
||||||
|
job_id = seed_job(
|
||||||
|
filename="with-revision.pdf",
|
||||||
|
status=JobStatus.TRANSCRIBED,
|
||||||
|
transcription_text="original text",
|
||||||
|
revision_text="hello",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(f"/ui/jobs/{job_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Revision Editor" in response.text
|
||||||
|
assert "Update revision" in response.text
|
||||||
|
assert "hello" in response.text
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user