104 Commits
Author SHA1 Message Date
John Lancaster f7192a33dc document form 2026-08-04 00:36:27 -05:00
John Lancaster 5932c0d3a1 global theming 2026-08-03 22:56:04 -05:00
John Lancaster edb6967888 split up docs pages 2026-08-03 22:25:07 -05:00
Jim Lancaster 759d4c2434 UI style refresh: Very close!!! 2026-08-03 19:46:32 -05:00
Jim Lancaster 323f12d911 UI style refresh: Final cleanup 2026-08-03 16:27:02 -05:00
Jim Lancaster f80834d589 UI style refresh: extract reusable components and refactor 2026-08-03 15:28:58 -05:00
Jim Lancaster 752346025b UI style refresh continued 2026-08-03 15:15:58 -05:00
Jim Lancaster 4f6e1fd913 UI style refresh with Gemini's help 2026-08-03 13:54:24 -05:00
Jim Lancaster 47aef0e26e UI slog grinds on 2026-08-02 23:44:53 -05:00
Jim Lancaster c098013a68 UI slog continues 2026-08-02 20:03:02 -05:00
Jim Lancaster 49e2e48df1 UI updates continue. Focus on Sources 2026-08-02 18:41:09 -05:00
Jim Lancaster 0ab7ad50f2 UI updates, changes sync'd to UI docs 2026-08-02 18:20:38 -05:00
Jim Lancaster 9653060c2a UI update complete? 2026-08-02 13:33:09 -05:00
Jim Lancaster ed6f9dfe25 UI update planning complete 2026-08-02 11:33:19 -05:00
Jim Lancaster 5946867ff3 UI update initial phase complete. Still need to create schema-mapping for the two many-to-many tables. 2026-08-02 11:23:04 -05:00
Jim Lancaster 646a360aca UI update planning continued 2026-08-02 10:00:03 -05:00
Jim Lancaster 2b3d33e50e Begin UI update starting with Document table. 2026-08-02 08:02:58 -05:00
Jim Lancaster dfe6f121ff V2 (new) sStep 4 complete. (untested, unreviewed, cross my fingers) 2026-08-01 18:28:00 -05:00
Jim Lancaster 51ac2d0b98 V2 step 3 complete 2026-08-01 16:24:44 -05:00
Jim Lancaster 61cc8a200b V2 step 2 complete 2026-08-01 16:17:38 -05:00
Jim Lancaster c46d1bd0bc V2 implementation step 1 2026-08-01 15:34:06 -05:00
Jim Lancaster 00ed176ac1 Merge branch 'session-engine' of https://gitea.john-stream.com/bbchops/transcription into session-engine 2026-08-01 14:47:39 -05:00
Jim Lancaster 99a128e981 Reorganized docs, checked docs for internal consistency and made adjustments 2026-08-01 14:47:29 -05:00
John Lancaster aa94f34de4 pruned ddl 2026-08-01 10:35:21 -05:00
John Lancaster 661e2b1bec smoothed readme and startup 2026-08-01 09:51:21 -05:00
John Lancaster d75083a666 shutdown fixes 2026-08-01 09:36:34 -05:00
John Lancaster d0a3ca0289 WIP theming 2026-07-31 19:34:12 -05:00
John Lancaster 209c48987c separated cli settings 2026-07-31 19:23:12 -05:00
John Lancaster 4ed1f43eda ui instructions 2026-07-31 16:01:23 -05:00
John Lancaster ce8fcce6b0 Merge commit '4ae8e5be4f60059ba611ceea0cf553b009e6a88e' into session-engine 2026-07-31 15:40:20 -05:00
Jim Lancaster 4ae8e5be4f Test UI diagrams 2026-07-31 11:35:37 -05:00
John Lancaster 6b5b0500b3 unified implementation plan 2026-07-31 10:28:47 -05:00
John Lancaster bbf7fe28c2 cleanup 2026-07-31 10:20:02 -05:00
John Lancaster c4d25c1be8 Merge remote-tracking branch 'origin/doc_update' into session-engine 2026-07-31 10:16:17 -05:00
John Lancaster 1fa5eb1127 doc updates for pydantic 2026-07-31 10:12:34 -05:00
Jim Lancaster 3eefc36239 Update V1 & V2 core documents and reorganize docs folder 2026-07-31 10:04:07 -05:00
John Lancaster ec6617a1c4 updates 2026-07-30 23:16:17 -05:00
John Lancaster 9eb0f40c08 session and engine 2026-07-30 22:33:49 -05:00
John Lancaster f769d29da1 uv.lock update 2026-07-30 21:17:09 -05:00
John Lancaster 8afc462a6d startup 2026-07-30 21:16:48 -05:00
John Lancaster 3d6daec561 moved models to db pkg 2026-07-30 21:10:13 -05:00
John Lancaster 1cc2f319d5 uvicorn startup 2026-07-30 21:09:51 -05:00
Jim Lancaster d2b793ea69 Begin planning V2 2026-07-30 19:39:12 -05:00
Jim Lancaster a975ca299a Trouble shooting PDF transcriptions 2026-07-29 18:58:52 -05:00
Jim Lancaster a3b3bab571 V1 mostly complete except for some testing. Linting in the last step changed nearly every file which is why this commit is so larger. 2026-07-29 17:27:21 -05:00
Jim Lancaster bc21a97019 Updated test suite 2026-07-29 16:20:46 -05:00
Jim Lancaster 0973311d9f Update documentation for consistency and refactor the code. An unresolved error in testing still exists. 2026-07-29 14:12:18 -05:00
Jim Lancaster eaf9805121 Updates to docs. Added new transcription_methodology, revised approach to revisions: 1 revision per document (that can be updated) 2026-07-29 13:29:44 -05:00
Jim Lancaster ec61013b47 Used co-pilot for complete review of all documentation, including extensive revision of v1.md 2026-07-02 12:37:32 -05:00
Jim Lancaster 97cb7055d4 Updated models.py 2026-07-02 10:23:06 -05:00
Jim Lancaster f975e25093 minor update to docs 2026-07-01 12:59:50 -05:00
Jim Lancaster 90ba8fefdd Update docs after db restructure 2026-07-01 12:57:13 -05:00
John Lancaster b8998025e2 delete button on transcript header 2026-06-29 20:35:21 -05:00
John Lancaster 002eb572e9 header slot 2026-06-29 20:19:19 -05:00
John Lancaster d44c7de684 gitignore updates 2026-06-29 19:05:24 -05:00
John Lancaster 282b0fb967 ui test updates 2026-06-29 19:05:15 -05:00
John Lancaster a9a47c3906 model used being carried thru 2026-06-29 19:04:55 -05:00
John Lancaster 9ada09accf job detail page stuff 2026-06-29 18:33:22 -05:00
John Lancaster 67b0980664 reworked zooming 2026-06-29 18:00:22 -05:00
John Lancaster e35a8ec060 coloring and page tweaks 2026-06-29 17:34:05 -05:00
John Lancaster 3a141bd4cc page tweaks 2026-06-29 14:08:35 -05:00
John Lancaster 8129f5a9e8 ui instructions 2026-06-29 13:59:36 -05:00
John Lancaster 58b4c381a4 added revisions to transcription table 2026-06-29 13:59:26 -05:00
John Lancaster 5719debbaa messing with zoom/reset 2026-06-29 08:06:02 -05:00
John Lancaster e7c7ab71b4 zooming fix 2026-06-28 22:20:48 -05:00
John Lancaster ca5c9f787f started panzoom thing 2026-06-28 22:10:06 -05:00
John Lancaster 8064821503 app_shell tweak 2026-06-28 15:19:32 -05:00
John Lancaster 593388ef3a test updates 2026-06-28 15:18:31 -05:00
John Lancaster 83ee7b31e0 styling 2026-06-28 15:18:06 -05:00
John Lancaster 455a01d7c4 started app shell 2026-06-28 14:58:29 -05:00
John Lancaster e2e421835f job_detail 2026-06-28 14:40:58 -05:00
John Lancaster 57c1d22bb9 jobs table 2026-06-28 14:36:14 -05:00
John Lancaster dba96e7a72 ui redirects 2026-06-28 14:19:03 -05:00
John Lancaster 912cfd44de fixture updates 2026-06-28 13:55:02 -05:00
John Lancaster 8d5fee886f pruned jobs page 2026-06-28 13:44:49 -05:00
John Lancaster 34468c521f debug tweaks 2026-06-28 13:42:44 -05:00
John Lancaster b682e092ea test_upload_page 2026-06-28 13:42:05 -05:00
John Lancaster bd33e338b3 worker runtime fixes 2026-06-28 13:41:54 -05:00
John Lancaster 57a988973f debug config 2026-06-28 13:25:52 -05:00
John Lancaster 9ad67d55e8 worker lifespan updates 2026-06-28 13:17:02 -05:00
John Lancaster 7bdc0c6f79 types 2026-06-28 12:59:36 -05:00
John Lancaster 4e4bf50219 boundaries 2026-06-28 12:33:57 -05:00
John Lancaster c409b42077 transaction boundaries 2026-06-28 11:37:53 -05:00
John Lancaster f1fb45e0d2 async provider 2026-06-28 09:35:14 -05:00
John Lancaster f0501d919e _finalize method 2026-06-28 09:12:58 -05:00
John Lancaster 517d01abe2 service instructions 2026-06-28 08:59:17 -05:00
John Lancaster d967f58358 services 2026-06-28 08:16:28 -05:00
John Lancaster 2000f0096b pruning 2026-06-27 22:13:55 -05:00
John Lancaster aa93080d7d project updates 2026-06-27 22:13:41 -05:00
John Lancaster 52dbf70304 process_job updates 2026-06-27 21:56:52 -05:00
John Lancaster f0b359edf8 service updates 2026-06-27 21:48:10 -05:00
John Lancaster e6f12fa993 job service tests 2026-06-27 19:29:51 -05:00
John Lancaster d3ffb01e93 test settings 2026-06-27 19:15:56 -05:00
John Lancaster f0b0109b11 started test_job_service 2026-06-27 19:04:21 -05:00
John Lancaster f4417a0f64 test stubs 2026-06-27 18:05:10 -05:00
John Lancaster cbb91c4cf6 session scope 2026-06-27 17:48:07 -05:00
John Lancaster 2d73065d63 services updates 2026-06-27 11:11:43 -05:00
John Lancaster e75ca4c79a service base 2026-06-27 10:38:17 -05:00
John Lancaster 96cbadd56e upload service stuff 2026-06-27 10:10:05 -05:00
John Lancaster f2aadf7e53 job service 2026-06-27 09:29:29 -05:00
John Lancaster 755f908b6a worker updates 2026-06-26 19:17:44 -05:00
John Lancaster a16c6f5ecd project tools 2026-06-26 19:17:33 -05:00
John Lancaster 621f508c26 docker stuff 2026-06-26 19:17:18 -05:00
John Lancaster b719d95f4b pruning 2026-06-26 19:17:04 -05:00
173 changed files with 16255 additions and 6833 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.gitignore
.vscode
.venv
.pytest_cache
.ruff_cache
__pycache__/
*.py[cod]
*.db
.env
tests/
docs/
uploads/
@@ -0,0 +1,77 @@
---
description: Follow these guidelines when editing the services
applyTo: 'src/transcription/services/*.py'
---
# Services
## Structure
- Project core data models defined in [models](../../src/transcription/models.py)
- 1 service class per data model
- Only services directly interact with the database, and only through async methods
- Services are completely independent of one another. Any operation that needs to use more than a single service, which is most of them, needs to have a separate orchestration function.
## Error Handling
- Service-specific errors defined at the top of the respective module and inherit from `AppError`
- Use a context manager for large `try/except` blocks like in [transcription](../../src/transcription/services/transcription.py)
## Checklist
- [ ] Uses `ServiceBase` for common logic
- [ ] CRUD methods created at the top
- [ ] Session kwarg for `AsyncSession` to pass in a session object to each method
- [ ] Services use `self._session_scope` in their methods to pass the session thru.
- Multiple operations on the same object(s) require sharing a session between all the methods used.
## CRUD Methods
- Create, read, update, and delete, created in that order
- Name format `<operation>_<model >`, for example `create_document` or `update_job`
- All services must define these 4 methods first, and in that order
## Transaction Finalization
When a service method accepts an optional `session` kwarg, write methods must use `self._finalize` to finalize the transaction properly according to whether or not they are sharing a session.
- If `session` is `None`: the method owns the transaction and should `commit()`.
- If `session` is provided: the method must **not** commit; it should `flush()` so IDs and FK values are available to the caller's transaction.
- Use `refresh()` on returned ORM objects when the caller needs DB-populated values (defaults, triggers, merged state).
Recommended helper behavior:
- Inputs: active session object, original `session` arg (or a boolean ownership flag), and an optional list of objects to refresh.
- Logic: `commit` when service-owned session, `flush` when caller-owned session, then refresh requested objects.
This keeps orchestration functions atomic: they can pass one shared session across multiple services and commit exactly once at the workflow boundary.
## Workflow Transaction Boundaries
For multi-step job lifecycles (for example queued transcription jobs), orchestration functions must use explicit transaction phases.
Required boundary model:
- **Transaction A (claim):** transition `JobStatus.QUEUED -> JobStatus.PROCESSING` and commit immediately.
- Perform provider/network work **outside** database transactions.
- **Transaction B (terminal success):** write transcript content and set `JobStatus.TRANSCRIBED` in the same shared-session commit.
- **Transaction B (terminal failure):** write transcript error detail and set `JobStatus.FAILED` in the same shared-session commit.
- **Transaction C (retry path):** write transcript error detail, increment retry count, and set `JobStatus.QUEUED` in one shared-session commit.
Atomicity rules:
- Never commit transcript updates separately from the paired terminal/retry job status change.
- Terminal state (`TRANSCRIBED` or `FAILED`) and transcript row changes must succeed or roll back together.
- Retry persistence (`QUEUED` + retry increment + error detail) must succeed or roll back together.
Separation of concerns:
- Worker modules should stay lightweight and delegate lifecycle transitions to service/workflow orchestration functions.
- In `workflows.py`, `process_queued_job` should own one complete attempt lifecycle: `QUEUED -> PROCESSING -> TRANSCRIBED|FAILED`.
- In `workflows.py`, `advance_job` should coordinate broader status progression around attempts (for example retry scheduling from `FAILED -> QUEUED`).
- Services should expose session-aware write helpers (flush on caller-owned session) so orchestration controls commit boundaries.
- Backoff/sleep behavior must run outside transactional scopes.
# Service Composition
Some operations, like uploading a picutre, require modifications to multiple tables, which can be done by composing methods from the service object into a separate function.
+53
View File
@@ -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.
+3
View File
@@ -14,3 +14,6 @@ wheels/
# SQLite database
*.db
# Document images
uploads/*
+23
View File
@@ -0,0 +1,23 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Debug transcription app",
"type": "debugpy",
"request": "launch",
"module": "debugpy",
"args": [
"-m",
"transcription",
"--host", "127.0.0.1",
"--port", "9999",
"--database.driver", "sqlite"
],
"justMyCode": true,
"console": "integratedTerminal",
"env": {
"PYTHONPATH": "${workspaceFolder}/src"
}
}
]
}
+47
View File
@@ -0,0 +1,47 @@
FROM python:3.12-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_LINK_MODE=copy
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:0.5.24 /uv /uvx /bin/
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-dev --no-install-project
COPY src ./src
COPY prompts ./prompts
RUN uv sync --frozen --no-dev
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/app/.venv/bin:$PATH" \
PYTHONPATH="/app/src" \
UPLOAD_DIR="/app/uploads" \
PROMPT_DIR="/app/prompts"
WORKDIR /app
RUN groupadd --system --gid 1001 appgroup \
&& useradd --system --uid 1001 --gid appgroup --create-home appuser
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src /app/src
COPY --from=builder /app/prompts /app/prompts
RUN mkdir -p /app/uploads /app/data \
&& chown -R appuser:appgroup /app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=3s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"
CMD ["uvicorn", "transcription.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
+78 -6
View File
@@ -22,30 +22,102 @@ uv sync
### 2) Configure environment
Create a `.env` file in the project root (minimum required setting shown):
Create a `.env` file in the project root with the required OpenRouter API key:
```env
OPENROUTER_API_KEY=your_openrouter_api_key
```
Optional settings (defaults shown):
Settings are read from CLI arguments first, then environment variables, then `.env`, then the defaults below.
### Configuration Source Precedence
When the same setting is provided in multiple places, the value is chosen in this order (highest priority first):
1. CLI arguments (for example `--port 9999`)
2. Settings constructor arguments (used mainly in tests)
3. Environment variables
4. `.env` file values
5. Model defaults in code
Practical examples:
- `--port 9999` overrides both `PORT=8000` in the shell and `PORT=7000` in `.env`.
- `DATABASE__PATH=prod.db` in the shell overrides `DATABASE__PATH=dev.db` in `.env`.
#### Server and runtime
| Environment variable | Default | Description |
| --- | --- | --- |
| `HOST` | `0.0.0.0` | Address on which the server listens. |
| `PORT` | `8000` | Server port. |
| `LOG_LEVEL` | `info` | Uvicorn and application log level. |
| `RELOAD` | `false` | Restart the development server when source files change. |
| `ENVIRONMENT` | `development` | Runtime environment: `development`, `test`, or `production`. |
#### Provider
| Environment variable | Default | Description |
| --- | --- | --- |
| `PROVIDER` | `openrouter` | Transcription provider. |
| `OPENROUTER_API_KEY` | Required | OpenRouter API key. |
| `PROVIDER_MODEL` | Provider default | Optional model override. |
| `OPENROUTER_HTTP_REFERER` | Unset | Optional OpenRouter attribution URL. |
| `OPENROUTER_APP_TITLE` | Unset | Optional OpenRouter attribution title. |
#### Database and files
Use nested env vars for database settings (recommended):
```env
DATABASE_URL=sqlite:///./transcription.db
DATABASE__DRIVER=sqlite
DATABASE__PATH=app.db
# BOOTSTRAP_SCHEMA_ON_STARTUP=true
SQLITE_CHECK_SAME_THREAD=false
UPLOAD_DIR=./uploads
PROMPT_DIR=./prompts
```
For PostgreSQL:
```env
DATABASE__DRIVER=postgres
DATABASE__HOST=localhost
DATABASE__PORT=5432
DATABASE__DATABASE=transcription
DATABASE__USER=postgres
DATABASE__PASSWORD=change-me
```
This uses Pydantic nested settings (`env_nested_delimiter='__'`) and avoids JSON blobs in `.env`. A top-level `DATABASE={...}` JSON value is still supported as a fallback, and nested keys such as `DATABASE__PATH` take precedence over conflicting JSON keys.
`BOOTSTRAP_SCHEMA_ON_STARTUP` creates missing tables when the app starts. When unset, it is enabled in `development` and `test`, and disabled in `production`; set it explicitly to override that policy. `SQLITE_CHECK_SAME_THREAD` defaults to `false`.
#### Worker
```env
WORKER_MAX_RETRIES=0
WORKER_RETRY_BACKOFF_SECONDS=0
WORKER_PROVIDER_TIMEOUT_SECONDS=20
WORKER_MIN_TRANSCRIPTION_CHARS=0
WORKER_MIN_TRANSCRIPTION_LINES=0
WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
```
### 3) Run the app
```bash
uv run uvicorn transcription.app:create_app --factory --reload
uv run python -m transcription --port 9999 --reload --database.driver sqlite --bootstrap-schema-on-startup
```
This starts the development server with SQLite, creates missing tables, and enables automatic reload. Run `uv run python -m transcription --help` for all CLI options; CLI names use kebab case and nested database options use dot notation, such as `--database.path ./data/transcription.db`.
### 4) Open in browser
- GUI: [http://[IP_ADDRESS]:8000/ui](http://[IP_ADDRESS]:8000/ui)
- Health check: [http://[IP_ADDRESS]:8000/healthz](http://[IP_ADDRESS]:8000/healthz)
- GUI: [http://localhost:9999/ui](http://localhost:9999/ui)
- Health check: [http://localhost:9999/healthz](http://localhost:9999/healthz)
Replace `localhost` with the server's hostname or IP address when connecting from another machine.
## How to navigate the GUI
+21
View File
@@ -0,0 +1,21 @@
services:
transcription:
build:
context: .
dockerfile: Dockerfile
container_name: transcription-app
env_file:
- .env
environment:
DATABASE_URL: sqlite:////app/data/transcription.db
UPLOAD_DIR: /app/uploads
PROMPT_DIR: /app/prompts
ports:
- "8002:8000"
volumes:
- ./uploads:/app/uploads
- transcription_data:/app/data
restart: unless-stopped
volumes:
transcription_data:
-40
View File
@@ -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>`&nbsp;&nbsp;a. Sub-point`<br>`&nbsp;&nbsp;b. Next point`<br>`III. [sic] Third Topic` |
@@ -1,35 +0,0 @@
# ADR-0001: Lifespan-owned runtime resources
- **Status:** accepted
- **Date:** 2026-06-25
## Context
MVP initialized core runtime resources (database engine and worker dependencies) through module-level globals and startup side effects. `REQ-7` requires lifespan-owned runtime resources with explicit ownership and cleanup.
## Decision
Adopt lifespan-owned runtime resource initialization in `transcription.app`:
1. Initialize database runtime during app lifespan startup.
2. Store runtime handles on `app.state`.
3. Pass runtime-owned dependencies (engine) to worker startup.
4. Dispose runtime resources explicitly during lifespan shutdown.
## Consequences
### Positive
- Explicit startup and shutdown ownership.
- Predictable cleanup ordering.
- Reduced hidden global side effects.
### Tradeoffs
- Minor wiring complexity in app startup.
- Some call-sites still support fallback lazy initialization for compatibility.
## Alternatives Considered
1. **Keep module-level global ownership**
- Rejected: conflicts with `REQ-7` and increases ambiguity.
2. **Introduce full async DB stack immediately**
- Rejected for Step 1: too broad for architecture-consolidation scope.
@@ -1,36 +0,0 @@
# ADR-0002: Explicit schema bootstrap policy
- **Status:** accepted
- **Date:** 2026-06-25
## Context
MVP called schema bootstrap (`create_all`) on every startup. `REQ-10` requires explicit, opt-in schema bootstrap behavior so normal production startup does not mutate schema.
## Decision
Add environment-aware bootstrap policy:
1. New settings:
- `environment`: `development` | `test` | `production`
- `bootstrap_schema_on_startup`: optional explicit override
2. Default behavior:
- Development/test: bootstrap enabled
- Production: bootstrap disabled
3. App startup calls `create_all` only when policy evaluates true.
## Consequences
### Positive
- Production startup behavior is safer and policy-driven.
- Local development remains simple by default.
### Tradeoffs
- Deployments now require explicit schema management in production.
## Alternatives Considered
1. **Always bootstrap in all environments**
- Rejected: violates `REQ-10` intent.
2. **Disable bootstrap everywhere immediately**
- Rejected: hurts local developer workflow without migration tool replacement yet.
@@ -1,31 +0,0 @@
# ADR-0003: Persistence baseline and transition path
- **Status:** accepted
- **Date:** 2026-06-25
## Context
Architecture targets PostgreSQL baseline (optional MongoDB), while MVP currently runs on SQLite by default. V1 needs a clear transition path without destabilizing ongoing work.
## Decision
1. Preserve database URL configurability through centralized settings.
2. Keep SQLite functional for local dev/test and fast feedback.
3. Treat PostgreSQL as production baseline target for V1 completion.
4. Keep persistence access behind `transcription.db` runtime/session access points.
## Consequences
### Positive
- Clear migration path without immediate broad rewrite.
- Controlled risk while preserving velocity.
### Tradeoffs
- Temporary dual-path assumptions (SQLite local vs PostgreSQL target).
## Alternatives Considered
1. **Immediate forced PostgreSQL-only migration**
- Rejected: higher short-term disruption risk.
2. **Remain SQLite-only for V1**
- Rejected: inconsistent with architecture and requirement trajectory.
@@ -1,32 +0,0 @@
# ADR-0004: In-process worker topology for V1
- **Status:** accepted
- **Date:** 2026-06-25
## Context
The current system uses an in-process background worker. Architecture docs allow this in foundation stage and permit later hardening (optional external worker/queue).
## Decision
Retain in-process worker topology for V1, with improved lifecycle ownership:
1. Worker starts/stops via app lifespan.
2. Worker receives runtime-owned DB engine dependency explicitly.
3. Extension path to external worker remains behind existing service/adapter seams.
## Consequences
### Positive
- Keeps operational complexity low for personal-scale use.
- Preserves delivery focus on V1 completion.
### Tradeoffs
- Throughput/scaling limits remain compared to external queue-based topology.
## Alternatives Considered
1. **Immediate queue/external worker introduction**
- Rejected: premature complexity for current scale.
2. **Ad hoc thread lifecycle management outside lifespan**
- Rejected: weaker shutdown guarantees and poorer ownership clarity.
-20
View File
@@ -1,20 +0,0 @@
# Architecture Decision Records (ADRs)
This directory records significant architecture decisions for Version 1.
## ADR Format
Each ADR should include:
1. **Status** (`proposed`, `accepted`, `superseded`)
2. **Context**
3. **Decision**
4. **Consequences**
5. **Alternatives Considered**
## Index
- [ADR-0001: Lifespan-owned runtime resources](ADR-0001-lifespan-owned-runtime-resources.md)
- [ADR-0002: Explicit schema bootstrap policy](ADR-0002-explicit-schema-bootstrap-policy.md)
- [ADR-0003: Persistence baseline and transition path](ADR-0003-persistence-baseline-and-transition-path.md)
- [ADR-0004: In-process worker topology for V1](ADR-0004-in-process-worker-topology.md)
+136
View File
@@ -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)
+88
View File
@@ -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)
+64
View File
@@ -0,0 +1,64 @@
# 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`.
### 4. Update the UI for the V2 schema
- Review the UI components and views that display document, job, person, and source data so they reference the V2 schema instead of V1 relationships.
- Update upload, detail, and listing screens to show the new person and source associations, revised-source fields, and the revised status values.
- Keep the UI behavior aligned with the updated service layer and ensure the existing UI tests continue to pass with the V2 data model.
- Consider the guidance in `docs/ui_style_guide.md` when making UI changes so the updated views remain consistent with the projects visual and interaction conventions.
## Done When
- 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
+47
View File
@@ -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)
+25
View File
@@ -0,0 +1,25 @@
# Historical Document Transcription Design Intent
I have several thousand pages of family history told through letters, postcards, books, and other documents that I want to transcribe to text.
---
## Goals
1. Preserve our family history
2. Unburden my family (and descendants) from having to store and care for the physical media. Once the documents have been transcribed and organized, they can be donated (or kept by a family member that wants to retain and preserve them).
3. Make the document text easily available and easily searchable.
4. Ability create timelines for individuals and/or families through document dates or the data contained in them. Perhaps even use AI to generate biographies or family histories.
---
## Source material
1. **letters, cards, diaries** - handwritten; mostly stored in boxes and tubs with little organization
2. **books** - typed or typeset; mostly self-published books 50-100 pages in length. This may be expanded to include selected pages from other publications.
3. **photos** - notes written on the backs of photos and the pages of photo albums
4. **other ephemera** - newspaper clippings, event programs, invitations, military records, immigration records, etc
---
## Methodology
1. Follow current best practices per **A Guide to Documentary Editing** by Mary-Jo Kline. (See [Transcription Methodology](transcription_methodology.md))
@@ -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.
+112
View File
@@ -0,0 +1,112 @@
# UI Style Guide (Invariant)
## 1. Purpose
This guide defines non-negotiable UI styling rules for the transcription application.
The design system is token-first and class-driven:
1. Theme tokens are defined in [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css).
2. Python UI code composes semantic classes instead of inline color values.
3. Pages and components should share a single visual language across Documents, Jobs, People, and Sources flows.
## 2. Source of Truth
Use these files as the style authority:
1. [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css) for color tokens, semantic utility classes, table styles, and viewer surfaces.
2. [src/transcription/ui/theme.py](src/transcription/ui/theme.py) for runtime NiceGUI theme bridge and shared UI helpers.
If this document conflicts with implementation, update this document to match the code immediately after intentional style changes.
## 3. Core Design Invariants
1. Flat, high-density surfaces over decorative depth.
2. Strong content hierarchy with subdued backgrounds and border-based separation.
3. Viewer area remains the highest contrast region in image/transcription workflows.
4. Primary actions are consistent and visually recognizable.
5. Accessible focus rings are always visible for keyboard users.
## 4. Token System
### 4.1 Palette Tokens
Base palette variables live under :root in [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css):
1. --palette-carbon-black: #1c2321
2. --palette-cool-steel: #7d98a1
3. --palette-blue-slate: #5e6572
4. --palette-powder-blue: #a9b4c2
5. --palette-platinum: #eef1ef
### 4.2 Semantic Theme Tokens
Do not style components directly with palette tokens when a semantic token exists.
Semantic tokens currently include:
1. --theme-text and --theme-text-muted
2. --theme-page, --theme-surface, --theme-surface-raised, --theme-surface-muted
3. --theme-border
4. --theme-primary and --theme-primary-hover
5. --theme-secondary and --theme-focus
6. --theme-inverse-text
7. --theme-viewer, --theme-viewer-border, --theme-viewer-muted
## 5. Approved Semantic Classes
### 5.1 Text and Background
1. ui-text-primary
2. ui-text-muted
3. ui-text-inverse
4. ui-bg-page
5. ui-bg-surface
6. ui-bg-surface-raised
7. ui-bg-surface-muted
8. ui-bg-viewer
9. ui-bg-viewer-overlay
10. ui-bg-viewer-overlay-soft
### 5.2 Borders and Surfaces
1. ui-border-subtle
2. ui-border-viewer
3. ui-header-divider
4. ui-card-surface
5. ui-row-surface
6. ui-note-box
### 5.3 Interactive Elements
1. ui-btn-primary
2. ui-btn-secondary
3. ui-link-primary
4. ui-text-accent
### 5.4 Table Patterns
1. ui-table
2. ui-table-header
3. ui-table-body
Use existing class combinations from [src/transcription/ui/components](src/transcription/ui/components) and [src/transcription/ui/pages](src/transcription/ui/pages) as reference implementations.
## 6. Legacy Class Policy
Legacy classes with vibe- prefix still exist in a few components and are allowed only for compatibility while migrating:
1. Existing usage may remain temporarily.
2. New usage of vibe- classes is not allowed.
3. When touching a file that uses vibe- classes, prefer migrating it to ui- semantic classes in the same change when safe.
Current legacy usage examples are in:
1. [src/transcription/ui/components/document_panzoom.py](src/transcription/ui/components/document_panzoom.py)
2. [src/transcription/ui/components/error_presenter.py](src/transcription/ui/components/error_presenter.py)
3. [src/transcription/ui/components/transcript.py](src/transcription/ui/components/transcript.py)
## 7. Prohibited Patterns
1. Inline hex colors in Python UI class strings or style blocks, except in isolated bridge code explicitly marked for migration.
2. Ad-hoc one-off class names that duplicate existing semantic class intent.
3. Page-specific palette forks that bypass theme tokens.
4. Hidden or low-contrast focus states on interactive controls.
## 8. Implementation Rules For Contributors
1. Prefer composing existing semantic classes before creating new ones.
2. If a new class is required, add it to [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css) with a semantic name, then reuse it.
3. Keep behavior ownership in Python and appearance ownership in CSS.
4. Update UI tests that assert exact text or labels when intentional copy changes are made.
5. Avoid introducing class churn unrelated to the feature being changed.
## 9. Verification Checklist
Before merging UI changes, verify:
1. No new inline hex colors were introduced in UI pages/components.
2. New styles are token-backed and added to [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css).
3. Primary buttons, links, cards, and tables still render with consistent semantics.
4. Keyboard focus ring visibility is preserved.
5. Relevant UI and integration tests pass.
-572
View File
@@ -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.
-278
View File
@@ -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
- dont 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
-236
View File
@@ -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.
-262
View File
@@ -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
-70
View File
@@ -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
Youll see one warning from FastAPI/Starlette test client about `httpx` deprecation; it does not affect correctness and all tests pass.
-310
View File
@@ -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 14 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
---
-50
View File
@@ -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.)
-229
View File
@@ -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 15)
- 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)
-134
View File
@@ -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.
-267
View File
@@ -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
View File
@@ -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 2050 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.
+42
View File
@@ -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)
+136
View File
@@ -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)
+107
View File
@@ -0,0 +1,107 @@
from nicegui import ui
# 1. Mature Dark Mode Setup
ui.dark_mode(True)
# Define a refined dark palette using expanded dictionary styling
theme_colors = {
'primary': '#6366f1',
'secondary': '#8b5cf6',
'accent': '#ec4899',
'dark': '#0f172a',
'dark_page': '#020617',
'positive': '#10b981',
'negative': '#ef4444',
}
ui.colors(**theme_colors)
# Optional: Add custom CSS for subtle noise overlays or kinetic typography
ui.add_css('''
.glass-card {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 1.5rem;
}
''')
# 2. Bento Grid Layout
with ui.element('div').classes('grid grid-cols-1 md:grid-cols-4 gap-6 w-full max-w-6xl mx-auto p-8'):
# Header spanning all columns
with ui.element('div').classes('col-span-1 md:col-span-4 mb-4'):
ui.label('Analytics Dashboard').classes('text-4xl font-extrabold tracking-tight text-white')
ui.label('AI-driven insights for Q3').classes('text-lg text-slate-400 mt-1')
# Large Feature Card (Glassmorphism + Functional Motion)
with ui.element('div').classes('glass-card col-span-1 md:col-span-2 p-6 transition-transform duration-300 hover:scale-[1.02]'):
ui.icon('monitoring', size='2rem').classes('text-primary mb-4')
ui.label('Revenue Prediction').classes('text-xl font-semibold text-slate-100')
ui.label('$45,231.00').classes('text-5xl font-bold text-white mt-2')
# Placeholder for an interactive EChart
ui.echart({
'xAxis': {
'type': 'category',
'data': [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
],
},
'yAxis': {
'type': 'value',
},
'series': [
{
'data': [
120,
200,
150,
80,
70,
],
'type': 'bar',
'itemStyle': {
'color': '#6366f1',
},
},
],
}).classes('w-full h-48 mt-4')
# Smaller Metric Cards
metric_cards = [
{
'title': 'Active Users',
'value': '1,204',
'icon': 'group',
'color': 'text-secondary',
},
{
'title': 'Server Load',
'value': '34%',
'icon': 'memory',
'color': 'text-accent',
},
]
for card in metric_cards:
with ui.element('div').classes('glass-card col-span-1 p-6 flex flex-col justify-between transition-transform duration-300 hover:-translate-y-1'):
ui.icon(card['icon'], size='2rem').classes(card['color'])
ui.element('div').classes('flex-grow')
ui.label(card['value']).classes('text-4xl font-bold text-white mt-4')
ui.label(card['title']).classes('text-sm font-medium text-slate-400 uppercase tracking-wider')
# AI Assistant Module (Adaptive Interface)
with ui.element('div').classes('glass-card col-span-1 md:col-span-4 p-6 flex items-center gap-4'):
ui.icon('smart_toy', size='2rem').classes('text-positive animate-pulse')
with ui.element('div'):
ui.label('Ambient AI Suggestion').classes('text-sm font-bold text-positive uppercase tracking-wider')
ui.label('Based on current server load, scaling up instances in the EU-West region is recommended.').classes('text-slate-300')
ui.space()
ui.button('Apply Now', color='positive').classes('rounded-full px-6 py-2 shadow-lg shadow-positive/20')
ui.run(title='2026 UI Dashboard')
+92
View File
@@ -0,0 +1,92 @@
```mermaid
block-beta
columns 3
%% UI Component Column
block:UI["UI COMPONENTS / WIREFRAME"]:1
columns 1
block:HeaderUI["Header & Nav"]:1
columns 1
h_title["[Text] Document Name & Type"]
h_date["[Text] Date & Origin Location"]
end
block:EditorUI["Page Transcription Editor"]:1
columns 1
ed_img["[Image Viewer] Source Image"]
ed_page["[Badge] Page Number"]
ed_raw["[Read-Only] AI Raw Output"]
ed_rev["[Textarea] Human Revised Text"]
end
block:PeopleUI["Attribution Sidebar"]:1
columns 1
p_author["[List] Authors (Full Name)"]
p_recip["[List] Recipients (Full Name)"]
p_bio["[Card] Person Biography & Dates"]
end
block:JobUI["AI Processing Drawer"]:1
columns 1
j_status["[Badge] Job Status"]
j_model["[Text] Provider & Model"]
j_tokens["[JSON View] AI Token Usage"]
end
end
%% Directional Mapping / Connectors
block:FLOW["MAPPING / FLOW"]:1
columns 1
f1["Reads / Updates -->"]
f2["Renders Active Page -->"]
f3["Joins via Role -->"]
f4["Executes & Logs -->"]
end
%% Postgres Schema Column
block:DB["POSTGRES SQL SCHEMA"]:1
columns 1
block:DocTbl["Table: document"]:1
columns 1
d_id["id : UUID (PK)"]
d_name["name : TEXT"]
d_type["document_type : TEXT"]
d_date["document_date : DATE"]
end
block:SrcTbl["Table: source"]:1
columns 1
s_id["id : UUID (PK)"]
s_page["page_number : INT"]
s_path["file_path : TEXT"]
s_raw["raw_transcription : TEXT"]
s_rev["revised_text : TEXT"]
end
block:PersonTbl["Table: person & document_person"]:1
columns 1
p_id["id : UUID (PK)"]
p_name["full_name : TEXT"]
p_role["role : 'author' | 'recipient'"]
end
block:JobTbl["Table: job & job_source"]:1
columns 1
j_id["id : UUID (PK)"]
j_stat["status : VARCHAR"]
j_prov["provider / model : TEXT"]
j_meta["ai_metadata : JSONB"]
end
end
%% Connections
HeaderUI --> DocTbl
ed_img --> s_path
ed_page --> s_page
ed_raw --> s_raw
ed_rev --> s_rev
PeopleUI --> PersonTbl
JobUI --> JobTbl
```
+53
View File
@@ -0,0 +1,53 @@
```mermaid
flowchart LR
subgraph UI["UI Components / Wireframe"]
direction TB
subgraph HeaderUI["Header & Nav"]
h_title["[Text] Document Name & Type"]
h_date["[Text] Date & Origin Location"]
end
subgraph EditorUI["Page Transcription Editor"]
ed_img["[Image Viewer] Source Image"]
ed_page["[Badge] Page Number"]
ed_raw["[Read-Only] AI Raw Output"]
ed_rev["[Textarea] Human Revised Text"]
end
subgraph PeopleUI["Attribution Sidebar"]
p_author["[List] Authors / Recipients"]
end
subgraph JobUI["AI Processing Drawer"]
j_status["[Badge] Job Status"]
end
end
subgraph DB["Postgres SQL Schema"]
direction TB
subgraph DocTbl["Table: document"]
d_name["name : TEXT"]
d_type["document_type : TEXT"]
end
subgraph SrcTbl["Table: source"]
s_path["file_path : TEXT"]
s_page["page_number : INT"]
s_raw["raw_transcription : TEXT"]
s_rev["revised_text : TEXT"]
end
subgraph PersonTbl["Table: person & document_person"]
p_name["full_name : TEXT"]
p_role["role : author | recipient"]
end
subgraph JobTbl["Table: job & job_source"]
j_stat["status : VARCHAR"]
j_meta["ai_metadata : JSONB"]
end
end
%% Mappings
HeaderUI --> DocTbl
ed_img --> s_path
ed_page --> s_page
ed_raw --> s_raw
ed_rev --> s_rev
PeopleUI --> PersonTbl
JobUI --> JobTbl
```
+80
View File
@@ -0,0 +1,80 @@
# UI Documentation
This folder contains UI-focused design and mapping documents that connect the database schema to user-facing workflows.
## Document Types
### user-journey.md
A product and UX contract for a user-facing entity.
Use this document to describe:
- what the user is trying to do
- which screen or action starts the workflow
- which fields the user sees and edits
- validation rules
- expected success and failure outcomes
- where the user goes next
### schema-mapping.md
A field-level mapping between schema, UI, and implementation.
Use this document to describe:
- the authoritative schema fields for an entity
- which fields are shown, hidden, editable, or system-managed
- current implementation behavior
- intended target behavior
- implementation gaps between current code and intended UX
### acceptance-criteria.md
An implementation-ready checklist for CRUD behavior and quality gates.
Use this document to describe:
- testable acceptance statements by flow (Create, Read, Update, Delete)
- success and failure behaviors
- first-release constraints
- cross-criteria quality gates
### traceability-matrix.md
A criteria-to-code mapping that identifies implementation anchors and status.
Use this document to describe:
- acceptance criteria group to implementation file mapping
- delivery status (implemented, partial, planned)
- ordered implementation priorities
## Organization Rules
- Store documents under `docs/ui/entities/<entity-name>/`.
- Create both `user-journey.md` and `schema-mapping.md` for user-facing entities.
- Create `acceptance-criteria.md` for user-facing entities.
- Create only `schema-mapping.md` for supporting tables that do not currently have standalone UI.
- Keep one shared `traceability-matrix.md` under `docs/ui/entities/` to map criteria to implementation anchors.
- Keep top-level `docs/` reserved for core architecture, requirements, schema, and system-wide reference material.
## Current Entity Plan
User-facing entities:
- `document`
- `person`
- `source`
- `job`
Supporting entities:
- `document-person`
- `job-source`
## Relationship to Core Docs
These UI docs complement, but do not replace:
- `docs/schema_v2.md`
- `docs/requirements_v2.md`
- `docs/architecture_v2.md`
When there is a conflict:
- schema definitions come from the database model and schema docs
- user interaction intent comes from the user-journey docs
- implementation truth comes from code and is recorded in schema-mapping docs as current-state evidence
@@ -0,0 +1,182 @@
# DocumentPerson Schema-to-UI Mapping
Purpose: Map the DocumentPerson schema to UI-facing workflows, while separating intended target behavior from current implementation.
Supporting entity note: DocumentPerson does not currently have a standalone UI surface.
## 1. Entity Snapshot
- Table: document_person
- Primary key: id (UUID)
- Related entities: Document, Person
- Canonical schema references:
- src/transcription/db/models.py
- docs/schema_v2.md
## 2. Mapping Rules
This document uses three lenses:
1. Intended behavior: what user-facing workflows should support indirectly.
2. Current behavior: what code supports today.
3. Gap to target: what must change to align implementation with intended UX.
## 3. Field Inventory
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|---|---|---|---|---|---|
| id | UUID | No | uuid4() | Hidden, system-managed | Primary key |
| document_id | UUID FK | No | None | Context-managed | Selected Document context |
| person_id | UUID FK | No | None | Context-managed | Selected Person context |
| role | enum DocumentPersonRole | No | author | Visible in relationship context | First-release behavior may default to author |
| created_at | datetime | No | datetime.now(UTC) | Hidden or read-only | System-managed timestamp |
Constraint behavior:
1. document_id, person_id, and role are unique as a tuple.
2. duplicate links for the same document, person, and role must be rejected.
## 4. CREATE Mapping
### 4.1 Intended Create Flow
Entry points are indirect through user-facing entities:
1. Document create or update workflows may create one or more DocumentPerson links.
2. Person relationship workflows may create DocumentPerson links.
| Field | Intended User Input | Required | Visible | Notes |
|---|---|---|---|---|
| document_id | None | Yes | No | Derived from selected Document |
| person_id | None | Yes | No | Derived from selected Person |
| role | Select or default | Yes | Indirectly | Defaults to author in first-release behavior |
| created_at | None | No | No | System-generated |
### 4.2 Current Implementation
Current entry point: Document create/edit flows
Current user action: select an existing Person from the Document author dropdown
Current backend path: Document page submit callback -> `DocumentService.create_document_person()` or `delete_document_person()` as the author selection changes
| Field | Current Value at Create | Source | Visible to User | Evidence |
|---|---|---|---|---|
| id | Generated UUID | System | No | src/transcription/db/models.py |
| document_id | Caller-provided | Document UI | Indirectly | src/transcription/ui/pages/documents_page.py |
| person_id | Caller-provided | Document UI | Indirectly | src/transcription/ui/pages/documents_page.py |
| role | Default author in current UI | Service/model default | No | src/transcription/db/models.py, src/transcription/services/documents.py |
| created_at | Current UTC timestamp | System | No | src/transcription/db/models.py |
### 4.3 Gap to Target
To satisfy intended supporting behavior, implementation must add:
1. explicit UI relationship controls in Document and/or Person detail flows.
2. duplicate-link handling with clear user feedback.
3. role-selection UX when role expansion is enabled beyond default author.
## 5. READ Mapping
### 5.1 Intended Read Behavior
Users should see DocumentPerson relationships indirectly in user-facing surfaces:
1. Document detail shows linked people.
2. Person detail shows linked documents.
3. Relationship role is shown where relevant.
### 5.2 Current Implementation
Current read behavior is mainly service-level.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| document_id/person_id link | Indirect relationship usage in workflows | Partial | Document/Person dedicated relationship surfaces are planned | docs/ui/entities/document/*, docs/ui/entities/person/* |
| role | Not shown in current job-centric pages | No | Role expansion is deferred in user-facing workflows | docs/ui/entities/person/user-journey.md |
| created_at | Not rendered | No | Operational metadata only | current UI pages |
Service read/query coverage:
1. read_document_person() returns one link by id.
2. list_document_people() supports filtering by document_id and person_id.
### 5.3 Gap to Target
To satisfy intended read behavior, implementation must add:
1. linked-people and linked-documents UI sections backed by list_document_people().
2. relationship role display where role context is required.
## 6. UPDATE Mapping
### 6.1 Intended Update Behavior
DocumentPerson updates are limited to relationship role or relationship-management actions.
Intended editable fields:
- role (when role management is enabled)
Intended read-only fields:
- id
- document_id
- person_id
- created_at
### 6.2 Current Implementation
| Field | Updatable via UI | Updatable via Service | Notes |
|---|---|---|---|
| role | No | Yes | DocumentService.update_document_person() supports updates |
| document_id/person_id | No | Technically yes via full-row update | Should generally be treated as immutable link identity |
| created_at | No | Technically yes | Should remain system-managed |
### 6.3 Gap to Target
Implementation should add:
1. explicit relationship-role edit controls when product scope enables them.
2. safeguards against mutating link identity instead of recreating links.
## 7. DELETE Mapping
### 7.1 Intended Delete Behavior
Deletion of DocumentPerson should be exposed as unlink behavior in Document and Person flows.
Rules:
1. unlink should remove only the selected relationship.
2. unlink must not delete the underlying Document or Person records.
### 7.2 Current Implementation
| Action | UI Exposed | Backend Capability | Notes |
|---|---|---|---|
| Delete DocumentPerson link | No | Yes | DocumentService.delete_document_person() exists |
### 7.3 Gap to Target
Implementation must add:
1. unlink controls in relationship sections.
2. confirmation and success feedback for relationship removal.
3. blocked-delete guidance if policy constraints are added later.
## 8. Hidden and System-Managed Fields
| Field | Category | Why Hidden or Protected |
|---|---|---|
| id | System-managed | Internal identifier |
| document_id | Context-managed | Derived from selected Document |
| person_id | Context-managed | Derived from selected Person |
| created_at | System-managed | Audit timestamp |
## 9. Traceability Anchors
Schema and models:
- docs/schema_v2.md
- src/transcription/db/models.py
Current implementation:
- src/transcription/services/documents.py
- tests/services/test_v2_crud.py
Related user-facing workflows:
- docs/ui/entities/document/user-journey.md
- docs/ui/entities/person/user-journey.md
## 10. Coverage Summary
- Every DocumentPerson schema field appears in the field inventory.
- Intended behavior is defined as supporting workflow behavior rather than standalone UI.
- Current behavior reflects UI-backed CRUD through Document create/edit flows and Person detail rendering, with no standalone DocumentPerson UI.
- Gaps between intended and current behavior are explicit.
@@ -0,0 +1,136 @@
# Document Acceptance Criteria
Purpose: Define implementation-ready acceptance criteria for Document Read, Update, and Delete workflows.
Companion documents:
- docs/ui/entities/document/user-journey.md
- docs/ui/entities/document/schema-mapping.md
## Scope
This checklist covers:
1. Read flow
2. Update flow
3. Delete flow
This checklist does not cover:
1. Source upload workflow details
2. Job execution internals
3. Revision editor behavior
## Read Acceptance Criteria
### RD-1 Document detail retrieval
1. Given a valid Document id
2. When the user opens the Document detail page
3. Then the system displays Document metadata for that record only
### RD-2 Metadata visibility
1. The page shows name, document_type, document_date, document_date_raw, location_created, notes, archive_identifier
2. created_at and updated_at are displayed as system-managed, read-only values
### RD-3 Related people section
1. Given zero linked people
2. Then the page shows a no linked people yet empty state
3. Given one linked person
4. Then the page shows that linked person
### RD-4 Sources section empty state
1. The page shows a Sources action for the current Document
2. The page shows a primary + Add Source action that opens job-create flow for this Document
3. The action routes to a document-scoped Sources view
### RD-5 Jobs section empty state
1. The page shows a Jobs action for the current Document
2. The page shows a primary + Add Job action for the current Document
3. The action routes to a document-scoped Jobs view
### RD-6 Filtered navigation readiness
1. The detail page provides links or actions that can route to document-scoped Sources and Jobs views
2. Target views are filtered to the current Document id
### RD-7 Failure state
1. Given a nonexistent Document id
2. Then the UI shows a clear not found state without crashing
## Update Acceptance Criteria
### UP-1 Edit entry
1. Given a loaded Document detail page
2. When the user chooses Edit document
3. Then editable controls are shown for allowed fields only, including the author relationship selector
4. The author selector includes No author, existing Person options, and a Create new item option
5. Selecting Create new item routes to Person create
### UP-2 Editable fields
1. Editable: name, document_type, document_date, document_date_raw, location_created, notes, archive_identifier
2. Not editable: id, created_at, updated_at
3. The edit flow may also change the associated author Person link
### UP-3 Required validation
1. name is required
2. document_type is required
3. Save is blocked with inline feedback when either required field is missing
### UP-4 Date handling rule
1. document_date only is allowed
2. document_date_raw only is allowed
3. both fields together are allowed
4. if both are present, document_date is treated as canonical exact date and document_date_raw is retained as descriptive context
### UP-5 Successful save
1. Given valid input
2. When the user saves
3. Then changes persist
4. Then success feedback is shown
5. Then the user remains on Document detail with refreshed values
6. Then updated_at reflects update policy
### UP-6 Save failure
1. Given backend failure during save
2. Then clear error feedback is shown
3. Then the user-entered values remain available for retry where possible
4. Then no false success feedback is shown
## Delete Acceptance Criteria
### DL-1 Delete entry and confirmation
1. Given a Document detail page
2. When the user chooses Delete document
3. Then a confirmation dialog appears with permanent-action wording
### DL-2 Dependency guardrails
1. Delete is allowed only when the Document has no related Source records and no related Job records
2. Delete is blocked when at least one related Source or Job exists
### DL-3 Blocked delete behavior
1. When blocked
2. Then the UI explains why deletion is blocked
3. Then the UI identifies dependency categories present: Sources, Jobs, or both
4. Then the UI provides navigation to dependency cleanup paths
### DL-4 Successful delete
1. Given no blocking dependencies
2. When the user confirms delete
3. Then the Document is removed
4. Then success feedback is shown
5. Then the user is returned to the Document list page
### DL-5 Delete failure
1. Given backend failure during delete
2. Then a clear error message is shown
3. Then the user remains on Document detail with retry path
## Cross-Criteria Quality Gates
### QG-1 Separation of intent and implementation
1. UX intent remains in user-journey.md
2. Current versus target implementation mapping remains in schema-mapping.md
### QG-2 Traceability
1. Each accepted behavior maps to at least one future UI action or service call path
2. No acceptance criterion contradicts the current deferred-item policy
### QG-3 First-release constraints
1. Linked person during create remains optional
2. Recipient and multi-person expansion remain deferred
+231
View File
@@ -0,0 +1,231 @@
# Document Schema-to-UI Mapping
Purpose: Map the Document schema to the UI, while clearly separating intended target behavior from current implementation.
Companion document: user-journey.md
Acceptance criteria: acceptance-criteria.md
## 1. Entity Snapshot
- Table: Document
- Primary key: `id` (UUID)
- Related entities: `Source`, `Job`, `DocumentPerson`, `Person`
- Canonical schema references:
- `src/transcription/db/models.py`
- `docs/schema_v2.md`
## 2. Mapping Rules
This document uses three lenses:
1. Intended behavior: what the UX should support.
2. Current behavior: what the code supports today.
3. Gap to target: what must change to align implementation with the intended UX.
## 3. Field Inventory
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|---|---|---|---|---|---|
| id | UUID | No | `uuid4()` | Hidden, system-managed | Primary key |
| name | str | No | None | Shown, editable on create and edit | Required |
| document_type | str | Yes | None | Shown, editable on create and edit | Required by intended UX |
| document_date | date | Yes | None | Shown, editable | Canonical exact date when present |
| document_date_raw | str | Yes | None | Shown, editable | Approximate or unknown date text |
| location_created | str | Yes | None | Shown, editable | Optional metadata |
| notes | str | Yes | None | Shown, editable | Optional metadata |
| archive_identifier | str | Yes | None | Shown, editable | Free text in first release |
| created_at | datetime | No | `datetime.now(UTC)` | Hidden or read-only | System-managed |
| updated_at | datetime | No | `datetime.now(UTC)` | Hidden or read-only | System-managed |
## 4. CREATE Mapping
### 4.1 Intended Create Flow
Entry point: Document page
User action: Create new document
Success destination: new Document detail page
| Field | Intended User Input | Required | Visible | Notes |
|---|---|---|---|---|
| name | Text input | Yes | Yes | Primary identifier used by the user |
| document_type | Text input | Yes | Yes | Free text in first release |
| document_date | Date input | No | Yes | Structured exact date |
| document_date_raw | Text input | No | Yes | Approximate or uncertain date |
| location_created | Text input | No | Yes | Optional |
| notes | Text area | No | Yes | Optional |
| archive_identifier | Text input | No | Yes | Free text |
| created_at | None | No | No | System-generated |
| updated_at | None | No | No | Not used during initial create |
Related records during intended create:
- A related person may optionally be selected or created.
- If present, the system creates a `DocumentPerson` link.
- Jobs are not created during Document create.
- Sources are not created during Document create.
### 4.2 Current Implementation
Current entry point: `/documents` page
Current user action: open create form, fill metadata, optionally select an existing Person
Current backend path: document page submit callback -> `DocumentService.create_document()` -> optional `DocumentService.create_document_person()`
| Field | Current Value at Create | Source | Visible to User | Evidence |
|---|---|---|---|---|
| id | Generated UUID | System | No | `Document` default factory in `src/transcription/db/models.py` |
| name | User-provided | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| document_type | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| document_date | Parsed from date input or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| document_date_raw | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| location_created | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| notes | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| archive_identifier | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
| created_at | Current UTC timestamp | System | No | Default factory in `src/transcription/db/models.py` |
| updated_at | Current UTC timestamp | System | No | Default factory in `src/transcription/db/models.py` |
Current related-record behavior:
- User may optionally select an existing `Person`.
- If selected, `DocumentPerson` is created with role `author`.
- `Job` is not created during Document create.
- `Source` is not created during Document create.
### 4.3 Gap to Target
To satisfy the intended Create flow, implementation now includes:
1. a Document page and dedicated create form
2. user-entered metadata fields for `document_type`, `document_date`, `document_date_raw`, `location_created`, `notes`, and `archive_identifier`
3. optional Person lookup through a dropdown of existing people
4. optional `DocumentPerson` link creation when a person is chosen
5. post-submit routing to a Document detail page
## 5. READ Mapping
### 5.1 Intended Read Behavior
On the Document detail page, the user should be able to see:
1. Document metadata
2. linked people
3. a Sources section with empty-state behavior when no sources exist
4. a Jobs section with empty-state behavior when no jobs exist
5. filtered Jobs and Sources views for the current document
### 5.2 Current Implementation
Current Document visibility in the UI is direct.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| name | Rendered as title and detail heading | Yes | Dedicated Document detail page | `src/transcription/ui/pages/documents_page.py` |
| id | Not shown as raw id | No | Internal identifier remains hidden | `src/transcription/ui/pages/documents_page.py` |
| document_type | Rendered | Yes | Shown on detail and editable on create/edit | `src/transcription/ui/pages/documents_page.py` |
| document_date | Rendered | Yes | Exact date shown when present | `src/transcription/ui/pages/documents_page.py` |
| document_date_raw | Rendered | Yes | Approximate date shown when present | `src/transcription/ui/pages/documents_page.py` |
| location_created | Rendered | Yes | Optional metadata shown | `src/transcription/ui/pages/documents_page.py` |
| notes | Rendered | Yes | Optional metadata shown | `src/transcription/ui/pages/documents_page.py` |
| archive_identifier | Rendered | Yes | Optional metadata shown | `src/transcription/ui/pages/documents_page.py` |
| created_at | Rendered read-only | Yes | System timestamp shown on detail | `src/transcription/ui/pages/documents_page.py` |
| updated_at | Rendered read-only | Yes | System timestamp shown on detail | `src/transcription/ui/pages/documents_page.py` |
### 5.3 Gap to Target
To satisfy the intended Read flow, implementation now includes:
1. metadata rendering for Document fields
2. linked people rendering
3. document-scoped Sources and Jobs navigation views
## 6. UPDATE Mapping
### 6.1 Intended Update Behavior
The user should eventually be able to edit Document metadata from the Document detail page or a dedicated edit flow.
Intended editable fields:
- `name`
- `document_type`
- `document_date`
- `document_date_raw`
- `location_created`
- `notes`
- `archive_identifier`
Intended system-managed fields:
- `id`
- `created_at`
- `updated_at`
### 6.2 Current Implementation
| Field | Updatable via UI | Updatable via Service | Notes |
|---|---|---|---|
| id | No | Practically no | Primary key should be treated as immutable |
| name | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
| document_type | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
| document_date | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
| document_date_raw | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
| location_created | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
| notes | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
| archive_identifier | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
| created_at | No | Technically yes | Should remain system-managed |
| updated_at | No | Technically yes | Should remain system-managed |
### 6.3 Gap to Target
Implementation now includes:
1. Document edit controls in the UI
2. validation and save behavior for Document metadata
3. author relationship controls through the edit flow
## 7. DELETE Mapping
### 7.1 Intended Delete Behavior
The UI should eventually provide a delete action for Document with guardrails.
Rules:
1. A Document can be deleted when it has no attached Jobs and no attached Sources.
2. If dependent Jobs or Sources exist, the UI should block deletion and explain that those related records must be removed first.
3. Delete confirmation should make it clear that the action is permanent.
### 7.2 Current Implementation
| Action | UI Exposed | Backend Capability | Notes |
|---|---|---|---|
| Delete Document | Yes | Yes | `DocumentService.delete_document()` exists and the UI blocks dependent deletes |
### 7.3 Gap to Target
Implementation includes:
1. a Document delete control in the UI
2. pre-delete dependency checks for Jobs and Sources
3. user-facing messaging when deletion is blocked
4. confirmation UX for successful delete attempts
## 8. Hidden and System-Managed Fields
| Field | Category | Why Hidden or Protected |
|---|---|---|
| id | System-managed | Internal identifier |
| created_at | System-managed | Audit timestamp |
| updated_at | System-managed | Audit timestamp |
## 9. Traceability Anchors
Schema and models:
- `docs/schema_v2.md`
- `src/transcription/db/models.py`
Current implementation:
- `src/transcription/ui/pages/documents_page.py`
- `src/transcription/services/documents.py`
- `src/transcription/services/store.py`
- `src/transcription/ui/pages/jobs_page.py`
- `src/transcription/ui/components/transcript.py`
Companion UX spec:
- `docs/ui/entities/document/user-journey.md`
## 10. Acceptance Checklist Summary
- Every Document schema field appears in the field inventory.
- Intended Create behavior matches the companion user journey.
- Current Create behavior reflects the existing upload-driven implementation.
- Gaps between intended and current behavior are explicit.
- Read, Update, and Delete sections distinguish target behavior from current code.
+427
View File
@@ -0,0 +1,427 @@
# Document User Journey
Purpose: Define how a user should interact with the UI to create and manage a Document record, including expected inputs, validation, results, and related record creation.
Scope: This document describes intended user interaction for the Document UI. It is the UX contract for the Document entity.
Companion schema mapping: schema-mapping.md
Companion acceptance criteria: acceptance-criteria.md
## 1. Overview
A Document represents a real historical artifact the user wants to describe, organize, and eventually transcribe. The user should be able to create a Document before uploading or linking any source files.
Creating a Document is a metadata-first workflow:
1. The user opens the Document page.
2. The user selects Create new document.
3. The user enters descriptive metadata about the document.
4. The user optionally selects one related person from the existing Person list.
5. The system creates the Document.
6. If a person was selected, the system links that Person to the Document through DocumentPerson with author role.
7. The user sees a success state and lands on the new Document detail page.
## 2. User Goal
The user wants to create a new Document record that:
1. Has enough metadata to identify the historical artifact.
2. Can optionally be linked to a person.
3. Exists independently of transcription jobs and source uploads.
4. Is ready for later steps such as adding sources, starting jobs, and reviewing transcriptions.
## 3. Page Model
### 3.1 Document Page
The Document page is the general UI surface where users manage documents.
It should support:
1. listing or locating existing documents
2. starting the Create new document flow
3. navigating into a specific Document after it exists
### 3.2 Document Detail Page
The Document detail page is the page for one specific Document after it has been created.
It should show:
1. the Document metadata
2. related people linked to the Document
3. a linked-author summary when available
4. document-scoped navigation links for Sources and Jobs
5. filtered views for sources and jobs linked to the current document
6. primary actions + Add Source and + Add Job
## 4. Entry Point
Entry point: Document page
Primary action: Create new document
Expected UI affordance:
1. A visible button, link, or primary action labeled Create new document.
2. Activation opens a dedicated form view, modal, or detail panel for creating a Document.
Preferred first implementation:
1. A dedicated Document create page or panel.
2. A simple form with explicit labels.
3. Existing Person records should be selectable through a dropdown.
4. Text inputs are acceptable for the remaining fields in first release.
## 5. Create Document Form
The Create Document form should contain the following fields.
### 5.1 Required Fields
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Document name | name | Text input | Yes | Examples: Pioneer Days, Letter from Zenna to Omie |
| Document type | document_type | Text input | Yes | Examples: book, letter, enlistment papers, military record, other |
### 5.2 Date Fields
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Exact date | document_date | Date input | No | Use when the exact date is known |
| Approximate date | document_date_raw | Text input | No | Use when exact date is uncertain, approximate, or unknown |
Date handling rule:
1. The form may allow both fields to be entered.
2. If both fields are entered, `document_date` is the canonical structured date.
3. `document_date_raw` may still be retained as the user-entered descriptive form.
4. The UI should explain the distinction clearly.
Examples:
1. Exact date: `07/13/1885`
2. Approximate date: `c. 1885`
3. Approximate date: `Fall 1925`
4. Approximate date: `unknown`
### 5.3 Optional Metadata Fields
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Document location | location_created | Text input | No | Where the document was created |
| Notes | notes | Multiline text area | No | Freeform notes about the document |
| Archive identifier | archive_identifier | Text input | No | Free text for now; may represent inventory code, storage reference, or repository note |
Archive identifier guidance:
1. First implementation should treat this as free text.
2. Helper text may explain that this can store a repository code, box or folder reference, or storage note.
### 5.4 System Fields
| Schema Field | User Editable | Notes |
|---|---|---|
| created_at | No | System-generated at creation time |
| updated_at | No | Not user-entered during creation |
### 5.5 Optional Related Person
The Create Document flow may optionally link one related person during first release.
| UI Label | Schema Area | Input Type | Required | Notes |
|---|---|---|---|---|
| Related person | Person -> DocumentPerson | Dropdown select | No | Selects an existing Person and links as author when saved |
First release behavior:
1. The user may save a Document without linking any person.
2. If a person is linked during create, only one person is supported in first release.
3. The selected person is linked as author.
4. Additional people and recipient workflows are deferred to a future revision.
### 5.6 Related Records Not Created Directly Here
| Related Area | Included in Document Create | Notes |
|---|---|---|
| Jobs | No | Jobs are created later when transcription work begins |
| Sources | No | Sources are added later as uploaded pages or files |
## 6. Related Person Workflow
### 6.1 User Intent
The user should be able to:
1. select an existing Person to associate with the Document
2. change the associated Person from the Document edit flow
3. save the Document even if no person is linked
### 6.2 Data Model Interpretation
Person selection source:
1. The UI should select from Person records.
2. If a person is linked, the system should create a DocumentPerson record.
3. Role handling for non-author document relationships is deferred.
4. If the first release needs a persisted role immediately, the role can default to `author` until the relationship model is broadened.
This means:
1. The user does not choose from DocumentPerson records.
2. DocumentPerson is the relationship created after the Person is chosen or created.
### 6.3 Related Person UI Behavior
Minimum acceptable first implementation:
1. Dropdown of existing Person records.
2. Clear display of the selected related person before submit.
3. Ability to change or clear the selected person in the Document edit flow.
4. A Create new item option in the author selector that routes to Person create.
5. A visible Create new person link near the selector.
If the person does not exist:
1. The user can use Create new item from the author selector and continue from Person create.
2. The Document create flow links existing Person records after selection.
## 7. Validation Rules
### 7.1 Required Field Validation
The form must reject submission if:
1. `name` is empty
2. `document_type` is empty
### 7.2 Date Validation
The form should allow:
1. `document_date` only
2. `document_date_raw` only
3. both `document_date` and `document_date_raw`
4. neither date field
If both are present:
1. `document_date` is treated as the canonical exact date
2. `document_date_raw` is retained as descriptive context
### 7.3 Related Person Validation
The form must not require a linked person in first release.
If a related person is selected or created:
1. the selected value must resolve to a valid Person record before final save
2. the DocumentPerson link must not be partially persisted on failure
## 8. Submission Behavior
When the user submits the form, the system should perform these logical steps:
1. validate form inputs
2. create the Document record
3. create one DocumentPerson record only if an existing related person was selected
4. persist intended records successfully before reporting success to the user
Expected write sequence:
1. insert Document
2. insert DocumentPerson link only if a person is linked
Recommended transactional behavior:
1. Document and optional DocumentPerson writes should succeed or fail together
2. Person creation is a separate workflow reached from the author selector and is not part of the same transaction
## 9. Expected Result After Success
After successful creation, the user should expect to see:
1. confirmation that the Document was created successfully
2. the Document name displayed in the resulting UI state
3. the Document metadata displayed on the new Document detail page
4. any linked person displayed in the resulting UI state
5. a Sources section showing an empty state when no sources exist yet
6. a Jobs section showing an empty state when no jobs exist yet
7. a clear next step, such as adding source files
Recommended success route:
1. navigate to the new Document detail page
2. show Document summary metadata
3. show linked people section
4. show empty-state placeholders for Sources and Jobs
## 10. Expected Result After Failure
If submission fails, the user should expect:
1. clear error messaging
2. field-level validation feedback where applicable
3. no false success message
4. preservation of entered form values when possible
Examples:
1. missing required name
2. missing required document type
3. failed person creation
4. failed DocumentPerson link creation
5. database or server error
## 11. Read Document Journey
### 11.1 User Intent
The user wants to open a specific Document and quickly understand:
1. what the document is
2. which people are linked to it
3. whether sources exist
4. whether jobs exist
5. what the next action should be
### 11.2 Entry Points
A user can reach a Document detail page by:
1. selecting a document from the Document page list
2. being redirected after successfully creating a new document
3. following a direct link to a known Document record
### 11.3 Document Detail Layout
The Document detail page should include:
1. a header area with document name, document type, and key date values
2. a metadata section with location_created, notes, and archive_identifier
3. System metadata where created_at and updated_at are shown as read-only values
4. a related people section
5. a Sources section
6. a Jobs section
The Document detail page should support:
1. empty-state messaging when no related records exist
2. clear next actions from each empty state
3. filtered Sources and Jobs views scoped to the current document
### 11.4 Read Empty States
If no related records exist:
1. People section says no linked people yet
2. Sources section says no sources added yet
3. Jobs section says no jobs created yet
4. each section presents one clear next action
### 11.5 Read Success Criteria
A successful Read experience means:
1. The user can identify the Document immediately
2. The user can see whether work has started
3. The user can navigate directly to document-scoped Jobs and Sources workflows
## 12. Update Document Journey
### 12.1 User Intent
The user wants to correct or enrich metadata after creation without touching jobs or source transcriptions directly.
### 12.2 Update Entry Point
From the Document detail page:
1. The user selects Edit document
2. UI opens edit mode or a dedicated edit view
### 12.3 Editable Fields
First release editable fields:
1. name
2. document_type
3. document_date
4. document_date_raw
5. location_created
6. notes
7. archive_identifier
Read-only or system-managed fields:
1. id
2. created_at
3. updated_at
### 12.4 Update Validation Rules
1. name remains required
2. document_type remains required
3. document_date and document_date_raw may both be present
4. if both date fields are present, document_date remains canonical
5. validation errors should be shown inline and block save
### 12.5 Update Save Behavior
On save:
1. system validates form data
2. system persists Document updates
3. updated_at is refreshed by system policy
4. UI shows a confirmation message
5. user remains on Document detail page with refreshed values
### 12.6 Update Failure Behavior
If save fails:
1. Show a clear error message
2. keep user edits in form where possible
3. do not show stale success messaging
4. Allow retry without losing context
## 13. Delete Document Journey
### 13.1 User Intent
The user wants to remove a Document only when it is safe and unambiguous.
### 13.2 Delete Entry Point
From the Document detail page:
1. The user selects Delete document
2. UI opens a confirmation dialog explaining permanence
### 13.3 Delete Guardrails
Delete is allowed only when:
1. the Document has no related Source records
2. the Document has no related Job records
Delete is blocked when:
1. any Source exists for the Document
2. any Job exists for the Document
### 13.4 Blocked Delete UX
When blocked:
1. Show an explicit reason that related Jobs or Sources exist
2. Show which dependency types are present
3. provide links to filtered Sources and Jobs for cleanup
4. keep the Document unchanged
### 13.5 Allowed Delete UX
When allowed:
1. Show final confirmation with document name
2. perform delete
3. show success confirmation
4. return user to Document page list
### 13.6 Delete Failure Behavior
If delete fails due to system error:
1. Show a clear error message
2. keep user on Document detail page
3. preserve ability to retry
## 14. Non-Goals for This Flow
The Document journey does not define:
1. Source upload field-level UX
2. Job execution internals
3. revision editor behavior for transcriptions
4. multi-person recipient workflows in first release
## 15. Relationship to Other Workflows
This Document workflow integrates with:
1. Sources workflow for adding pages or files to the document
2. Jobs workflow for transcription execution
3. Person workflow for future expansion beyond one optional linked person
## 16. Relationship to Schema Mapping
This document is the intended UX contract.
The companion schema-mapping document should answer:
1. which schema field appears on which screen
2. whether the field is currently implemented
3. whether the field is hidden, editable, or system-managed
4. what the implementation gap is between intended UX and current code
## 17. Deferred Items
These topics are intentionally deferred to future revisions:
1. multiple linked people during create and update
2. recipient support during create and update
3. a broader role model for non-author document relationships
4. filtered Jobs and Sources list navigation details
@@ -0,0 +1,206 @@
# JobSource Schema-to-UI Mapping
Purpose: Map the JobSource schema to UI-facing workflows, while separating intended target behavior from current implementation.
Supporting entity note: JobSource does not currently have a standalone UI surface.
## 1. Entity Snapshot
- Table: job_source
- Primary key: id (UUID)
- Related entities: Job, Source
- Canonical schema references:
- src/transcription/db/models.py
- docs/schema_v2.md
## 2. Mapping Rules
This document uses three lenses:
1. Intended behavior: what user-facing workflows should support indirectly.
2. Current behavior: what code supports today.
3. Gap to target: what must change to align implementation with intended UX.
## 3. Field Inventory
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|---|---|---|---|---|---|
| id | UUID | No | uuid4() | Hidden, system-managed | Primary key |
| job_id | UUID FK | No | None | Context-managed | Selected Job context |
| source_id | UUID FK | No | None | Context-managed | Selected Source context |
| status | enum JobSourceStatus | No | pending | Shown in job detail source context | Per-source execution state |
| raw_transcription | str | Yes | None | Shown read-only in review context | Machine output per source |
| ai_metadata | JSONB/JSON | Yes | None | Hidden or advanced diagnostics | Provider metadata |
| raw_api_response | JSONB/JSON | Yes | None | Hidden or advanced diagnostics | Low-level provider payload |
| error_detail | str | Yes | None | Shown when status is failed | Execution failure details |
| executed_at | datetime | No | datetime.now(UTC) | Shown read-only | Execution timestamp |
## 4. CREATE Mapping
### 4.1 Intended Create Flow
JobSource creation is indirect through Job and transcription workflows:
1. Job create flow should create a JobSource row for each uploaded source page.
2. Processing workflow may create missing JobSource rows when persisting transcription output.
| Field | Intended User Input | Required | Visible | Notes |
|---|---|---|---|---|
| job_id | None | Yes | No | Derived from active Job |
| source_id | None | Yes | No | Derived from created/selected Source |
| status | None | No | Indirectly | Defaults to pending at create |
| raw_transcription | None | No | No at create | Filled after processing |
| ai_metadata | None | No | No | Operational metadata |
| raw_api_response | None | No | No | Operational payload |
| error_detail | None | No | No at create | Filled on failure |
| executed_at | None | No | No | System-generated |
### 4.2 Current Implementation
Current entry points:
1. upload create path adds pending JobSource link in _create_upload_records().
2. transcription update path creates or updates JobSource row during output persistence.
Current backend paths:
1. src/transcription/services/store.py -> _create_upload_records()
2. src/transcription/services/transcription.py -> update_job_transcription()
| Field | Current Value at Create/Update | Source | Visible to User | Evidence |
|---|---|---|---|---|
| id | Generated UUID | System | No | src/transcription/db/models.py |
| job_id | Caller or workflow derived | Service/workflow | Indirectly | store.py, transcription.py |
| source_id | Caller or workflow derived | Service/workflow | Indirectly | store.py, transcription.py |
| status | pending at create, transcribed or failed on update | Workflow logic | Partial | transcription.py |
| raw_transcription | Set on successful transcription update | Workflow/provider result | Yes in review context | transcription.py, jobs UI |
| ai_metadata | Available in model; not currently filled in update path | Workflow potential | No | models.py, transcription.py |
| raw_api_response | Available in model; not currently filled in update path | Workflow potential | No | models.py, transcription.py |
| error_detail | Set on failed transcription update | Workflow/provider error | Partial | transcription.py |
| executed_at | Set at row creation and refreshed on updates | System/workflow | Partial | models.py, transcription.py |
### 4.3 Gap to Target
To satisfy intended supporting behavior, implementation must add:
1. explicit per-source status display for all linked sources in Job detail.
2. clear surfaced error_detail for failed source executions.
3. optional diagnostics surface for ai_metadata/raw_api_response when needed.
4. first-class multi-source create path from Job create flow.
## 5. READ Mapping
### 5.1 Intended Read Behavior
Users should see JobSource data indirectly in job detail and review workflows:
1. per-source execution status.
2. per-source raw transcription output.
3. per-source failure details where applicable.
4. execution timestamp context.
### 5.2 Current Implementation
Current read behavior is partial and job-detail-centric.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| status | Job-level status is visible; source-level status is limited | Partial | Source-level status not fully surfaced as a dedicated list | src/transcription/ui/pages/jobs_page.py |
| raw_transcription | Original transcription card is visible | Yes | Primary source is shown in current detail flow | src/transcription/ui/components/transcript.py |
| error_detail | Not prominently surfaced in current detail UI | Partial | Stored in JobSource rows during failures | src/transcription/services/transcription.py |
| executed_at | Not first-class rendered | Partial | Available in model for future display | src/transcription/db/models.py |
Service read/query coverage:
1. read_job_source() reads one row with source relation.
2. list_job_sources() lists rows and supports job_id filtering.
### 5.3 Gap to Target
To satisfy intended read behavior, implementation must add:
1. source-level execution table in Job detail.
2. explicit failed-source messaging from error_detail.
3. multi-source navigation in job review UI.
## 6. UPDATE Mapping
### 6.1 Intended Update Behavior
JobSource updates are workflow-managed, not directly user-edited.
Intended user-editable fields:
- none in first-release behavior
Workflow-managed fields:
- status
- raw_transcription
- error_detail
- executed_at
- optional diagnostics payload fields
### 6.2 Current Implementation
| Field | Updatable via UI | Updatable via Service/Workflow | Notes |
|---|---|---|---|
| status | No | Yes | Set by transcription update and job lifecycle handling |
| raw_transcription | No | Yes | Persisted in update_job_transcription() |
| error_detail | No | Yes | Persisted on transcription failure |
| executed_at | No | Yes | Updated when existing JobSource rows are changed |
| ai_metadata/raw_api_response | No | Potentially yes | Model supports them; active population is limited |
### 6.3 Gap to Target
Implementation should add:
1. clearer job-detail visualization of per-source execution updates.
2. optional operator diagnostics views for advanced troubleshooting.
## 7. DELETE Mapping
### 7.1 Intended Delete Behavior
JobSource deletion should be policy-driven and usually tied to Job/Source lifecycle operations.
Rules:
1. direct user deletion is not required in first-release behavior.
2. cleanup should occur through Job or Source deletion policies.
### 7.2 Current Implementation
| Action | UI Exposed | Backend Capability | Notes |
|---|---|---|---|
| Delete JobSource row | No | Yes | TranscriptionService.delete_job_source() exists |
### 7.3 Gap to Target
Implementation may add:
1. maintenance tooling for cleanup operations.
2. policy-aware cascade guidance in Job and Source delete flows.
## 8. Hidden and System-Managed Fields
| Field | Category | Why Hidden or Protected |
|---|---|---|
| id | System-managed | Internal identifier |
| job_id | Context-managed | Derived from Job context |
| source_id | Context-managed | Derived from Source context |
| ai_metadata | Operational metadata | Advanced diagnostics payload |
| raw_api_response | Operational metadata | Raw provider response payload |
| executed_at | System-managed | Execution timestamp |
## 9. Traceability Anchors
Schema and models:
- docs/schema_v2.md
- src/transcription/db/models.py
Current implementation:
- src/transcription/services/store.py
- src/transcription/services/transcription.py
- src/transcription/services/workflows.py
- src/transcription/ui/pages/jobs_page.py
- src/transcription/ui/components/transcript.py
- tests/services/test_v2_crud.py
Related user-facing workflows:
- docs/ui/entities/job/user-journey.md
- docs/ui/entities/source/user-journey.md
## 10. Coverage Summary
- Every JobSource schema field appears in the field inventory.
- Intended behavior is defined as supporting workflow behavior rather than standalone UI.
- Current behavior reflects workflow/service-driven CRUD with partial job-detail visibility.
- Gaps between intended and current behavior are explicit.
+154
View File
@@ -0,0 +1,154 @@
# Job Acceptance Criteria
Purpose: Define implementation-ready acceptance criteria for Job Create, Read, Update, and Delete workflows.
Companion documents:
- docs/ui/entities/job/user-journey.md
- docs/ui/entities/job/schema-mapping.md
## Scope
This checklist covers:
1. Create flow
2. Read flow
3. Update flow
4. Delete flow
This checklist does not cover:
1. provider-specific transcription internals
2. advanced workflow scheduling and queue orchestration controls
3. multi-job bulk operations
## Create Acceptance Criteria
### CR-1 Job creation entry
1. Given the user is on the Jobs page
2. When the user selects Create job
3. Then the user is taken to Job detail/create mode
### CR-2 Required create values
1. document_id must be selected before submit
2. at least one source file must be uploaded before submit
3. each uploaded file creates a Source linked to the selected Document
4. each created Source is linked to the new Job through JobSource
### CR-3 Source ordering behavior
1. Given multi-file or folder upload
2. When source records are created
3. Then page ordering follows alphabetical order of original filenames
4. Then helper text explains how filename conventions control ordering
### CR-4 Provider/model/prompt visibility
1. provider, model, and prompt_name are visible in create flow when known
2. provider, model, and prompt_name are visible in detail flow when known
3. if values are unknown at create time, UI shows clear unknown or pending state without blocking submit
### CR-5 Successful create outcome
1. Given valid inputs
2. When the user submits create
3. Then the Job record is created and linked to selected Document
4. Then source and JobSource records are created for uploads
5. Then job status is queued or processing based on execution timing
6. Then the user is routed to Job detail mode
### CR-6 Create failure outcome
1. Given create validation or persistence failure
2. Then clear error feedback is shown
3. Then no false success feedback is shown
4. Then entered selections are preserved where possible
5. Then retry path remains available
## Read Acceptance Criteria
### RD-1 Jobs list retrieval
1. Given one or more jobs exist
2. When the user opens the Jobs page
3. Then all jobs are listed in a table or equivalent list surface
### RD-2 Jobs list fields
1. Jobs list shows job id
2. Jobs list shows status
3. Jobs list shows created or updated timestamps
4. Jobs list shows retry_count when available
5. Jobs list provides navigation to Job detail for each row
### RD-3 Job detail retrieval
1. Given a valid job id
2. When the user opens Job detail
3. Then job metadata for that record only is shown
4. Then document-scoped navigation links for Sources and Jobs are shown
### RD-4 Detail execution context visibility
1. provider, model, and prompt_name are displayed when known
2. status lifecycle value is visible
3. source-level transcription and revision context is available through Source detail navigation from Job detail
### RD-5 Missing and invalid id states
1. Given an invalid job id format
2. Then UI shows invalid job id state without crashing
3. Given a valid but nonexistent job id
4. Then UI shows job not found state without crashing
## Update Acceptance Criteria
### UP-1 Revision edit entry
1. Given a job detail page
2. When the user opens the page
3. Then navigation links to job-scoped Sources are available
4. Then source rows can open Source detail revision workflow
### UP-2 Revision validation
1. revision save blocks empty trimmed text and shows warning feedback
### UP-3 Successful revision save
1. Source detail save persists revised text and shows success feedback
### UP-4 Revision save failure
1. Source detail save failure shows clear error feedback with retry path
### UP-5 Job lifecycle state update visibility
1. status changes from queued to processing to terminal states are reflected in UI
2. retry_count updates are reflected when retry logic runs
3. users cannot directly edit lifecycle state fields in first release
## Delete Acceptance Criteria
### DL-1 Delete entry and confirmation
1. Given a job detail context
2. When the user opens job delete page
3. Then a permanent-action confirmation is shown for non-processing jobs
### DL-2 Dependency guardrails
1. Delete is blocked while job status is processing
2. Related JobSource links are removed as part of allowed delete flow
### DL-3 Blocked delete behavior
1. When blocked, the UI shows clear processing-state guidance
2. The user is offered navigation back to job or jobs list
### DL-4 Successful delete
1. Given an allowed delete
2. When the user confirms delete
3. Then the job is removed and success feedback is shown
4. Then the user is returned to Jobs list
### DL-5 Delete failure
1. Given backend failure during delete
2. Then clear error feedback is shown
3. Then the user remains in delete context with retry path
## Cross-Criteria Quality Gates
### QG-1 Separation of intent and implementation
1. UX intent remains in user-journey.md
2. Current versus target implementation mapping remains in schema-mapping.md
### QG-2 Traceability
1. Each accepted behavior maps to at least one UI action or service path
2. No acceptance criterion contradicts first-release deferred items
### QG-3 First-release constraints
1. Jobs page remains list-all with explicit Create job action
2. Job create requires Document selection and source upload
3. provider/model/prompt_name are visible to users when known
4. manual retry controls may remain deferred while status visibility is required
+233
View File
@@ -0,0 +1,233 @@
# Job Schema-to-UI Mapping
Purpose: Map the Job schema to the UI, while clearly separating intended target behavior from current implementation.
Companion document: user-journey.md
Acceptance criteria: acceptance-criteria.md
## 1. Entity Snapshot
- Table: Job
- Primary key: id (UUID)
- Related entities: Document, JobSource, Source
- Canonical schema references:
- src/transcription/db/models.py
- docs/schema_v2.md
## 2. Mapping Rules
This document uses three lenses:
1. Intended behavior: what the UX should support.
2. Current behavior: what the code supports today.
3. Gap to target: what must change to align implementation with the intended UX.
## 3. Field Inventory
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|---|---|---|---|---|---|
| id | UUID | No | uuid4() | Shown read-only in list and detail | Primary key |
| document_id | UUID FK | No | None | Required create input via Document selection | Job belongs to one Document |
| status | enum JobStatus | No | queued | Shown read-only as lifecycle state | System-managed transitions |
| retry_count | int | No | 0 | Shown read-only | Operational counter |
| date_created | datetime | No | datetime.now(UTC) | Shown read-only | System-managed timestamp |
| date_updated | datetime | No | datetime.now(UTC) | Shown read-only | System-managed timestamp |
| provider | str | Yes | None | Visible when known; editable if create-time options are available | Processing metadata |
| model | str | Yes | None | Visible when known; editable if create-time options are available | Processing metadata |
| prompt_name | str | Yes | None | Visible when known; editable if create-time options are available | Prompt metadata |
Related execution fields rendered in Job detail via relationships:
- Job detail renders metadata and document links; source-level review/editing is reached through job-scoped Sources routes.
## 4. CREATE Mapping
### 4.1 Intended Create Flow
Entry point: Jobs page Create job action
User action: open create mode, select Document, upload one or more source files or a folder, submit for transcription
Success destination: Job detail page in detail mode
| Field | Intended User Input | Required | Visible | Notes |
|---|---|---|---|---|
| document_id | Select/search | Yes | Yes | Required create selection |
| status | None | No | Yes (read-only) | Starts at queued and changes by workflow |
| retry_count | None | No | Yes (read-only) | Starts at 0 |
| date_created | None | No | Yes (read-only) | System-generated |
| date_updated | None | No | Yes (read-only) | System-generated |
| provider | Display or select | No | Yes | Visible when known during create and detail |
| model | Display or select | No | Yes | Visible when known during create and detail |
| prompt_name | Display or select | No | Yes | Visible when known during create and detail |
Create-related relationship rules:
1. source file upload is required for create.
2. each uploaded file creates a Source linked to the selected Document.
3. each created Source must be linked to the new Job through JobSource.
4. processing order for multi-file and folder uploads is alphabetical by original filename.
### 4.2 Current Implementation
Current entry point: Jobs page create flow
Current user action: select Document and upload one or more files or a folder through a single upload widget
Current backend path: job create submit -> create_job_for_document()
| Field | Current Value at Create | Source | Visible to User | Evidence |
|---|---|---|---|---|
| id | Generated UUID | System | Yes on jobs list/detail | src/transcription/ui/pages/jobs_page.py |
| document_id | Selected existing Document id | User selection + service write | Indirectly | src/transcription/ui/pages/jobs_page.py, src/transcription/services/store.py |
| status | queued | Service/model default | Yes | src/transcription/services/store.py, src/transcription/db/models.py |
| retry_count | 0 | Model default | Yes | src/transcription/db/models.py, src/transcription/ui/pages/jobs_page.py |
| date_created | current UTC timestamp | System | Yes | src/transcription/db/models.py, src/transcription/ui/pages/jobs_page.py |
| date_updated | current UTC timestamp | System | Yes | src/transcription/db/models.py, src/transcription/ui/pages/jobs_page.py |
| provider | None at create, set after transcription update | Workflow/service | Yes | src/transcription/services/workflows.py |
| model | None at create, set after transcription update | Workflow/service | Yes | src/transcription/services/workflows.py |
| prompt_name | None at create, set by workflow updates | Workflow/service | Yes | src/transcription/services/workflows.py |
Current create constraints:
1. dedicated Create job action exists in the Jobs page.
2. job create flow requires a Document selection.
3. current upload path accepts one widget for files or folder selection.
### 4.3 Gap to Target
To satisfy intended Create flow, implementation must add:
1. Jobs list Create job action that opens Job detail/create mode.
2. explicit Document selection and source upload controls in create mode.
3. multi-file and folder upload support in create mode.
4. deterministic alphabetical page ordering and user guidance.
5. explicit visibility of provider, model, and prompt_name in create/detail when known.
## 5. READ Mapping
### 5.1 Intended Read Behavior
On Job list/detail surfaces, users should be able to see:
1. all jobs in one list.
2. status and timeline context.
3. selected Document context.
4. source-level processing and transcription results.
5. provider/model/prompt_name when known.
### 5.2 Current Implementation
Current read behavior exists in jobs list and jobs detail routes.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| id | Jobs list row and detail header | Yes | Primary visible identifier | src/transcription/ui/pages/jobs_page.py |
| status | Jobs list and detail | Yes | Chip styling for transcribed; text for others | src/transcription/ui/pages/jobs_page.py |
| retry_count | Jobs list table | Yes | Included in row model | src/transcription/ui/components/table/jobs.py |
| date_created | Jobs list table | Yes | Included in row model | src/transcription/ui/components/table/jobs.py |
| date_updated | Jobs list table | Yes | Included in row model | src/transcription/ui/components/table/jobs.py |
| document_id | Not rendered directly as labeled field | Partial | Document context exists by relationship but limited direct display | src/transcription/ui/pages/jobs_page.py |
| provider/model/prompt_name | Rendered as labeled fields in Job detail | Yes | Shows pending fallback when unset | src/transcription/ui/pages/jobs_page.py |
Source-related read behavior:
1. Job detail exposes Sources navigation for current job context.
2. Source preview, transcription context, and revision editor are rendered in Source detail.
3. invalid or missing job ids show explicit UI states.
### 5.3 Gap to Target
To satisfy intended Read flow, implementation must add:
1. optional in-page source summaries in Job detail if future UX requires fewer navigation steps.
2. richer filtering/search UX if needed.
## 6. UPDATE Mapping
### 6.1 Intended Update Behavior
Primary user updates in first release are source revision edits in Source detail reached from Job detail.
Intended editable scope (first release):
- Source.revised_text through Source detail review
Intended read-only Job fields in first release:
- id
- document_id after create
- status
- retry_count
- date_created
- date_updated
Job metadata visibility policy:
- provider, model, and prompt_name should be visible when known.
- create-time editing of provider/model/prompt_name is optional and depends on available options.
### 6.2 Current Implementation
| Field/Area | Updatable via UI | Updatable via Service | Notes |
|---|---|---|---|
| Source.revised_text from Source detail | Yes | Yes | Saved via transcription service revision path from Sources page detail route |
| status | No | Yes | Updated by workflow lifecycle services |
| retry_count | No | Yes | Incremented by workflow retry logic |
| provider/model/prompt_name | No | Yes | Set during transcription result finalization |
| document_id | No | Technically via model/service update | Treated as fixed post-create in intended UX |
### 6.3 Gap to Target
Implementation now includes:
1. create-mode handling for provider/model/prompt visibility and optional selection.
2. detail display for provider/model/prompt and document-scoped navigation links.
3. source revision workflow through job-scoped Sources and Source detail pages.
4. manual controls for retry and state transitions remain deferred.
## 7. DELETE Mapping
### 7.1 Intended Delete Behavior
Job deletion is implemented as a dedicated delete route with processing-state guardrails.
Rules:
1. deletion is allowed only when policy allows cleanup or retention handling for related JobSource records.
2. blocked deletion must explain constraints and required cleanup path.
3. successful deletion requires confirmation and returns user to Jobs list.
### 7.2 Current Implementation
| Action | UI Exposed | Backend Capability | Notes |
|---|---|---|---|
| Delete Job | Yes | Yes | Job delete page confirms permanent action and blocks when processing |
### 7.3 Gap to Target
Implementation may add in a future revision:
1. inline delete entry in Job detail header.
2. richer dependency messaging beyond processing-state guardrail.
## 8. Hidden and System-Managed Fields
| Field | Category | Why Hidden or Protected |
|---|---|---|
| status | System-managed lifecycle | Managed by worker lifecycle transitions |
| retry_count | System-managed operational state | Reflects retry behavior, not direct user input |
| date_created | System-managed | Audit timestamp |
| date_updated | System-managed | Audit timestamp |
## 9. Traceability Anchors
Schema and models:
- docs/schema_v2.md
- src/transcription/db/models.py
Current implementation:
- src/transcription/ui/pages/jobs_page.py
- src/transcription/ui/components/table/jobs.py
- src/transcription/ui/pages/sources_page.py
- src/transcription/services/jobs.py
- src/transcription/services/workflows.py
- src/transcription/services/store.py
- src/transcription/services/transcription.py
Companion UX spec:
- docs/ui/entities/job/user-journey.md
Acceptance checklist:
- docs/ui/entities/job/acceptance-criteria.md
## 10. Acceptance Checklist Summary
- Every Job schema field appears in the field inventory.
- Intended Create behavior matches the companion user journey.
- Current behavior reflects explicit jobs creation plus source review/editing through dedicated Sources routes.
- Provider/model/prompt visibility intent is explicit for create and detail views.
- Gaps between intended and current behavior are explicit.
- Read, Update, and Delete sections distinguish target behavior from current code.
+291
View File
@@ -0,0 +1,291 @@
# Job User Journey
Purpose: Define how a user should interact with the UI to create and manage a Job record, including document linking, source uploads, processing status, and page-level review.
Scope: This document describes intended user interaction for the Job UI. It is the UX contract for the Job entity.
Companion schema mapping: schema-mapping.md
Companion acceptance criteria: acceptance-criteria.md
## 1. Overview
A Job represents one transcription run for a selected Document and one or more uploaded source files.
Managing a Job is run-first:
1. The user opens the Jobs page.
2. The user selects Create job.
3. The user lands on a Job detail/create surface.
4. The user links a Document and uploads one or more source files.
5. The user submits for transcription.
6. The system creates and processes the Job.
7. The user reviews job metadata and follows document-scoped links for Sources and Jobs.
## 2. User Goal
The user wants to:
1. see all jobs in one place
2. create a new transcription run intentionally
3. attach the run to the correct Document
4. upload source file(s) for that run
5. submit and monitor processing state
6. review and revise page-level outputs
## 3. Page Model
### 3.1 Jobs List Page
The Jobs page is the primary UI surface where users manage jobs.
It should support:
1. listing all jobs
2. searching or filtering jobs
3. opening job detail for any row
4. starting Create job
5. clear empty state when no jobs exist
### 3.2 Job Detail/Create Page
The Job detail/create page is used for both creating a new Job and viewing an existing Job.
Create mode should include:
1. document selection
2. source upload controls
3. submit for transcription action
Detail mode should include:
1. job metadata and status
2. document-scoped navigation links for the current Document
3. provider/model/prompt visibility when known
4. no delete action in first release
## 4. Entry Points
Primary entry points:
1. from Jobs page, Create job
2. from Jobs page row selection, open existing Job detail
Current implementation note:
1. current code path uses explicit /jobs/new creation
2. intended UX is explicit Create job from the Jobs page
3. current detail view is link-oriented and routes source review/editing through dedicated Source detail
## 5. Create Job Flow
### 5.1 User Intent
The user wants to start a transcription run by selecting the right Document and providing source files in one guided flow.
### 5.2 Create Entry
1. The user opens the Jobs page
2. The user selects Create job
3. The system opens Job detail/create page in create mode
### 5.3 Create Inputs
| UI Label | Schema Area | Input Type | Required | Notes |
|---|---|---|---|---|
| Document | Job.document_id | Select/search | Yes | Links the run to one Document |
| Source files | Source upload fields | Multi-file upload or folder upload | Yes | User may select one file, many files, or a folder |
| Processing order | Source.page_number assignment rule | System rule | Yes | If multiple files are uploaded, order is alphabetical by original filename |
| Provider | Job.provider | Display or select | No | Visible to user when known; selectable when options are available |
| Model | Job.model | Display or select | No | Visible to user when known; selectable when options are available |
| Prompt | Job.prompt_name | Display or select | No | Visible to user when known; selectable when options are available |
### 5.4 Source Handling Rules
1. Each uploaded file becomes a Source linked to the selected Document
2. Each created Source is linked to the Job through JobSource
3. Multi-file or folder uploads are processed alphabetically by original filename
4. upload_name stores the original filename
5. stored filename uses UUID plus original extension in the form UUID.extension
Suggested helper text:
1. Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control page order.
### 5.5 Validation Rules
Create submission must be blocked when:
1. no Document is selected
2. no source file is uploaded
Create submission should provide clear feedback when:
1. uploaded files are invalid or unreadable
2. persistence fails for Job, Source, or JobSource linkage
### 5.6 Submission Behavior
On submit:
1. validate create inputs
2. create Job record linked to selected Document
3. create Source records for uploaded files
4. create JobSource links for each Source in the Job
5. queue processing for transcription
6. route user to Job detail mode
Recommended transactional behavior:
1. intended create writes should succeed or fail together
2. The user should not receive false success when required records fail
### 5.7 Create Success Result
After successful create:
1. job appears in Jobs list
2. job detail shows selected Document and created source set
3. status appears as queued or processing based on execution timing
4. The user can monitor progress and open page-level review
### 5.8 Create Failure Result
If create fails:
1. Show clear error message
2. preserve entered selections where possible
3. keep retry path available
4. do not show false success feedback
## 6. Read Job Journey
### 6.1 User Intent
The user wants to quickly understand what the job is, its current status, and which source pages need review.
### 6.2 Jobs List Expectations
The Jobs list should show, at minimum:
1. job identifier
2. document context
3. current status
4. creation or update timestamp
5. quick action to open detail
Optional first-release columns if available:
1. retry count
2. provider/model summary
### 6.3 Job Detail Expectations
The Job detail should show:
1. job status and summary metadata
2. selected Document context
3. document-scoped and job-scoped navigation links
4. source review entry through job-scoped Sources list
Source detail should show:
1. source metadata and preview
2. original transcription output per source
3. revision editor and latest revised content
### 6.4 Read Empty and Missing States
If no jobs exist:
1. list shows no jobs yet empty state
2. list shows Create job action
If a job id is invalid or missing:
1. Show clear not found state
2. do not crash the page
If a job has no source items due to failure:
1. Show clear warning state
2. keep recovery guidance visible
## 7. Job Status Lifecycle UX
### 7.1 Status Values
The UI should map to model-backed job states:
1. queued
2. processing
3. transcribed
4. completed
5. partial_success
6. failed
### 7.2 In-Progress States
When status is queued or processing:
1. Show active progress state
2. keep detail page refresh-safe
3. indicate that source-level results may still be arriving
### 7.3 Terminal States
When status is completed:
1. Show completion success state
2. direct user to revision workflow
When status is partial_success:
1. Show mixed outcome state
2. identify failed pages
3. guide user to review available successful pages and retry strategy
When status is failed:
1. Show failure state with actionable message
2. keep navigation and retry guidance available
## 8. Update Job Journey
### 8.1 User Intent
The user primarily updates job-related review outcomes by editing revised transcription text per source page.
### 8.2 First-Release Editable Scope
Editable in first release:
1. source-level revised_text through Source detail reached from job-scoped Sources navigation
Read-only in first release:
1. Job.document_id after create
2. job status values managed by processing workflow
3. provider/model/prompt values may be system-managed, but should remain visible in UI when known
### 8.3 Update Save Behavior
On revision save:
1. validate revised text
2. persist revised text for selected source
3. update revised timestamp fields by system policy
4. show success feedback
On save failure:
1. Show clear error feedback
2. preserve entered text where possible
3. Allow retry
## 9. Delete and Retention Policy
### 9.1 User Intent
The user may need to remove invalid or duplicate jobs safely.
### 9.2 First-Release Policy
Delete behavior uses explicit guardrails:
1. deletion is blocked while status is processing
2. blocked delete explains constraints and offers back navigation
3. allowed delete requires explicit confirmation and then returns to Jobs list with success feedback
## 10. Relationship to Other Workflows
Job workflow integrates with:
1. Document workflow for ownership context
2. Source workflow for uploaded page records and ordering
3. Revision workflow for human correction lifecycle
4. Worker processing workflow for queued execution and status transitions
## 11. Relationship to Schema Mapping
The companion schema-mapping document should specify:
1. field visibility per CRUD action
2. current implementation status
3. intended behavior
4. gap-to-target items
## 12. Deferred Items
Deferred to future revisions:
1. manual retry controls from job detail
2. advanced provider/model/prompt policy controls beyond basic create-time visibility
3. advanced bulk actions across multiple jobs
4. live streaming progress updates beyond refresh-based updates
5. job templates or preset configurations
@@ -0,0 +1,154 @@
# Job Acceptance Criteria
Purpose: Define implementation-ready acceptance criteria for Job Create, Read, Update, and Delete workflows.
Companion documents:
- docs/ui/entities/job/user-journey.md
- docs/ui/entities/job/schema-mapping.md
## Scope
This checklist covers:
1. Create flow
2. Read flow
3. Update flow
4. Delete flow
This checklist does not cover:
1. provider-specific transcription internals
2. advanced workflow scheduling and queue orchestration controls
3. multi-job bulk operations
## Create Acceptance Criteria
### CR-1 Job creation entry
1. Given the user is on the Jobs page
2. When the user selects Create job
3. Then the user is taken to Job detail/create mode
### CR-2 Required create values
1. document_id must be selected before submit
2. at least one source file must be uploaded before submit
3. each uploaded file creates a Source linked to the selected Document
4. each created Source is linked to the new Job through JobSource
### CR-3 Source ordering behavior
1. Given multi-file or folder upload
2. When source records are created
3. Then page ordering follows alphabetical order of original filenames
4. Then helper text explains how filename conventions control ordering
### CR-4 Provider/model/prompt visibility
1. provider, model, and prompt_name are visible in create flow when known
2. provider, model, and prompt_name are visible in detail flow when known
3. if values are unknown at create time, UI shows clear unknown or pending state without blocking submit
### CR-5 Successful create outcome
1. Given valid inputs
2. When the user submits create
3. Then the Job record is created and linked to selected Document
4. Then source and JobSource records are created for uploads
5. Then job status is queued or processing based on execution timing
6. Then the user is routed to Job detail mode
### CR-6 Create failure outcome
1. Given create validation or persistence failure
2. Then clear error feedback is shown
3. Then no false success feedback is shown
4. Then entered selections are preserved where possible
5. Then retry path remains available
## Read Acceptance Criteria
### RD-1 Jobs list retrieval
1. Given one or more jobs exist
2. When the user opens the Jobs page
3. Then all jobs are listed in a table or equivalent list surface
### RD-2 Jobs list fields
1. Jobs list shows job id
2. Jobs list shows status
3. Jobs list shows created or updated timestamps
4. Jobs list shows retry_count when available
5. Jobs list provides navigation to Job detail for each row
### RD-3 Job detail retrieval
1. Given a valid job id
2. When the user opens Job detail
3. Then job metadata for that record only is shown
4. Then document-scoped navigation links for Sources and Jobs are shown
### RD-4 Detail execution context visibility
1. provider, model, and prompt_name are displayed when known
2. status lifecycle value is visible
3. source-level transcription and revision context is available through Source detail navigation from Job detail
### RD-5 Missing and invalid id states
1. Given an invalid job id format
2. Then UI shows invalid job id state without crashing
3. Given a valid but nonexistent job id
4. Then UI shows job not found state without crashing
## Update Acceptance Criteria
### UP-1 Revision edit entry
1. Given a job detail page
2. When the user opens the page
3. Then navigation links to job-scoped Sources are available
4. Then source rows can open Source detail revision workflow
### UP-2 Revision validation
1. revision save blocks empty trimmed text and shows warning feedback
### UP-3 Successful revision save
1. Source detail save persists revised text and shows success feedback
### UP-4 Revision save failure
1. Source detail save failure shows clear error feedback with retry path
### UP-5 Job lifecycle state update visibility
1. status changes from queued to processing to terminal states are reflected in UI
2. retry_count updates are reflected when retry logic runs
3. users cannot directly edit lifecycle state fields in first release
## Delete Acceptance Criteria
### DL-1 Delete entry and confirmation
1. Given a job detail context
2. When the user opens job delete page
3. Then a permanent-action confirmation is shown for non-processing jobs
### DL-2 Dependency guardrails
1. Delete is blocked while job status is processing
2. Related JobSource links are removed as part of allowed delete flow
### DL-3 Blocked delete behavior
1. When blocked, the UI shows clear processing-state guidance
2. The user is offered navigation back to job or jobs list
### DL-4 Successful delete
1. Given an allowed delete
2. When the user confirms delete
3. Then the job is removed and success feedback is shown
4. Then the user is returned to Jobs list
### DL-5 Delete failure
1. Given backend failure during delete
2. Then clear error feedback is shown
3. Then the user remains in delete context with retry path
## Cross-Criteria Quality Gates
### QG-1 Separation of intent and implementation
1. UX intent remains in user-journey.md
2. Current versus target implementation mapping remains in schema-mapping.md
### QG-2 Traceability
1. Each accepted behavior maps to at least one UI action or service path
2. No acceptance criterion contradicts first-release deferred items
### QG-3 First-release constraints
1. Jobs page remains list-all with explicit Create job action
2. Job create requires Document selection and source upload
3. provider/model/prompt_name are visible to users when known
4. manual retry controls may remain deferred while status visibility is required
@@ -0,0 +1,147 @@
# Person Acceptance Criteria
Purpose: Define implementation-ready acceptance criteria for Person Create, Read, Update, and Delete workflows.
Companion documents:
- docs/ui/entities/person/user-journey.md
- docs/ui/entities/person/schema-mapping.md
## Scope
This checklist covers:
1. Create flow
2. Read flow
3. Update flow
4. Delete flow
This checklist does not cover:
1. advanced metadata_ editing UX
2. structured-name schema migration implementation
3. bulk merge or dedup workflow design
## Create Acceptance Criteria
### CR-1 Person creation entry
1. Given a Person page
2. When the user selects Create new person
3. Then the user can open a Person create form
### CR-2 Required field validation
1. full_name is required
2. Save is blocked when full_name is empty
3. Inline feedback is shown for required-field errors
### CR-3 Optional field handling
1. Optional fields may be blank without blocking create
2. Date raw and exact fields can coexist
3. Exact date remains canonical when both exact and raw are provided
4. Portrait uploads persist under uploads/portraits/person and store a relative portrait_path
### CR-4 Successful create outcome
1. Given valid input
2. When the user saves
3. Then the Person record is created
4. Then success feedback is shown
5. Then the user is routed to Person detail page
### CR-5 Create failure outcome
1. Given backend failure during create
2. Then clear error feedback is shown
3. Then entered values are retained where possible
4. Then no false success feedback is shown
## Read Acceptance Criteria
### RD-1 Person detail retrieval
1. Given a valid Person id
2. When the user opens the Person detail page
3. Then the system displays Person metadata for that record only
### RD-2 Metadata visibility
1. The page shows full_name and available optional person fields
2. created_at and updated_at are shown as system-managed, read-only values
3. portrait_path is rendered when available, including an image preview when possible
4. relative portrait_path values resolve through /uploads for image rendering
### RD-3 Linked documents section
1. Given no linked DocumentPerson rows
2. Then the page shows a no linked documents yet empty state
3. Given linked documents exist
4. Then the page shows linked document entries
### RD-4 Read failure state
1. Given a nonexistent Person id
2. Then the UI shows a clear not found state without crashing
## Update Acceptance Criteria
### UP-1 Edit entry
1. Given a loaded Person detail page
2. When the user selects Edit person
3. Then editable controls are shown for allowed fields only
### UP-2 Editable fields
1. Editable: full_name, display_name, maiden_name, birth/death fields, places, biography, portrait_path
2. Not editable: id, created_at, updated_at
3. metadata_ remains hidden in first release
### UP-3 Required validation
1. full_name remains required
2. Save is blocked with inline feedback when full_name is empty
### UP-4 Successful save
1. Given valid input
2. When the user saves
3. Then changes persist
4. Then success feedback is shown
5. Then the user remains on Person detail with refreshed values
### UP-5 Save failure
1. Given backend failure during save
2. Then clear error feedback is shown
3. Then the user-entered values remain available for retry where possible
4. Then no false success feedback is shown
## Delete Acceptance Criteria
### DL-1 Delete entry and confirmation
1. Given a Person detail page
2. When the user selects Delete person
3. Then a confirmation dialog appears with permanent-action wording
### DL-2 Relationship guardrails
1. Delete is allowed only when relationship policy allows it
2. If linked DocumentPerson rows must be removed first, delete is blocked
### DL-3 Blocked delete behavior
1. When blocked
2. Then the UI explains why deletion is blocked
3. Then the UI identifies linked-document dependency presence
4. Then the UI provides navigation to cleanup paths
### DL-4 Successful delete
1. Given no blocking dependencies
2. When the user confirms delete
3. Then the Person record is removed
4. Then success feedback is shown
5. Then the user returns to the Person list page
### DL-5 Delete failure
1. Given backend failure during delete
2. Then clear error feedback is shown
3. Then the user remains on Person detail with retry path
## Cross-Criteria Quality Gates
### QG-1 Separation of intent and implementation
1. UX intent remains in user-journey.md
2. Current versus target implementation mapping remains in schema-mapping.md
### QG-2 Traceability
1. Each accepted behavior maps to at least one future UI action or service call path
2. No acceptance criterion contradicts the deferred-item policy
### QG-3 First-release constraints
1. metadata_ remains hidden in first release
2. structured name field split remains deferred
3. recipient and multi-person role management stays in later revisions
+265
View File
@@ -0,0 +1,265 @@
# Person Schema-to-UI Mapping
Purpose: Map the Person schema to the UI, while clearly separating intended target behavior from current implementation.
Companion document: user-journey.md
Acceptance criteria: acceptance-criteria.md
## 1. Entity Snapshot
- Table: Person
- Primary key: id (UUID)
- Related entities: DocumentPerson, Document
- Canonical schema references:
- src/transcription/db/models.py
- docs/schema_v2.md
## 2. Mapping Rules
This document uses three lenses:
1. Intended behavior: what the UX should support.
2. Current behavior: what the code supports today.
3. Gap to target: what must change to align implementation with the intended UX.
## 3. Field Inventory
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|---|---|---|---|---|---|
| id | UUID | No | uuid4() | Hidden, system-managed | Primary key |
| full_name | str | No | None | Shown, editable on create and update | Required canonical name |
| display_name | str | Yes | None | Shown, editable | Optional |
| maiden_name | str | Yes | None | Shown, editable | Optional |
| birth_date | date | Yes | None | Shown, editable | Canonical exact date when present |
| birth_date_raw | str | Yes | None | Shown, editable | Approximate or unknown date text |
| birth_place | str | Yes | None | Shown, editable | Optional |
| death_date | date | Yes | None | Shown, editable | Canonical exact date when present |
| death_date_raw | str | Yes | None | Shown, editable | Approximate or unknown date text |
| death_place | str | Yes | None | Shown, editable | Optional |
| biography | str | Yes | None | Shown, editable | Optional narrative |
| portrait_path | str | Yes | None | Shown, editable | Optional path |
| metadata_ | JSONB/JSON | Yes | None | Hidden in first release | Advanced metadata |
| created_at | datetime | No | datetime.now(UTC) | Hidden or read-only | System-managed |
| updated_at | datetime | No | datetime.now(UTC) | Hidden or read-only | System-managed |
## 4. CREATE Mapping
### 4.1 Intended Create Flow
Entry point: Person page
User action: Create new person
Success destination: new Person detail page
| Field | Intended User Input | Required | Visible | Notes |
|---|---|---|---|---|
| full_name | Text input | Yes | Yes | Canonical identity field |
| display_name | Text input | No | Yes | Optional |
| maiden_name | Text input | No | Yes | Optional |
| birth_date | Date input | No | Yes | Structured exact date |
| birth_date_raw | Text input | No | Yes | Approximate/uncertain date |
| birth_place | Text input | No | Yes | Optional |
| death_date | Date input | No | Yes | Structured exact date |
| death_date_raw | Text input | No | Yes | Approximate/uncertain date |
| death_place | Text input | No | Yes | Optional |
| biography | Text area | No | Yes | Optional |
| portrait_path | Text input | No | Yes | Optional |
| metadata_ | None | No | No | Hidden in first release |
| created_at | None | No | No | System-generated |
| updated_at | None | No | No | Not user-entered |
Related records during intended create:
- No DocumentPerson link is required during Person creation.
- Document linking can be done later from Document or Person workflows.
### 4.2 Current Implementation
Current entry point: dedicated People page and Person create/edit flows
Current user action: open Person create page, fill form fields, optionally upload portrait
Current backend path: People page submit callbacks -> DocumentService.create_person() / update_person()
| Field | Current Value at Create | Source | Visible to User | Evidence |
|---|---|---|---|---|
| id | Generated UUID | System | No | Person model default factory in src/transcription/db/models.py |
| full_name | Form input | User input | Yes | src/transcription/ui/pages/people_page.py |
| display_name | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| maiden_name | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| birth_date | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| birth_date_raw | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| birth_place | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| death_date | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| death_date_raw | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| death_place | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| biography | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
| portrait_path | Relative upload path or manual path | Upload helper + user input | Yes | src/transcription/ui/pages/people_page.py, src/transcription/services/store.py |
| metadata_ | Caller-provided or None | Service/API caller | No | Person model in src/transcription/db/models.py |
| created_at | Current UTC timestamp | System | No | Person model default in src/transcription/db/models.py |
| updated_at | Current UTC timestamp | System | No | Person model default in src/transcription/db/models.py |
### 4.3 Gap to Target
To satisfy the intended Create flow, implementation now includes:
1. a Person page and dedicated create form
2. user-entered controls for Person fields
3. create validation and success/failure UX states
4. post-submit routing to a Person detail page
## 5. READ Mapping
### 5.1 Intended Read Behavior
On the Person detail page, the user should be able to see:
1. Person identity and biographical metadata
2. linked Documents (through DocumentPerson)
3. empty-state behavior when no linked documents exist
### 5.2 Current Implementation
Current Person visibility is implemented in dedicated list/detail/edit/delete pages.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| full_name | Rendered in header and summary | Yes | Dedicated Person page exists | `src/transcription/ui/pages/people_page.py` |
| display_name | Rendered | Yes | Visible in detail and list contexts | src/transcription/ui/pages/people_page.py |
| maiden_name | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| birth_date | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| birth_date_raw | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| birth_place | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| death_date | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| death_date_raw | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| death_place | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| biography | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| portrait_path | Rendered as text and image when available | Yes | Dedicated Person page exists | `src/transcription/ui/pages/people_page.py` |
| metadata_ | Not rendered | No | Hidden advanced field | no current UI field |
| created_at | Rendered read-only | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
| updated_at | Rendered read-only | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
### 5.3 Gap to Target
To satisfy the intended Read flow, implementation now includes:
1. metadata rendering for Person fields
2. linked Documents section with empty states
3. document-link navigation paths
## 6. UPDATE Mapping
### 6.1 Intended Update Behavior
The user should be able to edit Person metadata from the Person detail page or a dedicated edit flow.
Intended editable fields:
- full_name
- display_name
- maiden_name
- birth_date
- birth_date_raw
- birth_place
- death_date
- death_date_raw
- death_place
- biography
- portrait_path
Intended system-managed fields:
- id
- created_at
- updated_at
Hidden in first release:
- metadata_
### 6.2 Current Implementation
| Field | Updatable via UI | Updatable via Service | Notes |
|---|---|---|---|
| id | No | Practically no | Primary key should be treated as immutable |
| full_name | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| display_name | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| maiden_name | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| birth_date | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| birth_date_raw | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| birth_place | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| death_date | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| death_date_raw | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| death_place | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| biography | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
| portrait_path | Yes | Yes | Editable manually and via portrait upload helper |
| metadata_ | No | Yes | Technically updatable, hidden in first release |
| created_at | No | Technically yes | Should remain system-managed |
| updated_at | No | Technically yes | Should remain system-managed |
### 6.3 Gap to Target
Implementation now includes:
1. Person edit controls in the UI
2. validation and save behavior for Person metadata
3. a consistent updated_at update policy for Person edits
## 7. DELETE Mapping
### 7.1 Intended Delete Behavior
The UI should provide a delete action for Person with guardrails.
Rules:
1. Deletion can proceed when relationship policy allows no retained document links.
2. If linked DocumentPerson records exist and policy requires cleanup first, deletion is blocked.
3. Delete confirmation must make clear that deletion is permanent.
### 7.2 Current Implementation
| Action | UI Exposed | Backend Capability | Notes |
|---|---|---|---|
| Delete Person | Yes | Yes | Dedicated delete page enforces linked-document guardrails before service delete |
### 7.3 Gap to Target
Implementation includes:
1. a Person delete control in the UI
2. relationship-aware pre-delete checks
3. user-facing blocked-delete messaging
4. confirmation UX for successful delete attempts
## 8. Hidden and System-Managed Fields
| Field | Category | Why Hidden or Protected |
|---|---|---|
| id | System-managed | Internal identifier |
| created_at | System-managed | Audit timestamp |
| updated_at | System-managed | Audit timestamp |
| metadata_ | Hidden in first release | Advanced JSON metadata not needed in initial UI |
## 9. Structured Name Deferred Note
Structured name fields are deferred to a future schema revision.
Current policy:
1. full_name remains canonical and required.
Future revision intent:
1. introduce first_name, middle_name, last_name, and optional suffix fields.
2. maintain compatibility with existing full_name records during migration.
3. define normalization and reconciliation rules when structured and canonical forms differ.
## 10. Traceability Anchors
Schema and models:
- docs/schema_v2.md
- src/transcription/db/models.py
Current implementation:
- src/transcription/services/documents.py
- src/transcription/ui/pages/people_page.py
- src/transcription/services/store.py
Companion UX spec:
- docs/ui/entities/person/user-journey.md
Acceptance checklist:
- docs/ui/entities/person/acceptance-criteria.md
## 11. Acceptance Checklist Summary
- Every Person schema field appears in the field inventory.
- Intended Create behavior matches the companion user journey.
- Current Create behavior reflects dedicated UI form implementation with optional portrait upload handling.
- Gaps between intended and current behavior are explicit.
- Read, Update, and Delete sections distinguish target behavior from current code.
+292
View File
@@ -0,0 +1,292 @@
# Person User Journey
Purpose: Define how a user should interact with the UI to create and manage a Person record, including expected inputs, validation, outcomes, and links to Document relationships.
Scope: This document describes intended user interaction for the Person UI. It is the UX contract for the Person entity.
Companion schema mapping: schema-mapping.md
Companion acceptance criteria: acceptance-criteria.md
## 1. Overview
A Person represents a historical individual who may be associated with one or more Documents.
Managing a Person is a profile-first workflow:
1. The user opens the Person page.
2. The user selects Create new person.
3. The user enters known biographical fields.
4. The system creates the Person record.
5. The user can later associate the Person with one or more Documents through DocumentPerson links.
## 2. User Goal
The user wants to:
1. create and maintain historical person records
2. reuse the same Person across multiple Documents
3. record both precise and approximate date values where certainty is limited
4. link people to documents as author or recipient in future flows
## 3. Page Model
### 3.1 Person Page
The Person page is the general UI surface where users manage people.
It should support:
1. listing or locating existing people
2. starting the Create new person flow
3. navigating into a specific Person after it exists
### 3.2 Person Detail Page
The Person detail page is the page for one specific Person after creation.
It should show:
1. core identity fields
2. biographical metadata
3. portrait image when available
4. related Documents section
5. empty state when no linked documents exist yet
## 4. Entry Point
Entry point: Person page
Primary action: Create new person
Expected UI affordance:
1. a visible action labeled Create new person
2. activation opens a dedicated form view, modal, or detail panel
Preferred first implementation:
1. dedicated Person create page or panel
2. simple labeled form controls
3. text inputs are acceptable for first release
## 5. Create Person Form
### 5.1 Required Fields
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Full name | full_name | Text input | Yes | Canonical identity field |
### 5.2 Optional Name Fields
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Display name | display_name | Text input | No | Friendly or abbreviated display |
| Maiden name | maiden_name | Text input | No | Historical alternate surname |
### 5.3 Birth and Death Date Fields
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Birth date | birth_date | Date input | No | Exact known date |
| Birth date (approximate/raw) | birth_date_raw | Text input | No | Approximate or uncertain value |
| Death date | death_date | Date input | No | Exact known date |
| Death date (approximate/raw) | death_date_raw | Text input | No | Approximate or uncertain value |
Date handling rule:
1. exact and raw values may both be entered
2. exact date is canonical when present
3. raw date is retained as historical context
### 5.4 Optional Biographical Fields
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Birth place | birth_place | Text input | No | Free text |
| Death place | death_place | Text input | No | Free text |
| Biography | biography | Text area | No | Narrative context |
| Portrait path | portrait_path | Text input | No | File or resource path |
| Metadata | metadata_ | Hidden or advanced JSON editor | No | Prefer hidden in first release |
### 5.5 System Fields
| Schema Field | User Editable | Notes |
|---|---|---|
| id | No | System-generated |
| created_at | No | System-generated |
| updated_at | No | System-managed |
## 6. Validation Rules
### 6.1 Required Validation
1. full_name is required
2. save is blocked when full_name is empty
### 6.2 Date Validation
1. birth_date and birth_date_raw may coexist
2. death_date and death_date_raw may coexist
3. exact date fields are canonical when present
4. raw fields remain descriptive context
### 6.3 Integrity Validation
1. form accepts unknown values for optional fields
2. missing birth or death data does not block creation
## 7. Submission Behavior
On submit:
1. The system validates required fields
2. The system creates the Person record
3. The system returns the user to the Person detail page
4. The system shows a success message
5. If portrait upload is used, the file is stored under uploads/portraits/person and portrait_path is set to that relative file path
Recommended transactional behavior:
1. Person writes are atomic
2. no partial save state should be persisted
## 8. Expected Result After Success
After successful creation:
1. The user sees the Person detail page for the new record
2. full_name is visible in the header or summary
3. empty Related Documents section is shown if no links exist
4. The user can proceed to link this person from Document workflows
## 9. Expected Result After Failure
If creation fails:
1. Show a clear error message
2. Show field-level feedback for validation failures
3. preserve entered data where possible
4. do not show false success messaging
## 10. Read Person Journey
### 10.1 User Intent
The user wants to open a Person and quickly understand:
1. identity and key biography fields
2. whether this person is linked to any documents
3. what next action to take
### 10.2 Read Surfaces
The Person detail page should show:
1. full_name and display fields
2. birth and death fields
3. biography summary
4. related documents list or empty state
5. portrait preview resolved from /uploads when portrait_path is a relative path
### 10.3 Read Empty State
If no linked documents exist:
1. Show No linked documents yet
2. provide guidance to link from Document workflow
## 11. Update Person Journey
### 11.1 User Intent
The user wants to correct or enrich person metadata over time.
### 11.2 Editable Fields
Editable:
1. full_name
2. display_name
3. maiden_name
4. birth_date
5. birth_date_raw
6. birth_place
7. death_date
8. death_date_raw
9. death_place
10. biography
11. portrait_path
System-managed:
1. id
2. created_at
3. updated_at
4. metadata_ can remain hidden in first release
### 11.3 Update Save Behavior
On save:
1. validate required fields
2. persist updates
3. refresh updated_at by system policy
4. show confirmation
5. keep user on Person detail page
### 11.4 Update Failure Behavior
1. Show clear error feedback
2. preserve form state where possible
3. Allow retry
## 12. Delete Person Journey
### 12.1 User Intent
The user wants to remove incorrect or duplicate person records safely.
### 12.2 Delete Guardrails
Delete is allowed when:
1. Person has no required retained relationships
Delete is blocked when:
1. Person is linked to one or more Documents via DocumentPerson and unlink policy requires cleanup first
### 12.3 Blocked Delete UX
1. explain that linked Document relationships exist
2. Show link count or list
3. provide cleanup path
### 12.4 Allowed Delete UX
1. Show a confirmation dialog
2. confirm permanent action
3. delete Person
4. return to Person list with success message
## 13. Relationship to Other Workflows
This Person workflow integrates with:
1. Document create and update workflows through person lookup and linking
2. DocumentPerson mapping for role assignments
3. future recipient and multi-person enhancements
## 14. Relationship to Schema Mapping
The companion schema-mapping document should specify:
1. field visibility per CRUD action
2. current implementation status
3. intended behavior
4. gap-to-target items
## 15. Deferred Items
Deferred to future revisions:
1. advanced metadata_ editing UI
2. multi-person role editing in the Person UI itself
3. richer relationship timeline views
4. bulk merge or dedup workflows
5. structured name fields migration (first_name, middle_name, last_name, optional suffix)
### 15.1 Structured Name Fields Migration Note
For now, `full_name` remains the canonical required name field.
Future revision intent:
1. introduce structured fields such as first_name, middle_name, last_name, and optional suffix
2. keep full_name during transition for backward compatibility and historical formatting
3. define normalization and formatting rules for display and sorting
4. update search and dedup workflows to use both structured and canonical forms during migration
Migration considerations:
1. schema migration and backfill strategy for existing Person records
2. validation updates for create and update forms
3. compatibility for existing APIs and UI components that currently rely on full_name
4. clear precedence and reconciliation rules when structured fields and full_name differ
@@ -0,0 +1,141 @@
# Source Acceptance Criteria
Purpose: Define implementation-ready acceptance criteria for Source Create, Read, Update, and Delete workflows.
Companion documents:
- docs/ui/entities/source/user-journey.md
- docs/ui/entities/source/schema-mapping.md
## Scope
This checklist covers:
1. Create flow
2. Read flow
3. Update flow
4. Delete flow
This checklist does not cover:
1. advanced multi-version revision history design
2. job orchestration state-machine behavior
3. provider-level transcription internals
## Create Acceptance Criteria
### CR-1 Source creation entry
1. Given the user is in job creation or job configuration flow
2. When the user selects Add sources
3. Then the user can upload one or more source files or a folder
4. Then source creation is not offered as a standalone first-release document-only flow
### CR-2 Required create values
1. document_id is derived from selected Document context
2. JobSource.job_id is derived from the active Job context
3. Each created Source is linked to the active Job through JobSource at create time
4. page_number is assigned to preserve ordering
5. upload_name, filename, and file_path are persisted for each created source
### CR-3 Ordering and filename strategy
1. Given a multi-file or folder upload
2. When source records are created
3. Then page ordering follows alphabetical order of original filenames
4. Then upload_name stores the original filename
5. Then filename is stored using UUID plus original extension in the form UUID.extension
### CR-4 Successful create outcome
1. Given valid uploads
2. When source creation completes
3. Then Source records are created and linked to the Document
4. Then Source records are linked to the active Job through JobSource
5. Then source list reflects new pages in sequence
6. Then the user can open preview or revision workflow
### CR-5 Create failure outcome
1. Given upload or persistence failure
2. Then clear error feedback is shown
3. Then no false success feedback is shown
4. Then retry path remains available
5. Then creation fails when required Document or Job linkage cannot be established
## Read Acceptance Criteria
### RD-1 Source detail retrieval
1. Given a valid Source id in source context
2. When the user opens source detail
3. Then source metadata and preview are displayed for that source only
### RD-2 Transcription and revision visibility
1. Original transcription context is visible read-only in Source detail
2. Revision state is visible in Source detail
3. If revised_text is absent, revision input opens as empty and can be edited
### RD-3 Missing source state
1. Given a missing source
2. Then UI shows clear no source available or not found messaging without crashing
## Update Acceptance Criteria
### UP-1 Revision editing entry
1. Given a source context
2. When the user enters revision edit flow
3. Then revised_text input is available in Source detail
### UP-2 Revision validation
1. revised_text cannot be saved as empty after trimming
2. Warning feedback is shown for invalid empty input
### UP-3 Successful revision save
1. Given valid revision text
2. When the user saves
3. Then revised_text persists
4. Then date_revised is updated
5. Then success feedback is shown
6. Then refreshed revision content is visible
### UP-4 Revision save failure
1. Given backend failure during save
2. Then clear error feedback is shown
3. Then the user-entered text remains available for retry where possible
## Delete Acceptance Criteria
### DL-1 Delete entry and confirmation
1. Given a source in source context
2. When the user selects delete source
3. Then a permanent-action confirmation dialog appears
### DL-2 Dependency guardrails
1. If policy requires cleanup of related JobSource records first, delete is blocked
2. If policy allows dependent cleanup path, delete can proceed
### DL-3 Blocked delete behavior
1. When blocked
2. Then UI explains dependency constraints
3. Then UI provides guidance for dependency cleanup
### DL-4 Successful delete
1. Given no blocking dependencies
2. When the user confirms deletion
3. Then source is removed
4. Then success feedback is shown
5. Then the user returns to source list context
### DL-5 Delete failure
1. Given backend failure during delete
2. Then clear error feedback is shown
3. Then the user remains in source context with retry path
## Cross-Criteria Quality Gates
### QG-1 Separation of intent and implementation
1. UX intent remains in user-journey.md
2. Current versus target implementation mapping remains in schema-mapping.md
### QG-2 Traceability
1. Each accepted behavior maps to at least one future UI action or service path
2. No acceptance criterion contradicts first-release deferred items
### QG-3 First-release constraints
1. Source creation remains job-create-centric
2. revised_text is the primary editable source field in first release
3. source creation requires both Document linkage and Job linkage at create time
4. source delete management surfaces are phased in later
+212
View File
@@ -0,0 +1,212 @@
# Source Schema-to-UI Mapping
Purpose: Map the Source schema to the UI, while clearly separating intended target behavior from current implementation.
Companion document: user-journey.md
Acceptance criteria: acceptance-criteria.md
## 1. Entity Snapshot
- Table: Source
- Primary key: id (UUID)
- Related entities: Document, JobSource, Job
- Canonical schema references:
- src/transcription/db/models.py
- docs/schema_v2.md
## 2. Mapping Rules
This document uses three lenses:
1. Intended behavior: what the UX should support.
2. Current behavior: what the code supports today.
3. Gap to target: what must change to align implementation with the intended UX.
## 3. Field Inventory
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|---|---|---|---|---|---|
| id | UUID | No | uuid4() | Hidden, system-managed | Primary key |
| document_id | UUID FK | No | None | Hidden/context-managed | Selected Document context |
| page_number | int | No | 1 | Shown read-only or ordered list | Sequential ordering |
| upload_name | str | No | None | Shown read-only after upload | Original user-provided name |
| filename | str | No | None | Shown read-only | Stored filename |
| file_path | str | No | None | Usually hidden; preview uses path internally | Filesystem path |
| raw_transcription | str | Yes | None | Shown indirectly or hidden | Immutable machine output context |
| revised_text | str | Yes | None | Editable in Source detail | Human-authored correction |
| date_uploaded | datetime | No | datetime.now(UTC) | Shown read-only | System-managed timestamp |
| date_revised | datetime | Yes | None | Shown read-only | Set when revision is saved |
## 4. CREATE Mapping
### 4.1 Intended Create Flow
Entry point: Job creation or job configuration Add sources action
User action: upload one or more source files, or a whole folder
Success destination: source preview or revision flow in job detail context
| Field | Intended User Input | Required | Visible | Notes |
|---|---|---|---|---|
| document_id | Hidden/context | Yes | No | Comes from selected Document |
| JobSource.job_id | Hidden/context | Yes | No | Comes from active Job; required for first release |
| page_number | Auto or user-assisted ordering | Yes | Indirectly | Should preserve sequence |
| upload_name | File picker name | Yes | Yes | Original display name |
| filename | None | Yes | No or read-only | System-stored as UUID.extension |
| file_path | None | Yes | No | Storage path |
| raw_transcription | None | No | No | Filled by processing |
| revised_text | None | No | No | Initially empty |
| date_uploaded | None | No | No | System-generated |
| date_revised | None | No | No | Null until revision |
### 4.2 Current Implementation
Current entry point: Jobs page create flow
Current user action: upload one or more files or a folder through a single upload widget
Current backend path: job create submit -> create_job_for_document()
| Field | Current Value at Create | Source | Visible to User | Evidence |
|---|---|---|---|---|
| id | Generated UUID | System | No | Source model default in src/transcription/db/models.py |
| document_id | Selected existing Document id | Job create selection + service write | Indirectly | src/transcription/ui/pages/jobs_page.py, src/transcription/services/store.py |
| page_number | Sequential assignment based on existing max and alphabetical upload order | Service | No | src/transcription/services/store.py |
| upload_name | original filename basename | User file name transformed by service | Indirectly | src/transcription/services/store.py |
| filename | stored generated filename | Service | Indirectly | src/transcription/services/store.py |
| file_path | stored path | Service | Indirectly | src/transcription/services/store.py |
| raw_transcription | None initially | System | No at create | Source model defaults |
| revised_text | None initially | System | No at create | Source model defaults |
| date_uploaded | current UTC timestamp | System | No | Source model default |
| date_revised | None | System | No | Source model default |
### 4.3 Gap to Target
To satisfy intended Create flow, implementation now includes:
1. multi-source and folder upload support in job create/configure flows
2. deterministic page_number assignment from alphabetical original filename ordering
3. enforced create-time Source-to-Document and Source-to-Job linkage invariants
4. filename storage policy using UUID.extension
## 5. READ Mapping
### 5.1 Intended Read Behavior
On Source detail/list surfaces, users should be able to see:
1. source page preview
2. source metadata and ordering
3. revision state
4. original transcription context
### 5.2 Current Implementation
Current Source reading is centered on dedicated Sources list/detail routes with optional document/job filtering.
| Field | Current Rendering | Visible to User | Notes | Evidence |
|---|---|---|---|---|
| upload_name | Shown in Sources list and Source detail | Yes | Displayed in source context | src/transcription/ui/pages/sources_page.py |
| filename | Shown in Sources list and Source detail | Yes | Source metadata shown in list/detail | src/transcription/ui/pages/sources_page.py |
| file_path | Hidden from direct text rendering | No | Used internally for preview rendering | src/transcription/ui/components/document_panzoom.py |
| page_number | Shown in Sources list and Source detail | Yes | Ordering visible in filtered/global list | src/transcription/ui/pages/sources_page.py |
| raw_transcription | Shown read-only in Source detail | Yes | Read from latest linked JobSource context | src/transcription/ui/pages/sources_page.py |
| revised_text | Shown and editable in Source detail | Yes | Saved through revision action | src/transcription/ui/pages/sources_page.py |
| date_uploaded | Shown in Source detail | Yes | Read-only metadata | src/transcription/ui/pages/sources_page.py |
| date_revised | Shown in Source detail | Yes | Read-only metadata after revision save | src/transcription/ui/pages/sources_page.py |
### 5.3 Gap to Target
To satisfy intended Read flow, implementation must add:
1. optional list filtering controls in-page (current filtering is URL/context based)
2. optional page-specific navigation enhancements beyond current list/detail pattern
## 6. UPDATE Mapping
### 6.1 Intended Update Behavior
Primary user update for Source is revised_text maintenance in Source detail.
Intended editable fields (first release):
- revised_text
Intended read-only fields (first release):
- document_id
- page_number
- upload_name
- filename
- file_path
- raw_transcription
- date_uploaded
- date_revised
### 6.2 Current Implementation
| Field | Updatable via UI | Updatable via Service | Notes |
|---|---|---|---|
| revised_text | Yes | Yes | Saved via TranscriptionService.upsert_revision_for_source() from Source detail |
| date_revised | No | Yes | Set automatically on revision save |
| other fields | No | Technically yes in service layer | No first-class UI editing flow |
### 6.3 Gap to Target
Implementation should add in a later revision:
1. optional future controls for page ordering and metadata corrections
2. revision history and conflict-resolution UX beyond single revised_text updates
## 7. DELETE Mapping
### 7.1 Intended Delete Behavior
Source deletion is deferred in the current UI.
Rules:
1. Deletion can proceed when policy allows cleanup of related JobSource records.
2. If related execution history must be preserved first, deletion is blocked with guidance.
### 7.2 Current Implementation
| Action | UI Exposed | Backend Capability | Notes |
|---|---|---|---|
| Delete Source | No | Yes | TranscriptionService.delete_source() exists, no dedicated UI delete flow |
### 7.3 Gap to Target
Implementation should add in a future revision:
1. source delete controls in source/document context UI
2. dependency checks for JobSource links
3. blocked-delete messaging and cleanup path guidance
4. confirmation UX for successful delete attempts
## 8. Hidden and System-Managed Fields
| Field | Category | Why Hidden or Protected |
|---|---|---|
| id | System-managed | Internal identifier |
| document_id | Context-managed | Derived from selected document context |
| file_path | Operational/internal | Used for file storage and preview plumbing |
| date_uploaded | System-managed | Audit timestamp |
| date_revised | System-managed | Revision timestamp set by system |
## 9. Traceability Anchors
Schema and models:
- docs/schema_v2.md
- src/transcription/db/models.py
Current implementation:
- src/transcription/services/store.py
- src/transcription/services/transcription.py
- src/transcription/ui/pages/sources_page.py
- src/transcription/ui/pages/jobs_page.py
- src/transcription/ui/pages/documents_page.py
- src/transcription/ui/components/document_panzoom.py
Companion UX spec:
- docs/ui/entities/source/user-journey.md
Acceptance checklist:
- docs/ui/entities/source/acceptance-criteria.md
## 10. Acceptance Checklist Summary
- Every Source schema field appears in the field inventory.
- Intended Create behavior matches the companion user journey.
- Source create invariant requires both Document linkage and Job linkage at create time.
- Current behavior reflects upload-centric create flow and dedicated Sources list/detail review flow.
- Gaps between intended and current behavior are explicit.
- Read, Update, and Delete sections distinguish target behavior from current code.
+229
View File
@@ -0,0 +1,229 @@
# Source User Journey
Purpose: Define how a user should interact with the UI to create and manage Source records, including page-level transcription context and revision behavior.
Scope: This document describes intended user interaction for the Source UI. It is the UX contract for the Source entity.
Companion schema mapping: schema-mapping.md
Companion acceptance criteria: acceptance-criteria.md
## 1. Overview
A Source represents one page or file unit associated with a Document.
Managing Source records is page-first:
1. The user starts from a transcription job flow.
2. The user adds one or more source files.
3. The system creates Source records linked to the Document and linked to the Job through JobSource.
4. The user reviews source lists from a dedicated Sources page.
5. The user opens Source detail to review preview, metadata, transcription text, and revision text.
## 2. User Goal
The user wants to:
1. add page files to a Document
2. ensure every source is attached to the transcription job context
3. keep page order reliable
4. review original machine output
5. save human revisions per page
6. navigate source pages efficiently
## 3. Page Model
### 3.1 Source List Surface
A Source list surface should support:
1. listing source pages globally or filtered by selected Document or Job
2. sorting by page_number
3. opening the owning Document or Job context
4. opening Source detail for a selected source
### 3.2 Source Detail Surface
Source detail supports:
1. pan/zoom image or PDF preview
2. read-only source metadata (page number, names, timestamps)
3. read-only original transcription text
4. editable revision text with save action
## 4. Entry Points
Primary entry points:
1. from Job workflow, Add sources while creating or configuring a job
2. from Job detail, open filtered Sources for the current Job
3. from Document detail, open filtered Sources for the current Document
4. from global navigation, open all Sources
Current implementation note:
1. source interaction occurs in job-create flow and dedicated Sources list/detail flows
## 5. Create Source Flow
### 5.1 User Intent
The user wants to attach one or more files to a Document so each page can be processed and reviewed.
### 5.2 Create from Job Context
1. The user starts from a job-creation or job-configuration flow
2. The user can upload one or more files, or upload a whole folder
3. The system creates Source rows linked to the selected Document
4. The system creates JobSource links for the active Job as part of this flow
5. Source creation fails if required Document or Job linkage cannot be established
### 5.3 Source Create Inputs
| UI Label | Schema Field | Input Type | Required | Notes |
|---|---|---|---|---|
| Source files | upload_name/filename/file_path | Multi-file upload or folder upload | Yes | User may select one file, many files, or a folder |
| Processing order | page_number assignment rule | System rule | Yes | If multiple files are uploaded, processing order is alphabetical by original filename |
| Document reference | document_id | Hidden/context | Yes | Comes from selected Document |
| Job reference | JobSource.job_id | Hidden/context | Yes | Required for first-release source creation |
### 5.4 Filename Strategy
1. store original user filename in upload_name
2. store persisted filename using UUID plus original extension only, in the form UUID.extension
3. this replaces the previous UUID-upload_name.extension pattern
### 5.5 Ordering Guidance
1. multi-file or folder uploads are processed alphabetically by original filename
2. UI should show a warning or helper note so users understand that filename conventions control order
Suggested helper text:
1. Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control page order.
### 5.6 System-Managed Values at Create
| Schema Field | User Editable | Notes |
|---|---|---|
| id | No | System-generated |
| date_uploaded | No | System-generated |
| raw_transcription | No | Filled later by processing |
| revised_text | No | Initially empty |
| date_revised | No | Initially null |
### 5.7 Expected Create Result
After successful source create:
1. Source is linked to the Document
2. Source appears in page order derived from alphabetical upload filename ordering
3. Source is linked to the Job through JobSource at create time
4. The user can open the owning Document or Job context
### 5.8 Source Creation Invariant
For first release:
1. every new Source must have a Document link (Source.document_id)
2. every new Source must have a Job link through JobSource (JobSource.job_id -> JobSource.source_id)
3. source creation is treated as part of transcription workflow, not a standalone document-only upload path
## 6. Read Source Journey
### 6.1 User Intent
The user wants to view each page file and understand file identity and processing context.
### 6.2 Read Surface Expectations
The UI should show:
1. source lists for current context (all, document-filtered, or job-filtered)
2. upload_name as the original user-provided filename
3. filename as the stored system filename
4. page_number and ordering context
5. the owning Document and Job navigation context
6. direct action to open Source detail
### 6.3 Read Empty and Missing States
If source is missing:
1. Show clear not found or no source available messaging
If source metadata is partially unavailable:
1. Show fallback labels and keep navigation available where possible
## 7. Update Source Journey
### 7.1 User Intent
The user primarily tracks page-level source records while preserving raw machine output in the service layer.
### 7.2 Intended Editable Fields
Editable in first release:
1. revised_text in Source detail
Read-only in first release:
1. upload_name
2. filename
3. file_path
4. raw_transcription
5. page_number
6. date_uploaded
7. date_revised set by system on revision save
### 7.3 Revision Save Behavior
On save:
1. validate revision text is non-empty after trimming
2. persist revised_text
3. set date_revised
4. show success feedback
5. keep user in current source context
### 7.4 Revision Failure Behavior
If save fails:
1. Show clear error feedback
2. keep user input where possible
3. Allow retry
## 8. Delete Source Journey
### 8.1 User Intent
The user may need to remove incorrect or duplicate source files from a Document.
### 8.2 Guardrails
Delete is allowed when:
1. policy allows removal of related processing history
Delete is blocked when:
1. policy requires preserving dependent job-source execution records until explicit cleanup
### 8.3 Delete UX
When blocked:
1. explain dependency constraints in a future delete flow
2. show cleanup guidance in a future delete flow
When allowed:
1. confirm permanent removal in a future delete flow
2. remove source in a future delete flow
3. return to source list with success state in a future delete flow
## 9. Relationship to Other Workflows
Source workflow integrates with:
1. Document workflow for ownership and page organization
2. Job workflow for processing status and outputs
3. revision workflow for human correction lifecycle
## 10. Relationship to Schema Mapping
The companion schema-mapping document should specify:
1. field visibility per CRUD action
2. current implementation status
3. intended behavior
4. gap-to-target items
## 11. Deferred Items
Deferred to future revisions:
1. bulk page reordering UX
2. multi-file upload progress and resumable upload UX
3. revision history versions beyond a single revised_text field
4. richer per-page status dashboards
5. source delete UI with dependency-aware confirmation
+78
View File
@@ -0,0 +1,78 @@
# UI Entity Traceability Matrix
Purpose: Map acceptance criteria to concrete implementation anchors and current delivery status.
Updated: 2026-08-02
Status legend:
- Implemented: behavior exists in current UI and service flow
- Partial: parts exist, but user-facing behavior or guardrails are incomplete
- Planned: documented intent with no dedicated UI implementation yet
## Document
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---|
| Read detail and metadata | RD-1, RD-2, RD-7 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py | Dedicated Document detail route renders metadata, read-only system timestamps, and invalid/missing-id states. |
| Related sections and navigation | RD-3, RD-4, RD-5, RD-6 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py | Document detail now shows linked people plus document-scoped Sources and Jobs navigation for the current document. |
| Update entry, validation, and author linkage | UP-1, UP-2, UP-3, UP-4, UP-5, UP-6 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py; tests/services/test_document_service.py | Dedicated edit page includes required-field validation messaging, date parsing rules, and author relationship selection with save path routed back to document detail. |
| Delete controls and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py; tests/services/test_document_service.py | Dedicated delete page provides permanent-action confirmation, dependency-category blocking, and guarded backend delete behavior. |
## Person
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---|
| Create flow and validation | CR-1, CR-2, CR-3, CR-4, CR-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py | Dedicated Person create page with required full_name validation, optional field handling, and success routing to detail. |
| Read detail and linked documents | RD-1, RD-2, RD-3, RD-4 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Person detail route renders metadata, full-name summary, portrait preview when available, linked-document section, and invalid/missing-id states. |
| Update behavior | UP-1, UP-2, UP-3, UP-4, UP-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Dedicated Person edit page supports allowed fields, required full_name validation, and save path back to detail. |
| Delete behavior and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Dedicated delete page provides permanent-action confirmation, linked-document blocking message, and guarded backend delete behavior. |
## Source
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---|
| Create entry and required links | CR-1, CR-2, CR-4, CR-5 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; src/transcription/ui/pages/upload_page.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Source upload/create is job-create-context only (legacy upload route redirects), with required Document and JobSource linkage enforced. |
| Ordering and filename policy | CR-3 | Implemented | src/transcription/services/store.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Multi-file/folder uploads are ordered alphabetically by original filename, helper text is visible, and stored filenames use generated unique-id plus extension. |
| Read and navigation visibility | RD-1, RD-2, RD-3 | Implemented | src/transcription/ui/pages/sources_page.py; src/transcription/ui/pages/documents_page.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_sources_page.py; tests/ui/test_documents_page.py; tests/ui/test_jobs_page.py | Dedicated Sources list/detail routes support global, document-filtered, and job-filtered navigation plus source metadata and preview rendering. |
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/ui/pages/sources_page.py; src/transcription/services/transcription.py; tests/ui/test_sources_page.py; tests/services/test_transcription_service.py | Source detail exposes revision edit/save UX with non-empty validation, success feedback, and refreshed state after save. |
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Planned | src/transcription/services/transcription.py; tests/services/test_transcription_service.py | Job-detail source delete UI was removed from the current simplified flow; backend guardrails remain for future reinstatement. |
## Job
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|---|---|---|---|---|
| Create entry and required links | CR-1, CR-2, CR-5, CR-6 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py; tests/services/test_store.py | Jobs list now has explicit Create entry and `/jobs/new` create flow with Document selection, combined file/folder upload widget, and submit routing to job detail. |
| Source ordering and upload behavior | CR-3 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Multi-file and folder upload are supported through one widget, uploads are sorted alphabetically by original filename, and helper guidance is shown in create UI. |
| Provider/model/prompt visibility | CR-4, RD-4 | Implemented | src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py | Provider/model/prompt fields are visible in create and detail flows when known (with pending fallback labels). |
| Jobs list and detail read states | RD-1, RD-2, RD-3, RD-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/table/jobs.py; tests/ui/test_jobs_page.py | Jobs list, detail route, document-scoped navigation, and invalid/missing id states are present. |
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/pages/sources_page.py; src/transcription/services/transcription.py; tests/ui/test_jobs_page.py; tests/ui/test_sources_page.py; tests/services/test_transcription_service.py | Job detail routes users to job-scoped Sources where Source detail provides revision edit/save workflow. |
| Lifecycle visibility and retry indicators | UP-5 | Implemented | src/transcription/services/jobs.py; src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py | Job detail now surfaces lifecycle status plus retry/update metadata while lifecycle fields remain system-managed (no direct user edit controls). |
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/services/jobs.py; tests/ui/test_jobs_page.py; tests/services/test_job_service.py | Job delete page enforces processing-state block, confirms allowed deletes, and routes back to jobs list on success. |
## Quality Gate Coverage
| Quality Gate | Acceptance IDs | Status | Notes |
|---|---|---|---|
| Separation of intent vs implementation | QG-1 across entities | Implemented | user-journey.md, schema-mapping.md, and acceptance-criteria.md are maintained per entity. |
| Traceability from criteria to implementation | QG-2 across entities | Implemented | This matrix provides criterion-to-code anchors and current status tags. |
| First-release constraints | QG-3 across entities | Implemented | Constraints are documented and aligned with current flows: jobs-first source upload, visible provider/model/prompt context, and system-managed lifecycle fields. |
## Supporting Entity Coverage
| Supporting Entity | Documentation | Status | Notes |
|---|---|---|---|
| document-person | docs/ui/entities/document-person/schema-mapping.md | Completed | Supporting-entity schema mapping created; no standalone UI contract file by design. |
| job-source | docs/ui/entities/job-source/schema-mapping.md | Completed | Supporting-entity schema mapping created; no standalone UI contract file by design. |
## Suggested Implementation Order
1. Aggregate final acceptance review across Document, Person, Source, and Job criteria.
## Aggregate Final Review Snapshot (2026-08-02)
| Entity | Acceptance IDs still not fully met | Evidence | Notes |
|---|---|---|---|
| Document | None | src/transcription/ui/pages/documents_page.py; tests/ui/test_documents_page.py | Document criteria are covered by dedicated detail/edit/delete pages and document-scoped related views. |
| Person | None | src/transcription/ui/pages/people_page.py; tests/ui/test_people_page.py | Person criteria are covered by dedicated create/detail/edit/delete pages with relationship-aware delete guardrails. |
| Source | None | src/transcription/services/store.py; src/transcription/ui/pages/sources_page.py; src/transcription/ui/pages/jobs_page.py; tests/services/test_store.py; tests/ui/test_sources_page.py; tests/services/test_transcription_service.py | Source criteria are covered by job-context create behavior, ordering/filename policy, dedicated list/detail read flow, revision flow, and delete guardrails. |
| Job | None | src/transcription/ui/pages/jobs_page.py; src/transcription/services/jobs.py; tests/ui/test_jobs_page.py; tests/services/test_job_service.py | Job criteria are covered by create/read/revision/lifecycle visibility and delete guardrails in dedicated routes. |
@@ -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.
@@ -17,10 +17,10 @@ The deployed system targets personal use and a corpus of several thousand docume
Current scope includes:
- document upload and metadata capture
- content source upload and metadata capture
- asynchronous transcription jobs
- 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
- export of transcript data
@@ -61,7 +61,7 @@ flowchart LR
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.
@@ -131,22 +131,31 @@ Out of scope:
Production transcription flow:
1. A user uploads an image or PDF through the UI or API.
2. The application validates payloads and creates document and job records.
3. The in-process worker dequeues the job and calls the transcription provider.
4. The application persists transcript output, confidence metadata, and provenance events.
1. A user uploads one or more content sources through the UI or API.
2. The application validates payloads and creates document, source, and job records.
3. The in-process worker de-queues the job and calls the transcription provider.
4. The application persists original transcription output on the job, plus confidence metadata and provenance events.
5. Job status transitions from queued to processing to transcribed or failed.
6. The UI and API expose status, 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
System-of-record entities:
- documents and pages
- transcription jobs and status events
- documents and content sources
- transcription jobs, original transcription, and status events
- transcript revisions
- provenance metadata
### Original Transcription And Revision Ownership
- each processing job stores the original immutable provider output (`text`)
- provider metadata (`provider`, `model`, `prompt_name`) and failure detail (`error_detail`) are job-owned processing artifacts
- revisions are optional user-authored edits linked to a content source
- a revision can be created from original `job.text`
- many jobs will have zero revisions; revisions are additive and never overwrite original provider output
- a document groups one or more content sources (images, PDFs, and future source types)
Storage strategy:
- PostgreSQL for relational system-of-record entities
@@ -250,12 +259,14 @@ Control:
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:
- first-class human review and immutable revision history
---
## Technology References
- [FastAPI documentation](https://fastapi.tiangolo.com/)
@@ -264,13 +275,16 @@ Control:
- [PostgreSQL documentation](https://www.postgresql.org/docs/)
- [MongoDB documentation](https://www.mongodb.com/docs/)
## Related Pages
## Related Local References
- [System overview](index.md)
- [Version 1 plan](ver1/ver1.md)
- [Version 1 Step 1 plan](ver1/ver1-step1.md)
- [Version 1 Step 1 results](ver1/ver1-step1-results.md)
- [Architecture decision records index](adr/README.md)
- [System Overview](index_v1.md)
- [System Design Intent](intent.md)
- [Transcription Methodology](transcription_methodology.md)
- System Architecture (this document)
- [System Requirements](requirements_v1.md)
- [Data model](schema_v1.md)
- [Error Handling Policy](error_handling_v1.md)
- [Implementation Plan](implementation_plan_v1.md)
## Glossary
@@ -289,6 +303,6 @@ Control:
- Port/Interface: A stable contract used by application/domain code to call infrastructure implementations.
- Prompt artifact: A single Markdown file that defines one transcription prompt and can be revised independently.
- Provenance: Metadata that records where generated data came from and how it was produced.
- Revision history: 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.
- 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.
@@ -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 |
| `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 |
| `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 |
@@ -111,7 +111,7 @@ All logged errors must include, where available:
- `category`
- `operation` (e.g., `upload.submit`, `worker.process_job`, `jobs.refresh`)
- `exception_type`
- `job_id`, `document_id` (when relevant)
- `job_id`, `document_id`, `source_id` (when relevant)
- UTC timestamp
Rules:
@@ -266,12 +266,18 @@ Change requirements:
- preserve taxonomy stability; if changed, document migration impact
- record noteworthy policy changes in project release notes or changelog
## Related Pages
---
- [System overview](index.md)
- [Architecture](architecture.md)
- [Requirements](requirements.md)
- [Intent](intent.md)
## Related Local References
- [System Overview](index_v1.md)
- [System Design Intent](intent.md)
- [Transcription Methodology](transcription_methodology.md)
- [System Architecture](architecture_v1.md)
- [System Requirements](requirements_v1.md)
- [Data model](schema_v1.md)
- Error Handling Policy (this document)
- [Implementation Plan](implementation_plan_v1.md)
## Glossary
+203
View File
@@ -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)
+17 -16
View File
@@ -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.
## Start Here
Read [architecture.md](architecture.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.
Read [architecture_v1.md](architecture_v1.md) first.
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
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
- 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
- revision history for transcript edits
- optional revisions for user-authored edits of original immutable transcription text
- full-text search over accepted transcripts
- 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.
---
## Documentation Map
- Architecture and technical design: [architecture.md](architecture.md)
- Version 1 implementation plan: [ver1/ver1.md](ver1/ver1.md)
- Version 1 Step 1 plan: [ver1/ver1-step1.md](ver1/ver1-step1.md)
- Version 1 Step 1 results: [ver1/ver1-step1-results.md](ver1/ver1-step1-results.md)
- Architecture decision records (ADR index): [adr/README.md](adr/README.md)
- Runtime and deployment requirements: [requirements.md](requirements.md)
- Error handling policy and operational guidance: [error_handling.md](error_handling.md)
- Domain context and transcription policy: [intent.md](intent.md)
- System Overview (this document)
- [System Design Intent](intent.md)
- [Transcription Methodology](transcription_methodology.md)
- [System Architecture](architecture_v1.md)
- [System Requirements](requirements_v1.md)
- [Data model](schema_v1.md)
- [Error Handling Policy](error_handling_v1.md)
- [Implementation Plan](implementation_plan_v1.md)
## Glossary
+45
View File
@@ -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.
+39
View File
@@ -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
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
@@ -15,9 +15,9 @@ This page captures a SysML v1.6-style requirements baseline for the production s
| ID | Category | Requirement | Risk | Verify Method |
| --- | --- | --- | --- | --- |
| REQ-0 | System | Provide end-to-end document transcription with persistent, inspectable lifecycle state. | medium | demonstration |
| REQ-1 | Functional | Allow users to upload one or more document images 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-3 | Functional | Persist and expose job states: upload, queued, processing, transcribed, failed, completed. | high | inspection |
| REQ-1 | Functional | Allow users to upload one or more images or PDFs as sources from the web UI. | low | test |
| REQ-2 | Functional | Run each upload through asynchronous processing that returns an original transcription or explicit failure. | high | test |
| REQ-3 | Functional | Persist and expose job states: queued, processing, transcribed, failed. | high | inspection |
| REQ-4 | Functional | Persist transcription output, processing history, and failure details. | medium | test |
| REQ-5 | Interface | Expose API and UI views for status inspection and completed transcription reading. | medium | demonstration |
| REQ-6 | Performance | Trigger background processing on upload to preserve UI responsiveness. | medium | analysis |
@@ -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-11 | Design Constraint | Use service-backed persistence for core document and job data. | medium | inspection |
| REQ-12 | Design Constraint | Store transcription prompts as individual Markdown artifacts for iterative refinement. | medium | inspection |
| REQ-13 | Functional | Allow users to create one optional revision of transcription text derived from the original job transcription. | low | test |
### Requirement Relationships
- Contains: REQ-0 contains REQ-1 through REQ-12.
- Contains: REQ-0 contains REQ-1 through REQ-13.
- Derives: REQ-2 -> REQ-3, REQ-3 -> REQ-4.
- Traces: REQ-5 -> REQ-3.
- Refines: REQ-6 -> REQ-2.
@@ -50,17 +51,17 @@ This page captures a SysML v1.6-style requirements baseline for the production s
### Satisfaction Mapping
- UI satisfies REQ-1, REQ-5.
- UI satisfies REQ-1, REQ-5, REQ-13.
- API satisfies REQ-5.
- 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.
- OPS satisfies REQ-9.
- PROMPTS satisfies REQ-12.
### Verification Mapping
- TESTS verifies REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-10, REQ-11, REQ-12.
- TESTS verifies REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-10, REQ-11, REQ-12, REQ-13.
## 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.
- Test: automate behavioral checks through pytest suites and service-level tests.
---
## Related Local References
- [System Overview](index_v1.md)
- [System Design Intent](intent.md)
- [Transcription Methodology](transcription_methodology.md)
- [System Architecture](architecture_v1.md)
- System Requirements (this document)
- [Data model](schema_v1.md)
- [Error Handling Policy](error_handling_v1.md)
- [Implementation Plan](implementation_plan_v1.md)
## Glossary
- Document-oriented persistence: A storage approach that uses flexible document structures for variable data shapes.
+129
View File
@@ -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.
+98
View File
@@ -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.
+40
View File
@@ -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.
-86
View File
@@ -1,86 +0,0 @@
# Ver1 Step 1 Results: Architecture Consolidation
## Summary
Step 1 implementation has been completed for the primary architecture-consolidation objectives:
1. Lifespan-owned runtime resource model introduced for DB runtime ownership.
2. Schema bootstrap policy changed from implicit-always to explicit/environment-aware.
3. Worker startup now receives lifespan-owned DB engine dependency.
4. ADR set established for key V1 architectural decisions.
## Implemented Changes
### 1) Runtime ownership
- Updated `src/transcription/db.py`:
- Added `DatabaseRuntime` resource model.
- Added explicit runtime lifecycle methods:
- `initialize_database_runtime(...)`
- `get_database_runtime()`
- `dispose_database_runtime()`
- Updated `src/transcription/app.py`:
- Lifespan initializes DB runtime and stores it on `app.state`.
- Lifespan disposes DB runtime on shutdown.
### 2) Schema bootstrap policy (REQ-10 alignment)
- Updated `src/transcription/config.py`:
- Added `environment` setting (`development`, `test`, `production`).
- Added `bootstrap_schema_on_startup` explicit override setting.
- Updated `src/transcription/db.py`:
- Added `should_bootstrap_schema(settings)` policy function.
- Updated `src/transcription/app.py`:
- Startup now calls `create_all(...)` only when policy allows.
### 3) Worker dependency ownership
- Updated `src/transcription/worker.py`:
- `process_next_queued_job(..., engine=None)` now supports explicit engine injection.
- `run_worker_loop(..., engine=None, ...)` now supports explicit engine injection.
- Updated `src/transcription/app.py`:
- Worker thread is started with lifespan-owned engine.
### 4) ADR governance
Created:
- `docs/adr/README.md`
- `docs/adr/ADR-0001-lifespan-owned-runtime-resources.md`
- `docs/adr/ADR-0002-explicit-schema-bootstrap-policy.md`
- `docs/adr/ADR-0003-persistence-baseline-and-transition-path.md`
- `docs/adr/ADR-0004-in-process-worker-topology.md`
## Test Evidence
Targeted regression checks executed successfully:
- `uv run pytest tests/test_app.py tests/test_db.py tests/services/test_worker.py -q`
- Result: pass
## Residual Risks / Follow-ups
1. Full REQ-7 completion may still require broader runtime ownership coverage for additional resources as V1 expands.
2. Production schema management workflow (migrations/runbook tooling) should be finalized in subsequent V1 steps.
3. Additional boundary enforcement automation (import-lint style checks) can be added in later hardening.
## Step 1 Exit Assessment
- Architecture ownership clarity: **met**
- Schema bootstrap policy hardening: **met**
- Worker lifecycle dependency clarity: **met**
- ADR baseline established: **met**
## Completion Checklist With Evidence
| Criterion | Status | Evidence |
| --- | --- | --- |
| Architecture conformance matrix approved | partial | Consolidation implemented and documented in `docs/ver1/ver1-step1.md` + this results doc; formal matrix artifact can be added as a follow-up appendix. |
| REQ-7 ownership gaps resolved or explicitly deferred | met | Lifespan-owned DB runtime and explicit worker engine wiring implemented in `src/transcription/app.py`, `src/transcription/db.py`, `src/transcription/worker.py`. Residual scope documented under follow-ups. |
| REQ-10 explicit bootstrap policy implemented and verified | met | Policy implemented via `environment` + `bootstrap_schema_on_startup` in `src/transcription/config.py`, `should_bootstrap_schema(...)` in `src/transcription/db.py`, startup gate in `src/transcription/app.py`, tested in `tests/test_db.py`. |
| Dependency direction rules documented and enforced | partial | Layering and runtime ownership documented in `docs/architecture.md`. Lightweight enforcement exists via review and test discipline; automated import-lint remains a follow-up. |
| ADR set created for major Step 1 decisions | met | `docs/adr/README.md` and ADR-0001 through ADR-0004 created. |
| Architecture/index docs updated to match implementation | met | `docs/architecture.md` and `docs/index.md` updated with V1 Step 1 runtime policy and links to V1/ADR artifacts. |
| Regression and full test suites pass | met | Targeted: `uv run pytest tests/test_app.py tests/test_db.py tests/services/test_worker.py -q`; full suite: `uv run pytest -q`. |
| Step 1 results artifact published | met | This document (`docs/ver1/ver1-step1-results.md`) created and updated with summary, evidence, risks, and checklist. |
Step 1 is complete and ready to hand off to Ver1 Step 2.
-309
View File
@@ -1,309 +0,0 @@
# Step 1 Implementation Plan: Architecture Consolidation
## Purpose
Align the implemented MVP codebase with the production architecture and V1 constraints documented in:
- `docs/architecture.md`
- `docs/requirements.md`
- `docs/error_handling.md`
- `docs/index.md`
- `docs/intent.md`
- `docs/ver1/ver1.md` (Step 1)
This step hardens architecture boundaries and ownership without expanding product scope.
---
## MCP Skill and Guide Inputs Incorporated
This plan explicitly incorporates patterns and guardrails from john-stream-mcp resources:
1. `resource://skills/fastapi-uv-docker/document`
- App factory and lifespan ownership
- Health endpoint and cloud-native baseline expectations
- Environment-driven configuration and startup discipline
2. `resource://skills/fastapi-async-sqlalchemy-modernization/document`
- Current-state gap audit first
- Target runtime model before refactor
- Explicit resource lifecycle ownership
- Transaction/session boundary clarity
- Phased migration with rollback points
3. `resource://skills/nicegui/document`
- Clear dependency direction
- UI/page registration as composition, not business logic container
- Async responsiveness and boundary separation
4. `resource://prompts/greenfield-architecture/document`
- Pattern-comparison-first planning
- Explicit tradeoffs and staged implementation
- Output contract with risks, open questions, and next steps
---
## Current-State Gap Summary (Architecture vs Implementation)
Based on docs and current `src/transcription` code:
1. **REQ-7 gap (lifespan-owned resources)**
- DB engine/session factory are module globals in `db.py`, not app lifespan-owned.
- Worker thread lifecycle is owned by lifespan (good), but DB/provider resource ownership is mixed.
2. **REQ-10 gap (explicit opt-in schema bootstrap)**
- `create_all()` is executed unconditionally on startup in `app.py`.
3. **Data store target gap (REQ-9 + architecture baseline)**
- Runtime still defaults to SQLite MVP setup; production architecture targets PostgreSQL baseline with optional MongoDB.
4. **Layering clarity gap (architecture layer model)**
- Boundaries exist but are not yet formally enforced (interface/app/domain/infra dependency rules are implicit, not codified).
5. **Decision record gap**
- No ADR set documenting key V1 architectural decisions and deviations from MVP.
---
## Scope for Step 1
### In scope
1. Produce architecture conformance audit and decision records.
2. Define and implement target runtime ownership model for core resources.
3. Establish explicit schema bootstrap policy (opt-in in production paths).
4. Consolidate module boundaries and dependency direction rules.
5. Update architecture docs to reflect implemented reality and V1 trajectory.
### Out of scope
- Full async SQLAlchemy rewrite (plan and seams only if deferred)
- MongoDB feature implementation
- New user-facing features
- Major worker architecture replacement (in-process worker remains baseline)
---
## Target Architecture Decisions for V1
1. **Keep modular monolith topology** (FastAPI + NiceGUI + in-process worker).
2. **Preserve container-light simplicity guardrails** from `architecture.md`.
3. **Move runtime ownership to lifespan** for:
- DB engine/session factory lifecycle
- Worker runtime resources
- Provider client factory/config lifecycle
4. **Adopt explicit schema bootstrap policy**:
- Dev/test: opt-in auto-bootstrap allowed
- Production: startup must not mutate schema implicitly
5. **Formalize boundary map**:
- Interface (`api`, `ui`) -> Application (`services`) -> Domain (`models/rules`) -> Infrastructure (`db`, `providers`)
- No reverse imports
---
## Detailed Work Breakdown
## Phase A — Architecture Audit and Baseline Freeze
- [ ] **A1. Produce architecture conformance matrix**
- Map each architecture section to current modules/files.
- Classify each row: `aligned`, `partial`, `not aligned`.
- [ ] **A2. Produce REQ-7/REQ-9/REQ-10 focused gap report**
- Explicitly capture current vs required state.
- Include operational risk if left unresolved.
- [ ] **A3. Freeze MVP architecture baseline**
- Record current baseline behavior and known temporary shortcuts.
- Link this baseline from `docs/ver1/ver1.md`.
### Deliverables
- `docs/ver1/ver1-step1-audit.md` (or equivalent section in this doc)
- Architecture conformance table
### Exit Criteria
- No architecture changes begin before gap matrix and baseline are approved.
---
## Phase B — Resource Ownership Consolidation (Lifespan-Centric)
- [ ] **B1. Define runtime resource ownership contract**
- `app.py` lifespan owns resource initialization and cleanup order.
- `app.state` carries resource handles/factories.
- No hidden module-global side-effect initialization for runtime resources.
- [ ] **B2. Refactor DB ownership model**
- Replace module-global engine singleton pattern with lifespan-initialized resource model.
- Define one canonical session-factory access path for app/worker/services.
- [ ] **B3. Normalize worker dependencies**
- Ensure worker uses lifespan-owned resources/factories rather than implicit globals.
- Preserve deterministic startup/shutdown behavior.
- [ ] **B4. Define provider adapter ownership**
- Provider client creation strategy is centralized and lifecycle-aware.
- Avoid per-call hidden client construction when unnecessary.
### MCP-Guided Guardrails
- Use explicit lifecycle composition patterns from `fastapi-async-sqlalchemy-modernization`.
- Maintain app-factory + lifespan structure per `fastapi-uv-docker`.
- Keep UI registration as composition only per `nicegui`.
### Exit Criteria
- Core runtime resources have one owner and one cleanup path.
- No critical resource has ambiguous ownership.
---
## Phase C — Schema Bootstrap Policy (REQ-10 Alignment)
- [ ] **C1. Define environment-aware bootstrap policy**
- `auto_create_schema` (or equivalent) disabled in production by default.
- Startup schema mutation is explicit and intentional.
- [ ] **C2. Split startup responsibilities**
- App startup performs health-critical initialization only.
- Schema bootstrap path is moved to explicit command/flag workflow.
- [ ] **C3. Update deployment/runbook docs**
- Document migration/bootstrap flow for dev, staging, prod.
- Ensure policy is testable and auditable.
### Exit Criteria
- Normal production startup path does not call schema auto-create implicitly.
- Bootstrap behavior is explicit and documented.
---
## Phase D — Module Boundary Enforcement
- [ ] **D1. Publish dependency direction rules**
- Allowed import directions across `api`, `ui`, `services`, `models/domain`, `db/providers`.
- Explicitly disallow reverse dependencies.
- [ ] **D2. Reconcile package map with docs**
- Ensure docs architecture elements match real package layout and naming.
- Update docs where intentional deviations remain.
- [ ] **D3. Isolate cross-layer responsibilities**
- Keep API/UI presentation concerns out of services.
- Keep provider/DB specifics out of interface layer.
- [ ] **D4. Add lightweight architecture checks**
- Add static/import checks and/or review checklist in CI/review process.
### Exit Criteria
- Boundary rules are documented and applied.
- Architectural drift can be detected during review/CI.
---
## Phase E — Architecture Decision Records (ADRs)
- [ ] **E1. Create ADR index**
- Add `docs/adr/README.md` with template and status model.
- [ ] **E2. Record minimum V1 ADR set**
1. Runtime ownership model (lifespan-owned resources)
2. Schema bootstrap policy (explicit vs implicit)
3. Persistence baseline (PostgreSQL target; SQLite transition strategy)
4. Worker topology (in-process for V1, extension path preserved)
- [ ] **E3. Cross-link ADRs**
- Link from architecture and V1 docs.
### Exit Criteria
- Major architecture decisions are explicit, versioned, and discoverable.
---
## Phase F — Documentation Consolidation
- [ ] **F1. Update `docs/architecture.md`**
- Reflect real implementation and V1 target state separately.
- Mark transitional choices clearly.
- [ ] **F2. Update `docs/index.md` navigation consistency**
- Ensure architecture/readme references match actual docs/files.
- [ ] **F3. Update `docs/requirements.md` traceability notes**
- Mark REQ-7/REQ-10 status and verification approach after consolidation.
- [ ] **F4. Add Step 1 result summary**
- Create `docs/ver1/ver1-step1-results.md` after implementation.
### Exit Criteria
- Docs are internally consistent and match runtime architecture reality.
---
## Verification Plan
## Architecture Verification Matrix (Step 1)
1. **Inspection**
- Resource ownership map exists and matches code.
- Schema bootstrap policy is explicit and environment-aware.
- ADRs exist for each key architecture decision.
2. **Automated checks**
- Existing test suite remains green.
- New/updated tests validate startup policy (no implicit schema mutation in production mode).
- Import/dependency-direction checks pass (if introduced).
3. **Demonstration**
- App starts in dev mode with explicit expected behavior.
- App starts in production mode without mutating schema implicitly.
- Worker lifecycle starts/stops cleanly with app lifespan.
---
## Risks and Mitigations
1. **Risk:** Refactor destabilizes MVP behavior
**Mitigation:** Phase changes with small PRs and regression checks after each phase.
2. **Risk:** Over-rotation into premature async rewrite
**Mitigation:** Keep this step focused on lifecycle ownership and boundaries; defer full async migration unless required.
3. **Risk:** Schema policy changes break local DX
**Mitigation:** Keep explicit dev bootstrap path simple and documented.
4. **Risk:** Boundary rules become “doc only”
**Mitigation:** Add CI/review enforcement and architecture checklist.
---
## Recommended Implementation Order
1. Phase A — Audit and baseline freeze
2. Phase B — Resource ownership consolidation
3. Phase C — Schema bootstrap policy
4. Phase D — Boundary enforcement
5. Phase E — ADR authoring
6. Phase F — Documentation consolidation
This order minimizes risk: diagnose first, then refactor ownership, then lock policy, then enforce boundaries, and finally finalize docs.
---
## Step 1 Completion Checklist
- [ ] Architecture conformance matrix approved.
- [ ] REQ-7 ownership gaps resolved or explicitly deferred with owner/date.
- [ ] REQ-10 explicit bootstrap policy implemented and verified.
- [ ] Dependency direction rules documented and enforced.
- [ ] ADR set created for all major Step 1 decisions.
- [ ] Architecture and index docs updated to match implementation.
- [ ] Full test suite passes after consolidation.
- [ ] `docs/ver1/ver1-step1-results.md` created with evidence and residual risks.
---
## Handoff to Step 2
Once Step 1 completes, Step 2 (Error Handling & Reliability Hardening) can proceed on stable architecture seams:
- consistent lifecycle ownership,
- explicit startup policy,
- clear module boundaries,
- documented architecture decisions.
-80
View File
@@ -1,80 +0,0 @@
# Ver1 Step 2 Results: Error Handling & Reliability Hardening
## Summary
Step 2 implementation is complete for the planned reliability and error-handling hardening scope:
1. Worker retries are now explicit, bounded, and category-driven.
2. Error behavior is more consistent across worker/API/UI boundaries.
3. Logging now includes stronger boundary context in key failure paths.
4. Test coverage was expanded for retry policy and new reliability settings.
## Implemented Changes
### 1) Worker retry policy and terminal behavior
- Updated `src/transcription/models.py`:
- Added `Job.retry_count` with default `0`.
- Updated `src/transcription/config.py`:
- Added `worker_max_retries`.
- Added `worker_retry_backoff_seconds`.
- Updated `src/transcription/worker.py`:
- Added bounded retry decision path (`_should_retry`).
- Added requeue behavior (`_requeue_for_retry`) for retriable errors.
- Added deterministic terminal failure behavior (`_finalize_failed_job`).
- Preserved transcript failure detail persistence (`error_id`, `category`, suggestion).
### 2) API fallback normalization hardening
- Updated `src/transcription/api/errors.py`:
- Fallback handler now emits safe generic internal message for unhandled exceptions.
- Added structured boundary logging fields including operation and exception type.
### 3) UI interaction reliability guard
- Updated `src/transcription/ui/upload_page.py`:
- Added duplicate in-flight submission guard to prevent repeated upload handling while busy.
### 4) Observability/logging improvements
- Updated worker logs in `src/transcription/worker.py` to include operation and domain identifiers in key transitions:
- pick
- retry
- transcribed
- failed
## Test Coverage Added/Updated
- Updated `tests/test_models.py`:
- Assert `retry_count` default.
- Updated `tests/test_config.py`:
- Added worker retry settings default test.
- Updated `tests/services/test_worker.py`:
- Added retriable requeue test.
- Added retry-exhaustion terminal failure test.
- Updated existing tests for settings-driven worker behavior.
- Existing API error tests remained green with fallback behavior updates:
- `tests/api/test_error_responses.py`
## Verification Evidence
Executed and passing:
- `uv run pytest tests/services/test_worker.py tests/test_models.py tests/test_config.py tests/api/test_error_responses.py -q`
- `uv run pytest -q`
## Residual Risks / Follow-ups
1. Retry policy currently uses simple fixed backoff; richer strategy (exponential/jitter) can be added in later hardening.
2. Full cross-layer structured logging standardization can be expanded in Step 6 observability work.
3. A formal Step 2 error-path inventory artifact (`ver1-step2-audit.md`) is still recommended for governance completeness.
## Step 2 Exit Assessment
- Error taxonomy and envelope stability: **met**
- Bounded retry and terminal failure behavior: **met**
- Worker reliability controls: **met**
- UI interaction hardening for duplicate actions: **met**
- Test coverage expansion and full-suite regression safety: **met**
Step 2 is complete and ready to hand off to Ver1 Step 3.
-302
View File
@@ -1,302 +0,0 @@
# Step 2 Implementation Plan: Error Handling & Reliability Hardening
## Purpose
Implement **Ver1 Step 2** from `docs/ver1/ver1.md` by standardizing failure behavior and reliability controls so the system fails safely, predictably, and transparently across UI, API, services, worker, and provider boundaries.
Primary governing docs:
- `docs/error_handling.md` (authoritative contract)
- `docs/requirements.md` (REQ-2, REQ-3, REQ-4, REQ-5, REQ-6)
- `docs/architecture.md` (boundary ownership and worker lifecycle)
- `docs/ver1/ver1.md` (Step 2 objective)
---
## MCP Skill and Guide Inputs Incorporated
This plan integrates guidance from john-stream-mcp resources:
1. `resource://skills/python-logging-dictconfig/document`
- centralized `dictConfig` logging
- startup-only configuration
- stable named loggers and boundary-level logging discipline
2. `resource://skills/pytesting/document`
- deterministic, behavior-first tests
- explicit marker usage and fast/slow lane discipline
- integration checks for boundary behavior and error contracts
3. `resource://skills/fastapi-async-sqlalchemy-modernization/document`
- classify at source boundary
- explicit transaction/session behavior under failure
- phased rollout with quality gates and rollback awareness
4. `resource://skills/nicegui-ui-customization/document`
- explicit user-facing error feedback for each interaction
- prevent duplicate actions during in-flight operations
- preserve one-way dependency boundaries from UI -> services
5. `resource://skills/fastapi-uv-docker/document` (applied selectively)
- lifespan-safe startup/shutdown behavior
- health/readiness posture and cloud-native operational checks
---
## Current-State Gap Summary
The project already has a strong baseline (`AppError`, taxonomy enum, API envelope, worker persistence), but Step 2 needs completion-level hardening:
1. **Error contract consistency**
- API envelope exists, but consistency must be verified for all error pathways.
2. **Cross-boundary category normalization**
- Provider/service/worker mappings exist, but require stricter policy checks and tests.
3. **Retry policy implementation depth**
- Step 2 requires bounded retry policy and clear terminal behavior for retriable failures.
4. **Operational traceability**
- Logging exists; Step 2 requires consistent structured fields at critical boundaries.
5. **UI failure UX consistency**
- UI error handling exists; Step 2 requires explicit contract coverage and anti-duplication safeguards.
---
## Scope for Step 2
### In scope
1. Enforce canonical error taxonomy and envelope across all boundaries.
2. Standardize logging fields and boundary-level error traceability.
3. Implement/complete bounded retry and terminal failure behavior in worker paths.
4. Improve UI/API error presentation consistency and actionable guidance.
5. Add comprehensive Step 2 test coverage and verification matrix.
6. Update documentation to reflect final Step 2 policies and behavior.
### Out of scope
- Major architecture/topology changes (external queue, distributed worker)
- New end-user feature expansion outside reliability/error handling
- Full async ORM migration (unless required by bug fix)
---
## Target Decisions for Step 2
1. **Taxonomy stability is mandatory**
- `ErrorCategory` values remain stable contract identifiers.
2. **Classification occurs at source boundary**
- adapters/services normalize early; UI/API only present safely.
3. **User safety over internal detail leakage**
- expose safe message + suggestion + error_id; keep sensitive detail in logs.
4. **Retry is explicit and bounded**
- only retriable categories may retry; retries are capped; terminal failures persist reason.
5. **Boundary logs carry correlation fields**
- include `error_id`, `category`, `operation`, and domain identifiers where available.
---
## Detailed Work Breakdown
## Phase A — Error Contract Audit and Policy Lock
- [ ] **A1. Build error-path inventory**
- Enumerate all failure entry points across:
- `api/`
- `ui/`
- `services/`
- `worker.py`
- `providers/`
- [ ] **A2. Produce taxonomy mapping table**
- For each known exception path, map:
- source exception type
- target `ErrorCategory`
- retriable flag
- API status (if exposed)
- [ ] **A3. Reconcile with `docs/error_handling.md`**
- Resolve any mismatch in category semantics, status codes, or suggested actions.
### Deliverables
- `docs/ver1/ver1-step2-audit.md` (recommended)
- taxonomy mapping table
### Exit Criteria
- Every known failure path has explicit category + retriable policy.
---
## Phase B — API and Service Contract Hardening
- [ ] **B1. Enforce API envelope completeness**
- Ensure all API errors return:
- `error_id`, `category`, `message`, `suggestion`, `timestamp`
- [ ] **B2. Verify category-to-status mapping consistency**
- Confirm `api/errors.py` matches `docs/error_handling.md` mapping guidance.
- [ ] **B3. Normalize service exceptions at boundary**
- Services should raise `AppError` subclasses for known failures.
- Unknown exceptions must become `internal_unexpected_error` with traceable `error_id`.
- [ ] **B4. Ensure safe detail handling**
- API/UI messages remain safe.
- Diagnostic context remains in logs/persisted failure detail where appropriate.
### Exit Criteria
- No unstructured/unclassified exception escapes core boundaries.
- API responses are contract-stable for all tested failure modes.
---
## Phase C — Worker Retry and Terminal Failure Policy
- [ ] **C1. Define bounded retry policy**
- Add configurable retry settings (attempt limit/backoff policy).
- Limit retries to retriable categories.
- [ ] **C2. Implement terminal failure persistence**
- On retry exhaustion, persist clear terminal reason and `error_id`.
- Ensure job status transitions end deterministically at `failed`.
- [ ] **C3. Add duplicate-processing safety checks**
- Prevent duplicate terminal updates when job already resolved.
- [ ] **C4. Validate worker lifecycle under repeated transient failures**
- Ensure loop remains stable and responsive.
### Exit Criteria
- Retries are bounded and policy-driven.
- Exhausted retries produce deterministic failed state with evidence.
---
## Phase D — Logging and Observability Contract Enforcement
- [ ] **D1. Central logging conformance check**
- Confirm startup-only `dictConfig` use remains canonical.
- No module-level `basicConfig` use.
- [ ] **D2. Standardize error log fields**
- Require at minimum when available:
- `error_id`, `category`, `operation`, `exception_type`, `job_id`, `document_id`
- [ ] **D3. Boundary handoff logging**
- Add/normalize logs at transitions:
- UI action -> service
- service -> provider/db
- worker pickup -> terminal state
- [ ] **D4. Log noise control**
- Avoid duplicate stack-trace logging across layers for same exception.
### Exit Criteria
- Critical failure events are traceable end-to-end via logs and `error_id`.
---
## Phase E — UI Error UX Consistency and Interaction Hardening
- [ ] **E1. Standardize user error presentation**
- For upload/jobs interactions, ensure:
- clear title
- plain-language message
- suggested action
- visible error reference id
- [ ] **E2. Add in-flight interaction guards**
- Prevent duplicate submits/click storms during pending operations.
- [ ] **E3. Ensure deterministic UI state recovery**
- controls re-enable after failure
- status text remains actionable
- [ ] **E4. Keep UI boundary clean**
- no provider/protocol details leaked into page modules
### Exit Criteria
- All primary UI actions have consistent success/failure interaction behavior.
---
## Phase F — Test Expansion and Verification
Apply pytesting guidance: behavior-first assertions, deterministic fixtures, strict markers.
- [ ] **F1. API error contract tests**
- verify envelope fields and status mapping for each category class.
- [ ] **F2. Service classification tests**
- verify known failures map to expected `AppError` subclasses/categories.
- [ ] **F3. Worker retry policy tests**
- retriable failure retries and eventual success
- retriable failure exhaustion -> terminal failed
- non-retriable failure -> immediate failed
- [ ] **F4. UI error behavior tests**
- upload/jobs actions show actionable feedback on failures
- duplicate action guard behavior
- [ ] **F5. Regression guard tests**
- at least one test per previously observed production/real-world failure mode
### Validation Commands
- `uv run pytest --collect-only -q`
- `uv run pytest -m unit -q`
- `uv run pytest -m "not external" -q`
- `uv run pytest -q`
### Exit Criteria
- All Step 2 reliability/error contract tests pass.
- Existing suite remains green.
---
## Recommended Implementation Order
1. Phase A — audit and policy lock
2. Phase B — API/service contract hardening
3. Phase C — worker retry and terminal policy
4. Phase D — logging/traceability normalization
5. Phase E — UI consistency hardening
6. Phase F — test expansion and full verification
This order reduces risk by locking policy first, then applying behavior changes at core boundaries before UI polish.
---
## Risks and Mitigations
1. **Risk:** Overly broad retry policy causes hidden failure loops
**Mitigation:** strict category-based retry eligibility + hard cap + terminal persistence.
2. **Risk:** User-facing messages become too technical
**Mitigation:** enforce safe message + suggestion contract in tests.
3. **Risk:** Logging becomes noisy/redundant
**Mitigation:** boundary logging rules and single-trace ownership.
4. **Risk:** Reliability work introduces regressions in happy path
**Mitigation:** run full suite continuously; preserve integration pipeline tests.
---
## Step 2 Completion Checklist
- [ ] Error taxonomy mapping table completed and approved.
- [ ] API envelope and HTTP status behavior verified for all relevant failure categories.
- [ ] Service/provider exception normalization is consistent and tested.
- [ ] Worker retry behavior is bounded, explicit, and terminal-state safe.
- [ ] Structured error logging fields are present at boundary handoffs.
- [ ] UI failure flows provide clear, actionable, and traceable feedback.
- [ ] Full test suite passes with new Step 2 coverage included.
- [ ] `docs/ver1/ver1-step2-results.md` created with evidence and residual risks.
---
## Handoff to Step 3
After Step 2 completion, Step 3 (Functional Completion by Requirement Domain) proceeds on a hardened foundation:
- stable failure contracts,
- predictable retries and terminal behavior,
- actionable user/API error semantics,
- improved diagnostic traceability.
-320
View File
@@ -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 (3060 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.
+35
View File
@@ -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`.
+416
View File
@@ -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`.
+16 -1
View File
@@ -12,22 +12,37 @@ description = "Historical document transcription system"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"aiosqlite>=0.21.0",
"asyncpg>=0.31.0",
"fastapi>=0.138.0",
"nicegui==3.13.0",
"openrouter>=0.7.0",
"psycopg2-binary>=2.9.12",
"pydantic>=2.13.4",
"pydantic-settings>=2.9.1",
"sqlmodel>=0.0.25",
]
[project.optional-dependencies]
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.25",
"httpx2>=2.5.0",
"ipykernel>=7.3.0",
"ipywidgets>=8.1.8",
"pre-commit>=4.6.0",
"rich>=15.0.0",
"ruff>=0.15.20",
"ty>=0.0.54",
]
[tool.pytest.ini_options]
addopts = "--strict-markers -q"
asyncio_mode = "strict"
filterwarnings = [
"error:coroutine .* was never awaited:RuntimeWarning",
]
markers = [
"unit: pure logic tests with no external dependencies",
"integration: tests that touch framework or database contracts",
+62
View File
@@ -0,0 +1,62 @@
line-length = 120
indent-width = 4
target-version = "py313"
exclude = [
".venv",
".devenv",
".git",
".vscode",
"build",
"site",
"__pycache__",
]
[lint]
preview = true
extend-select = [
"ARG", # https://docs.astral.sh/ruff/rules/#flake8-unused-arguments-arg
"B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
"C4", # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4
"DOC102", # https://docs.astral.sh/ruff/rules/docstring-extraneous-parameter/
"DOC202", # https://docs.astral.sh/ruff/rules/docstring-extraneous-returns/
"DOC403", # https://docs.astral.sh/ruff/rules/docstring-extraneous-yields/
"DOC502", # https://docs.astral.sh/ruff/rules/docstring-extraneous-exception/
"E", "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
"F", # https://docs.astral.sh/ruff/rules/#pyflakes-f
"FURB", # https://docs.astral.sh/ruff/rules/#refurb-furb
"I", # https://docs.astral.sh/ruff/rules/#isort-i
"N", # https://docs.astral.sh/ruff/rules/#pep8-naming-n
"PD", # https://docs.astral.sh/ruff/rules/#pandas-vet-pd
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
"PLR0202", # https://docs.astral.sh/ruff/rules/no-classmethod-decorator/
"PLR0203", # https://docs.astral.sh/ruff/rules/no-staticmethod-decorator/
"PLR0206", # https://docs.astral.sh/ruff/rules/property-with-parameters/
"PLR0915", # https://docs.astral.sh/ruff/rules/too-many-statements/
"PLR1702", # https://docs.astral.sh/ruff/rules/too-many-nested-blocks/
"TRY002",
]
extend-fixable = ["ALL"]
ignore = [
"UP046",
"UP047",
]
[lint.extend-per-file-ignores]
"*.ipynb" = [
"F401", # unused imports
"F841", # unused local variable
"F821", # undefined name in exploratory notebook cells
]
[lint.isort]
force-single-line = true
[format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
+27
View File
@@ -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()
uvicorn.run(
"transcription.__main__:create_cli_app",
factory=True,
host=settings.host,
port=settings.port,
log_level=settings.log_level,
reload=settings.reload,
)
if __name__ == "__main__":
main()
+5 -5
View File
@@ -5,12 +5,12 @@ from fastapi import APIRouter
router = APIRouter()
def healthz() -> dict[str, str]:
"""Return a simple health status payload."""
return {"status": "ok"}
@router.get("/healthz")
def healthz_route() -> dict[str, str]:
"""Route wrapper for health status payload."""
return healthz()
def healthz() -> dict[str, str]:
"""Return a simple health status payload."""
return {"status": "ok"}
+68 -47
View File
@@ -2,80 +2,101 @@
from __future__ import annotations
import logging
from contextlib import AsyncExitStack
from contextlib import asynccontextmanager
from threading import Event
from threading import Thread
from datetime import UTC
from datetime import datetime
from datetime import timedelta
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from fastapi import status
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from .api.errors import register_error_handlers
from .api.health import router as health_router
from .config import Settings
from .config import configure_logging
from .config import get_settings
from .db import cleanup_database
from .db import create_all
from .db import dispose_database_runtime
from .db import initialize_database_runtime
from .ui import register_pages
from .worker import run_worker_loop
from .services import ServiceBundle
from .services.jobs import JobService
from .ui.pages import register_pages
from .worker import worker_consumer_lifespan
def _start_worker(app: FastAPI) -> None:
session_factory: async_sessionmaker[AsyncSession] = app.state.db_session_factory
stop_event = Event()
worker_thread = Thread(
target=run_worker_loop,
kwargs={
"session_factory": session_factory,
"stop_event": stop_event,
"poll_interval_seconds": 1.0,
},
daemon=True,
)
worker_thread.start()
app.state.worker_stop_event = stop_event
app.state.worker_thread = worker_thread
def _stop_worker(app: FastAPI) -> None:
stop_event = getattr(app.state, "worker_stop_event", None)
worker_thread = getattr(app.state, "worker_thread", None)
if stop_event is not None:
stop_event.set()
if worker_thread is not None:
worker_thread.join(timeout=2.0)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def _lifespan(app: FastAPI):
configure_logging()
settings = get_settings()
settings = getattr(app.state, "settings", None) or get_settings()
configure_logging(settings)
app.state.settings = settings
runtime = initialize_database_runtime(settings=settings)
app.state.db_engine = runtime.engine
app.state.db_session_factory = runtime.session_factory
app.state.services = ServiceBundle()
app.state.runtime = initialize_database_runtime(settings=settings)
if settings.should_bootstrap_schema:
await create_all(engine=runtime.engine)
await create_all(engine=app.state.runtime.engine)
settings.upload_dir.mkdir(parents=True, exist_ok=True)
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
_start_worker(app)
try:
await _recover_stale_processing_jobs(app)
async with AsyncExitStack() as stack:
stack.push_async_callback(dispose_database_runtime)
stop_event, worker_notifier = await stack.enter_async_context(
worker_consumer_lifespan(
session_factory=app.state.runtime.session_factory,
poll_interval_seconds=1.0,
)
)
app.state.worker_stop_event = stop_event
app.state.worker_notifier = worker_notifier
yield
finally:
_stop_worker(app)
await cleanup_database()
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."""
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)
async def root_redirect() -> RedirectResponse:
return RedirectResponse(url="/ui", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
@app.get("/ui", include_in_schema=False)
async def ui_redirect() -> RedirectResponse:
return RedirectResponse(url="/ui/documents", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
@app.get("/healthz")
def health() -> dict[str, str]:
return {"status": "ok"}
app.include_router(health_router)
register_error_handlers(app)
register_pages(app)
app.include_router(health_router)
return app
+39
View File
@@ -0,0 +1,39 @@
"""Helpers for accessing lifespan-owned application state resources."""
from __future__ import annotations
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.db.runtime import DatabaseRuntime
from transcription.db.session import get_session_factory
from transcription.worker import WorkerNotifier
from transcription.worker import resolve_worker_notifier
def resolve_database_runtime(state: object) -> DatabaseRuntime | None:
"""Return database runtime from app-like state objects when available."""
runtime = getattr(state, "runtime", None)
return runtime if isinstance(runtime, DatabaseRuntime) else None
def require_database_runtime(state: object) -> DatabaseRuntime:
"""Return database runtime or raise when app lifespan has not initialized it."""
runtime = resolve_database_runtime(state)
if runtime is None:
raise RuntimeError("Database runtime is not initialized on application state")
return runtime
def resolve_session_factory(state: object) -> async_sessionmaker[AsyncSession]:
"""Return DB session factory from state when available, otherwise shared runtime."""
runtime = resolve_database_runtime(state)
if runtime is not None:
return runtime.session_factory
return get_session_factory()
def get_worker_notifier(app: FastAPI) -> WorkerNotifier:
"""Return app worker notifier, or a no-op fallback when unavailable."""
return resolve_worker_notifier(app.state)
+59 -15
View File
@@ -6,11 +6,17 @@ are resolved by the provider adapters, not here.
"""
import logging.config
from contextvars import ContextVar
from collections.abc import Sequence
from enum import StrEnum
from functools import cache
from pathlib import Path
from typing import Annotated
from typing import Any
from typing import Literal
from pydantic import BaseModel
from pydantic import Field
from pydantic import SecretStr
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict
@@ -21,13 +27,42 @@ class Provider(StrEnum):
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):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
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 ---
provider: Provider = Provider.OPENROUTER
openrouter_api_key: str
@@ -39,8 +74,9 @@ class Settings(BaseSettings):
environment: Literal["development", "test", "production"] = "development"
# --- persistence ---
database_url: str = "sqlite:///./transcription.db"
bootstrap_schema_on_startup: bool | None = None
database: DatabaseSettings = Field(default_factory=SqliteSettings)
bootstrap_schema_on_startup: bool = False
sqlite_check_same_thread: bool = False
# --- filesystem paths ---
upload_dir: Path = Path("./uploads")
@@ -49,32 +85,37 @@ class Settings(BaseSettings):
# --- worker reliability ---
worker_max_retries: int = 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
def should_bootstrap_schema(self) -> bool:
"""Return whether startup should auto-create schema for this environment."""
if self.bootstrap_schema_on_startup is not None:
if "bootstrap_schema_on_startup" in self.model_fields_set:
return self.bootstrap_schema_on_startup
return self.environment in {"development", "test"}
_settings: ContextVar[Settings | None] = ContextVar("settings", default=None)
@cache
def get_settings(**kwargs: Any) -> Settings:
"""Load cached settings without reading process CLI arguments."""
return Settings(_cli_parse_args=False, **kwargs) # pyright: ignore[reportCallIssue]
def get_settings() -> Settings:
settings = _settings.get()
if settings is None:
settings = Settings() # pyright: ignore[reportCallIssue]
_settings.set(settings)
return settings
def parse_cli_settings(args: Sequence[str] | None = None) -> Settings:
"""Load settings with CLI arguments at the executable boundary."""
cli_args = True if args is None else list(args)
return Settings(_cli_parse_args=cli_args) # pyright: ignore[reportCallIssue]
LOGGING_CONFIG: dict[str, object] = {
LOGGING_CONFIG: dict[str, Any] = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
"format": "%(asctime)s %(levelname)-8s | %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
}
},
@@ -99,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."""
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")
-147
View File
@@ -1,147 +0,0 @@
"""Database runtime ownership, schema bootstrap, and session access.
V1 moves database resource ownership to explicit runtime initialization so
startup/shutdown behavior is predictable and lifespan-managed.
"""
from __future__ import annotations
import contextlib
import logging
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from sqlalchemy import inspect
from sqlalchemy import text
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from .config import Settings
from .config import get_settings
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class DatabaseRuntime:
"""Database runtime resources owned by app lifespan."""
engine: AsyncEngine
session_factory: async_sessionmaker[AsyncSession]
_runtime: DatabaseRuntime | None = 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)
connect_args: dict[str, object] = {}
if database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
return create_async_engine(
url=database_url,
echo=False,
pool_pre_ping=True,
connect_args=connect_args,
)
def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime:
"""Initialize lifespan-owned async DB resources once per process."""
global _runtime
if _runtime is not None:
return _runtime
active_settings = settings or get_settings()
engine = _build_engine(active_settings)
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
_runtime = DatabaseRuntime(engine=engine, session_factory=session_factory)
logger.debug("Initialized async database runtime for database_url=%s", engine.url)
return _runtime
def get_engine() -> AsyncEngine:
"""Return the current async SQLAlchemy engine."""
runtime = _runtime or initialize_database_runtime()
return runtime.engine
def get_session_factory() -> async_sessionmaker[AsyncSession]:
"""Return the shared async session factory."""
runtime = _runtime or initialize_database_runtime()
return runtime.session_factory
async def cleanup_database() -> None:
"""Cleanup database runtime resources."""
await dispose_database_runtime()
async def dispose_database_runtime() -> None:
"""Dispose lifespan-owned async database resources."""
global _runtime
if _runtime is None:
return
await _runtime.engine.dispose()
_runtime = None
async def create_all(*, engine: AsyncEngine | None = None) -> None:
"""Create all tables on the selected engine."""
# Import models so SQLModel metadata is fully registered before bootstrap.
from transcription import models as _models # noqa: F401
active_engine = engine or get_engine()
async with active_engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all)
await connection.run_sync(_ensure_sqlite_compat_columns)
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
def _ensure_sqlite_compat_columns(connection: Connection) -> None:
"""Apply lightweight dev/test SQLite compatibility column patches.
This keeps local bootstrap resilient when models evolve but no full
migration tooling is in place yet.
"""
if connection.engine.url.get_backend_name() != "sqlite":
return
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
if "job" not in table_names:
return
columns = {column["name"] for column in inspector.get_columns("job")}
if "retry_count" not in columns:
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0")
@contextlib.asynccontextmanager
async def get_session(
*,
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()
async with active_session_factory() as session:
yield session
def should_bootstrap_schema(settings: Settings) -> bool:
"""Compatibility helper for explicit bootstrap checks."""
return settings.should_bootstrap_schema
+13
View File
@@ -0,0 +1,13 @@
from .operations import create_all
from .runtime import dispose_database_runtime
from .runtime import initialize_database_runtime
from .session import session_scope
from .session import transaction_scope
__all__ = [
"create_all",
"dispose_database_runtime",
"initialize_database_runtime",
"session_scope",
"transaction_scope",
]
+60
View File
@@ -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)
+230
View File
@@ -0,0 +1,230 @@
"""SQLModel domain models for the V2 transcription system."""
from datetime import UTC
from datetime import date
from datetime import datetime
from enum import StrEnum
from typing import Any
from typing import Optional
from uuid import UUID
from uuid import uuid4
from sqlalchemy import JSON
from sqlalchemy import Column
from sqlalchemy import UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm.exc import DetachedInstanceError
from sqlalchemy.types import TypeDecorator
from sqlmodel import Field
from sqlmodel import Relationship
from sqlmodel import SQLModel
class JSONBCompat(TypeDecorator):
"""JSONB for PostgreSQL and JSON for SQLite/testing backends."""
impl = JSON
def load_dialect_impl(self, dialect):
if dialect.name == "postgresql":
return dialect.type_descriptor(JSONB())
return dialect.type_descriptor(JSON())
class JobStatus(StrEnum):
QUEUED = "queued"
PROCESSING = "processing"
TRANSCRIBED = "transcribed"
COMPLETED = "completed"
PARTIAL_SUCCESS = "partial_success"
FAILED = "failed"
class DocumentPersonRole(StrEnum):
AUTHOR = "author"
RECIPIENT = "recipient"
class JobSourceStatus(StrEnum):
PENDING = "pending"
TRANSCRIBED = "transcribed"
FAILED = "failed"
class Document(SQLModel, table=True):
"""An historical document."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
name: str
document_type: str | None = None
document_date: date | None = None
document_date_raw: str | None = None
location_created: str | None = None
notes: str | None = None
archive_identifier: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
jobs: list["Job"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
sources: list["Source"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
document_people: list["DocumentPerson"] = Relationship(
back_populates="document", sa_relationship_kwargs={"lazy": "selectin"}
)
@property
def authors(self):
"""Return linked people whose role is AUTHOR."""
return [
link.person
for link in self.document_people
if link.role == DocumentPersonRole.AUTHOR and link.person is not None
]
@property
def author(self):
"""Return the first linked author for convenience in read paths."""
return self.authors[0] if self.authors else None
class Person(SQLModel, table=True):
"""A historical person linked to one or more documents."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
full_name: str
display_name: str | None = None
maiden_name: str | None = None
birth_date: date | None = None
birth_date_raw: str | None = None
birth_place: str | None = None
death_date: date | None = None
death_date_raw: str | None = None
death_place: str | None = None
biography: str | None = None
portrait_path: str | None = None
metadata_: dict[str, Any] | None = Field(
default=None,
sa_column=Column("metadata", JSONBCompat(), nullable=True),
)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
document_people: list["DocumentPerson"] = Relationship(
back_populates="person", sa_relationship_kwargs={"lazy": "selectin"}
)
@property
def authored_documents(self):
"""Return documents where this person is linked as AUTHOR."""
return [
link.document
for link in self.document_people
if link.role == DocumentPersonRole.AUTHOR and link.document is not None
]
class DocumentPerson(SQLModel, table=True):
"""Associates documents with people in a given role."""
__tablename__: str = "document_person"
id: UUID = Field(default_factory=uuid4, primary_key=True)
document_id: UUID = Field(foreign_key="document.id")
person_id: UUID = Field(foreign_key="person.id")
role: DocumentPersonRole = Field(default=DocumentPersonRole.AUTHOR)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
__table_args__ = (UniqueConstraint("document_id", "person_id", "role", name="uq_document_person_role"),)
document: Optional["Document"] = Relationship(
back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"}
)
person: Optional["Person"] = Relationship(
back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"}
)
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
model: str | None = None
prompt_name: str | None = None
document: Optional["Document"] = Relationship(back_populates="jobs", sa_relationship_kwargs={"lazy": "selectin"})
job_sources: list["JobSource"] = Relationship(back_populates="job", sa_relationship_kwargs={"lazy": "selectin"})
@property
def filename(self) -> str:
"""Return the filename of the associated source, when available."""
if not self.job_sources:
return "unknown"
for job_source in self.job_sources:
source = job_source.__dict__.get("source")
if source is None:
try:
source = job_source.source
except DetachedInstanceError:
source = None
except Exception: # noqa: BLE001
source = None
if source is not None:
return source.filename
return "unknown"
@property
def error_detail(self) -> str | None:
"""Return the first available source-level error detail for the job."""
if not self.job_sources:
return None
for job_source in self.job_sources:
if job_source.error_detail:
return job_source.error_detail
return None
class Source(SQLModel, table=True):
"""A document source image or PDF page."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
document_id: UUID = Field(foreign_key="document.id")
page_number: int = Field(default=1, ge=1)
upload_name: str
filename: str
file_path: str
raw_transcription: str | None = None
revised_text: str | None = None
date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC))
date_revised: datetime | None = None
document: Optional["Document"] = Relationship(back_populates="sources", sa_relationship_kwargs={"lazy": "selectin"})
job_sources: list["JobSource"] = Relationship(back_populates="source", sa_relationship_kwargs={"lazy": "selectin"})
class JobSource(SQLModel, table=True):
"""A single AI execution record for one source page."""
__tablename__: str = "job_source"
id: UUID = Field(default_factory=uuid4, primary_key=True)
job_id: UUID = Field(foreign_key="job.id")
source_id: UUID = Field(foreign_key="source.id")
status: JobSourceStatus = Field(default=JobSourceStatus.PENDING)
raw_transcription: str | None = None
ai_metadata: dict[str, Any] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
raw_api_response: dict[str, Any] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
error_detail: str | None = None
executed_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
job: Optional["Job"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "selectin"})
source: Optional["Source"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "selectin"})
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
import logging
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlmodel import SQLModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from .engine import resolve_engine
from .models import Job
from .models import JobStatus
logger = logging.getLogger(__name__)
async def create_all(*, engine: AsyncEngine | None = None) -> None:
"""Create any missing tables on the selected engine."""
# Import models so SQLModel metadata is fully registered before bootstrap.
from transcription.db import models as _models # noqa: F401
active_engine = engine or resolve_engine()
async with active_engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all)
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
async def get_next_queued_job(*, session: AsyncSession) -> Job | None:
"""Get the next queued job, if any."""
result = await session.exec(
select(Job)
.where(Job.status == JobStatus.QUEUED)
.order_by(Job.date_created) # pyright: ignore[reportArgumentType]
.limit(1)
) # fmt: skip
return result.first()
+51
View File
@@ -0,0 +1,51 @@
import logging
from contextvars import ContextVar
from dataclasses import dataclass
from sqlalchemy.ext.asyncio import AsyncEngine
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 get_database_url
from .engine import get_engine
from .session import get_session_factory
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class DatabaseRuntime:
"""Database runtime resources owned by app lifespan."""
engine: AsyncEngine
session_factory: async_sessionmaker[AsyncSession]
_runtime: ContextVar[DatabaseRuntime | None] = ContextVar("database_runtime", default=None)
async def dispose_database_runtime() -> None:
"""Dispose lifespan-owned async database resources."""
runtime = _runtime.get()
if runtime is None:
return
await runtime.engine.dispose()
_runtime.set(None)
def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime:
"""Initialize lifespan-owned async DB resources once per process."""
runtime = _runtime.get()
if runtime is not None:
return runtime
active_settings = settings or get_settings()
database_url = get_database_url(active_settings)
engine = get_engine(database_url)
session_factory = get_session_factory(database_url)
runtime = DatabaseRuntime(engine=engine, session_factory=session_factory)
_runtime.set(runtime)
logger.debug("Initialized async database runtime for database_url=%s", engine.url)
return runtime
+99
View File
@@ -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),
]
+2 -4
View File
@@ -17,6 +17,7 @@ class ErrorCategory(StrEnum):
NOT_FOUND = "not_found_error"
CONFLICT = "conflict_error"
EXTERNAL_PROVIDER = "external_provider_error"
PROCESSING = "processing_error"
INFRA_TRANSIENT = "infrastructure_transient_error"
INFRA_PERSISTENT = "infrastructure_persistent_error"
INTERNAL_UNEXPECTED = "internal_unexpected_error"
@@ -81,7 +82,4 @@ def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
def format_error_detail(error: AppError) -> str:
"""Return a compact persisted failure string for transcript.error_detail."""
return (
f"[{error.category.value}] {error.message} | "
f"suggestion={error.suggestion} | error_id={error.error_id}"
)
return f"[{error.category.value}] {error.message} | suggestion={error.suggestion} | error_id={error.error_id}"
-71
View File
@@ -1,71 +0,0 @@
"""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 UTC
from datetime import datetime
from enum import StrEnum
from typing import Optional
from uuid import UUID
from uuid import uuid4
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")
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(UTC),
)
# --- relationships ---
job: Job = Relationship(back_populates="transcript")
+7 -1
View File
@@ -22,11 +22,17 @@ class TranscriptionResult:
text: str
provider: str
prompt_name: str
model: str
finish_reason: str | None = None
usage_input_tokens: int | None = None
usage_output_tokens: int | None = None
usage_total_tokens: int | None = None
class TranscriptionProvider(Protocol):
"""Contract every transcription provider adapter must satisfy."""
def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
async def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
"""Transcribe the provided image according to the prompt text."""
...
+51 -4
View File
@@ -6,8 +6,10 @@ import base64
import logging
from dataclasses import dataclass
from typing import Any
from typing import cast
from openrouter import OpenRouter
from openrouter.components.chatmessages import ChatMessagesTypedDict
from transcription.config import Settings
from transcription.config import get_settings
@@ -44,12 +46,12 @@ class OpenRouterTranscriptionProvider:
"""Return the resolved OpenRouter model slug."""
return self._model
def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
async def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
"""Send prompt + image to OpenRouter and return normalized text output."""
request = self._build_request(prompt_text=prompt_text, image_bytes=image_bytes, mime_type=mime_type)
try:
response = self._client.chat.send(
messages=request.messages,
response = await self._client.chat.send_async(
messages=cast(list[ChatMessagesTypedDict], request.messages),
model=request.model,
http_referer=request.http_referer,
x_open_router_title=request.x_open_router_title,
@@ -62,8 +64,19 @@ class OpenRouterTranscriptionProvider:
text = self._extract_text(response)
model = self._get_optional_attr(response, "model") or self.model
finish_reason = self._extract_finish_reason(response)
usage_input_tokens, usage_output_tokens, usage_total_tokens = self._extract_usage(response)
logger.info("OpenRouter transcription completed using model=%s", model)
return TranscriptionResult(text=text, provider="openrouter", 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:
image_b64 = base64.b64encode(image_bytes).decode("ascii")
@@ -102,6 +115,34 @@ class OpenRouterTranscriptionProvider:
raise ProviderResponseError("OpenRouter response contained no transcription text")
return text
def _extract_finish_reason(self, response: Any) -> str | None:
choices = self._get_optional_attr(response, "choices")
if not choices:
return None
first_choice = choices[0]
finish_reason = self._get_optional_attr(first_choice, "finish_reason")
if isinstance(finish_reason, str) and finish_reason.strip():
return finish_reason.strip()
return None
def _extract_usage(self, response: Any) -> tuple[int | None, int | None, int | None]:
usage = self._get_optional_attr(response, "usage")
if usage is None:
return None, None, None
input_tokens = self._as_int(self._get_optional_attr(usage, "prompt_tokens"))
output_tokens = self._as_int(self._get_optional_attr(usage, "completion_tokens"))
total_tokens = self._as_int(self._get_optional_attr(usage, "total_tokens"))
if input_tokens is None:
input_tokens = self._as_int(self._get_optional_attr(usage, "input_tokens"))
if output_tokens is None:
output_tokens = self._as_int(self._get_optional_attr(usage, "output_tokens"))
if total_tokens is None:
total_tokens = self._as_int(self._get_optional_attr(usage, "total"))
return input_tokens, output_tokens, total_tokens
def _normalize_content(self, content: Any) -> str:
if isinstance(content, str):
return content.strip()
@@ -125,3 +166,9 @@ class OpenRouterTranscriptionProvider:
if isinstance(obj, dict):
return obj.get(key)
return getattr(obj, key, None)
@staticmethod
def _as_int(value: Any) -> int | None:
if isinstance(value, int):
return value
return None
+26 -22
View File
@@ -1,25 +1,29 @@
"""Service layer exports."""
from transcription.services.transcription import DEFAULT_PROMPT_FILE
from transcription.services.transcription import PromptLoadError
from transcription.services.transcription import TranscriptionError
from transcription.services.transcription import load_image_payload
from transcription.services.transcription import load_prompt_text
from transcription.services.transcription import transcribe_document_image
from transcription.services.upload import SUPPORTED_UPLOAD_EXTENSIONS
from transcription.services.upload import UploadError
from transcription.services.upload import UploadJobResult
from transcription.services.upload import create_upload_job
from dataclasses import dataclass
from dataclasses import field
__all__ = [
"DEFAULT_PROMPT_FILE",
"SUPPORTED_UPLOAD_EXTENSIONS",
"PromptLoadError",
"TranscriptionError",
"UploadError",
"UploadJobResult",
"create_upload_job",
"load_image_payload",
"load_prompt_text",
"transcribe_document_image",
]
from ..db.session import SessionFactory
from .documents import DocumentService
from .jobs import JobService
from .transcription import TranscriptionService
__all__ = ["DocumentService", "JobService", "ServiceBundle", "TranscriptionService"]
@dataclass(frozen=True, slots=True)
class ServiceBundle:
"""Container for all service instances."""
documents: DocumentService = field(default_factory=DocumentService)
jobs: JobService = field(default_factory=JobService)
transcriptions: TranscriptionService = field(default_factory=TranscriptionService)
@classmethod
def from_session_factory(cls, session_factory: SessionFactory) -> "ServiceBundle":
"""Create a ServiceBundle from a session factory."""
return cls(
documents=DocumentService(session_factory=session_factory),
jobs=JobService(session_factory=session_factory),
transcriptions=TranscriptionService(session_factory=session_factory),
)
+59
View File
@@ -0,0 +1,59 @@
import asyncio
from abc import ABC
from collections.abc import Sequence
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from ..config import Settings
from ..config import get_settings
from ..db.session import resolve_session_factory
from ..db.session import session_scope
class ServiceBase(ABC):
"""Thin service class for managing documents in the database."""
settings: Settings
session_factory: async_sessionmaker[AsyncSession]
queue: asyncio.Queue
def __init__(
self,
session_factory: async_sessionmaker[AsyncSession] | None = None,
queue: asyncio.Queue | None = None,
):
self.settings = get_settings()
self.session_factory = session_factory or resolve_session_factory()
self.queue = queue or asyncio.Queue()
@asynccontextmanager
async def _session_scope(self, session: AsyncSession | None = None):
"""Provide a transactional scope around a series of operations."""
async with session_scope(
session_factory=self.session_factory,
session=session,
) as active_session:
yield active_session
async def _finalize(
self,
*,
session: AsyncSession,
caller_session: AsyncSession | None,
refresh: Sequence[object] = (),
) -> None:
"""Finalize a write based on transaction ownership.
Service-owned sessions commit immediately. Caller-owned sessions flush so
orchestration code can commit once at a larger transaction boundary.
"""
should_commit = caller_session is None
if should_commit:
await session.commit()
else:
await session.flush()
for obj in refresh:
await session.refresh(obj)
+347
View File
@@ -0,0 +1,347 @@
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from pathlib import Path
from uuid import UUID
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..db.models import Document
from ..db.models import DocumentPerson
from ..db.models import Person
from ..errors import AppError
from ..errors import ErrorCategory
from .base import ServiceBase
logger = logging.getLogger(__name__)
class DocumentError(AppError):
"""Raised when document operations fail."""
class MissingSourceError(DocumentError):
"""Raised when a document has no associated sources."""
class UploadError(DocumentError):
"""Raised when uploaded content cannot be persisted safely."""
class DocumentAlreadyExistsError(DocumentError):
"""Raised when a document with the same name already exists in the database."""
class DocumentDeleteBlockedError(DocumentError):
"""Raised when a document delete is blocked by dependent records."""
class PersonDeleteBlockedError(DocumentError):
"""Raised when a person delete is blocked by linked documents."""
@dataclass(frozen=True)
class UploadJobResult:
"""Summary of created upload records."""
document_id: UUID
job_id: UUID
stored_path: Path
original_filename: str
class DocumentService(ServiceBase):
"""Thin service class for managing documents in the database."""
#
# CRUD Operations
#
async def create_document(
self,
document: Document,
*,
session: AsyncSession | None = None,
) -> Document:
"""Create a new document in the database."""
async with self._session_scope(session) as _session:
_session.add(document)
try:
await self._finalize(session=_session, caller_session=session, refresh=(document,))
except IntegrityError as exc:
raise DocumentAlreadyExistsError(
f"Document with id {document.id} already exists",
category=ErrorCategory.VALIDATION,
suggestion="Rename the file and try again.",
) from exc
return document
async def read_document(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
"""Read an existing document from the database.
The selectinload option is used to eagerly load related jobs and sources.
"""
async with self._session_scope(session) as _session:
document = await _session.get(
Document,
document_id,
options=(
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
),
)
if document is None:
raise DocumentError(
f"Document with id {document_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Re-upload the source document and retry.",
)
elif not document.sources:
raise MissingSourceError(
f"Document with id {document_id} has no associated source records",
category=ErrorCategory.NOT_FOUND,
suggestion="Upload at least one source for this document and retry.",
)
return document
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
"""Update an existing document in the database."""
async with self._session_scope(session) as _session:
document.updated_at = datetime.now(UTC)
merged = await _session.merge(document)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
"""Delete a document from the database."""
async with self._session_scope(session) as _session:
existing = await _session.get(
Document,
document.id,
options=(
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
),
)
if existing is None:
raise DocumentError(
f"Document with id {document.id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the document id and retry.",
)
has_jobs = bool(existing.jobs)
has_sources = bool(existing.sources)
if has_jobs or has_sources:
blocked_by: list[str] = []
if has_sources:
blocked_by.append("Sources")
if has_jobs:
blocked_by.append("Jobs")
raise DocumentDeleteBlockedError(
f"Document delete blocked by related records: {', '.join(blocked_by)}",
category=ErrorCategory.VALIDATION,
suggestion="Remove related Sources and Jobs first, then retry deletion.",
)
await _session.delete(existing)
await self._finalize(session=_session, caller_session=session)
async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
"""Create a new person in the database."""
async with self._session_scope(session) as _session:
_session.add(person)
await self._finalize(session=_session, caller_session=session, refresh=(person,))
return person
async def read_person(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
"""Read an existing person from the database."""
async with self._session_scope(session) as _session:
person = await _session.get(Person, person_id)
if person is None:
raise DocumentError(
f"Person with id {person_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the person id and retry.",
)
return person
async def read_person_detail(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
"""Read a person with eagerly loaded document links for UI detail rendering."""
async with self._session_scope(session) as _session:
query = (
select(Person)
.options(
selectinload(Person.document_people).selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
)
.where(Person.id == person_id)
.execution_options(populate_existing=True)
)
person = (await _session.exec(query)).first()
if person is None:
raise DocumentError(
f"Person with id {person_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the person id and retry.",
)
return person
async def update_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
"""Update an existing person in the database."""
async with self._session_scope(session) as _session:
person.updated_at = datetime.now(UTC)
merged = await _session.merge(person)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
async def delete_person(self, person: Person, *, session: AsyncSession | None = None) -> None:
"""Delete a person from the database."""
async with self._session_scope(session) as _session:
existing = await _session.get(
Person,
person.id,
options=(
selectinload(Person.document_people), # pyright: ignore[reportArgumentType]
),
)
if existing is None:
raise DocumentError(
f"Person with id {person.id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the person id and retry.",
)
if existing.document_people:
raise PersonDeleteBlockedError(
"Person delete blocked by linked documents",
category=ErrorCategory.VALIDATION,
suggestion="Remove linked DocumentPerson records first, then retry deletion.",
)
await _session.delete(existing)
await self._finalize(session=_session, caller_session=session)
async def create_document_person(
self,
document_person: DocumentPerson,
*,
session: AsyncSession | None = None,
) -> DocumentPerson:
"""Create a document-person association in the database."""
async with self._session_scope(session) as _session:
_session.add(document_person)
await self._finalize(session=_session, caller_session=session, refresh=(document_person,))
return document_person
async def read_document_person(
self,
document_person_id: UUID,
*,
session: AsyncSession | None = None,
) -> DocumentPerson:
"""Read an existing document-person association from the database."""
async with self._session_scope(session) as _session:
document_person = await _session.get(DocumentPerson, document_person_id)
if document_person is None:
raise DocumentError(
f"DocumentPerson with id {document_person_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the document-person id and retry.",
)
return document_person
async def update_document_person(
self,
document_person: DocumentPerson,
*,
session: AsyncSession | None = None,
) -> DocumentPerson:
"""Update an existing document-person association in the database."""
async with self._session_scope(session) as _session:
merged = await _session.merge(document_person)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
async def delete_document_person(
self,
document_person: DocumentPerson,
*,
session: AsyncSession | None = None,
) -> None:
"""Delete a document-person association from the database."""
async with self._session_scope(session) as _session:
await _session.delete(document_person)
await self._finalize(session=_session, caller_session=session)
# Query Operations
async def query_documents(
self, *, name: str | None = None, session: AsyncSession | None = None
) -> Sequence[Document]:
"""Query documents from the database based on provided filters."""
async with self._session_scope(session) as _session:
query = select(Document)
if name is not None:
query = query.where(Document.name == name)
result = await _session.exec(query)
return result.all()
async def list_documents(self, *, session: AsyncSession | None = None) -> Sequence[Document]:
"""List all documents in the database."""
async with self._session_scope(session) as _session:
result = await _session.exec(select(Document))
return result.all()
async def read_document_detail(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
"""Read a document with eagerly loaded relations for UI detail rendering."""
async with self._session_scope(session) as _session:
query = (
select(Document)
.options(
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
selectinload(Document.document_people).selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
)
.where(Document.id == document_id)
.execution_options(populate_existing=True)
)
document = (await _session.exec(query)).first()
if document is None:
raise DocumentError(
f"Document with id {document_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the document id and retry.",
)
return document
async def list_people(self, *, session: AsyncSession | None = None) -> Sequence[Person]:
"""List all people in the database."""
async with self._session_scope(session) as _session:
result = await _session.exec(select(Person))
return result.all()
async def list_document_people(
self,
*,
document_id: UUID | None = None,
person_id: UUID | None = None,
session: AsyncSession | None = None,
) -> Sequence[DocumentPerson]:
"""List document-person associations, optionally filtered by document or person."""
async with self._session_scope(session) as _session:
query = select(DocumentPerson).options(
selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
)
if document_id is not None:
query = query.where(DocumentPerson.document_id == document_id)
if person_id is not None:
query = query.where(DocumentPerson.person_id == person_id)
result = await _session.exec(query)
return result.all()
+223
View File
@@ -0,0 +1,223 @@
from collections.abc import Sequence
from datetime import UTC
from datetime import datetime
from uuid import UUID
from sqlalchemy.orm import selectinload
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..errors import AppError
from ..errors import ErrorCategory
from ..db.models import Job
from ..db.models import JobSource
from ..db.models import JobStatus
from ..db.models import Source
from .base import ServiceBase
class JobDeleteBlockedError(AppError):
"""Raised when a job delete operation is blocked by lifecycle policy."""
class JobService(ServiceBase):
"""Thin service class for managing jobs in the database."""
#
# CRUD Operations
#
async def create_job(self, job: Job, session: AsyncSession | None = None) -> Job:
"""Create a new job in the database."""
async with self._session_scope(session) as _session:
_session.add(job)
await self._finalize(session=_session, caller_session=session, refresh=(job,))
return job
async def read_job(self, job_id: UUID, session: AsyncSession | None = None) -> Job:
"""Read an existing job from the database.
The related document is always eagerly loaded so callers can safely
access ``job.document`` in async contexts without triggering lazy-load IO.
"""
async with self._session_scope(session) as _session:
query = (
select(Job)
.options(
selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
)
.where(Job.id == job_id)
.execution_options(populate_existing=True)
)
job = (await _session.exec(query)).first()
if job is None:
raise ValueError(f"Job with id {job_id} not found")
return job
async def update_job(self, job: Job, session: AsyncSession | None = None) -> Job:
"""Update an existing job in the database."""
async with self._session_scope(session) as _session:
merged = await _session.merge(job)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
async def delete_job(self, job: Job, session: AsyncSession | None = None) -> None:
"""Delete a job from the database."""
async with self._session_scope(session) as _session:
await _session.delete(job)
await self._finalize(session=_session, caller_session=session)
# Query Operations
async def query_jobs(
self,
*,
status: JobStatus | None = None,
filename: str | None = None,
session: AsyncSession | None = None,
) -> Sequence[Job]:
"""Query jobs from the database based on provided filters."""
async with self._session_scope(session) as _session:
query = select(Job).options(
selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
)
if status is not None:
query = query.where(Job.status == status)
if filename is not None:
query = query.where(Job.job_sources.any(JobSource.source.has(Source.filename == filename)))
result = await _session.exec(query)
return result.all()
async def list_jobs(
self,
*,
load_docs: bool = False,
session: AsyncSession | None = None,
) -> Sequence[Job]:
"""List all jobs in the database with eagerly loaded documents."""
_ = load_docs
async with self._session_scope(session) as _session:
query = select(Job).options(
selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
)
result = await _session.exec(query)
return result.all()
# Other Operations
async def mark_job_status(
self,
job_id: UUID,
status: JobStatus,
session: AsyncSession | None = None,
) -> Job:
"""Mark a job with a new status."""
return await self.update_job_state(job_id=job_id, status=status, session=session)
async def update_job_state(
self,
*,
job_id: UUID,
status: JobStatus,
retry_count_increment: int = 0,
session: AsyncSession | None = None,
) -> Job:
"""Update a job's lifecycle fields.
When ``session`` is provided, this method flushes so callers can commit
once at an orchestration boundary.
"""
async with self._session_scope(session) as _session:
query = (
select(Job)
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
.where(Job.id == job_id)
.execution_options(populate_existing=True)
)
job = (await _session.exec(query)).first()
if job is None:
raise ValueError(f"Job with id {job_id} not found")
job.status = status
if retry_count_increment:
job.retry_count += retry_count_increment
job.date_updated = datetime.now(UTC)
await self._finalize(session=_session, caller_session=session, refresh=(job,))
return job
async def read_next_queued_job(
self,
*,
session: AsyncSession | None = None,
) -> Job | None:
"""Read the next queued job ordered by creation time."""
async with self._session_scope(session) as _session:
query = (
select(Job)
.options(
selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
)
.where(Job.status == JobStatus.QUEUED)
# Break ties by id so "next" is stable when two rows share close timestamps.
.order_by(Job.date_created, Job.id) # pyright: ignore[reportArgumentType]
)
return (await _session.exec(query)).first()
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)
async def delete_job_with_guardrails(self, *, job_id: UUID, session: AsyncSession | None = None) -> None:
"""Delete a job with lifecycle guardrails and dependent cleanup policy.
Policy:
- Block when the job is actively processing.
- Otherwise remove related JobSource rows, then delete the job.
"""
async with self._session_scope(session) as _session:
query = (
select(Job)
.options(selectinload(Job.job_sources)) # pyright: ignore[reportArgumentType]
.where(Job.id == job_id)
.execution_options(populate_existing=True)
)
job = (await _session.exec(query)).first()
if job is None:
raise ValueError(f"Job with id {job_id} not found")
if job.status == JobStatus.PROCESSING:
raise JobDeleteBlockedError(
"Job delete blocked while status is processing",
category=ErrorCategory.VALIDATION,
suggestion="Wait for processing to complete, or move the job out of processing before deleting.",
)
for job_source in list(job.job_sources):
await _session.delete(job_source)
await _session.delete(job)
await self._finalize(session=_session, caller_session=session)
+321
View File
@@ -0,0 +1,321 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
import logging
from pathlib import Path
from uuid import UUID
from uuid import uuid4
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings
from transcription.config import get_settings
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from ..db.models import Document
from ..db.models import Job
from ..db.models import JobSource
from ..db.models import JobSourceStatus
from ..db.models import Source
from .documents import UploadJobResult
logger = logging.getLogger(__name__)
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
SUPPORTED_PORTRAIT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
class UploadError(AppError):
"""Raised when uploaded content cannot be persisted safely."""
@dataclass(frozen=True)
class JobCreateResult:
"""Summary of explicit Job create records."""
document_id: UUID
job_id: UUID
source_ids: tuple[UUID, ...]
async def create_upload_job(
*,
filename: str,
file_bytes: bytes,
session: AsyncSession,
settings: Settings | None = None,
) -> UploadJobResult:
"""Create upload-backed document and queued job records."""
runtime_settings = settings or get_settings()
stored_path = store_file(
filename=filename,
file_bytes=file_bytes,
settings=runtime_settings,
)
try:
document, job = await _create_upload_records(
session=session,
original_filename=filename,
stored_path=stored_path,
)
except Exception as exc:
_best_effort_delete(stored_path)
raise UploadError(
"Failed to create upload database records",
category=ErrorCategory.INFRA_TRANSIENT,
suggestion="Retry upload. If this keeps happening, verify database availability.",
retriable=True,
) from exc
logger.info("Created upload job document_id=%s job_id=%s", document.id, job.id)
return UploadJobResult(
document_id=document.id,
job_id=job.id,
stored_path=stored_path,
original_filename=Path(filename).name,
)
async def create_job_for_document(
*,
document_id: UUID,
uploads: Sequence[tuple[str, bytes]],
session: AsyncSession,
provider: str | None = None,
model: str | None = None,
prompt_name: str | None = None,
settings: Settings | None = None,
) -> JobCreateResult:
"""Create a queued job for an existing document with one or more uploaded sources."""
if not uploads:
raise UploadError(
"At least one upload is required to create a job",
category=ErrorCategory.VALIDATION,
suggestion="Upload one or more files and try again.",
)
runtime_settings = settings or get_settings()
sorted_uploads = sorted(uploads, key=lambda item: Path(item[0]).name.casefold())
stored_uploads: list[tuple[str, Path]] = []
for filename, file_bytes in sorted_uploads:
stored_uploads.append(
(
filename,
store_file(
filename=filename,
file_bytes=file_bytes,
settings=runtime_settings,
),
)
)
try:
job, source_ids = await _create_job_for_document_records(
session=session,
document_id=document_id,
stored_uploads=stored_uploads,
provider=provider,
model=model,
prompt_name=prompt_name,
)
except Exception as exc:
for _, stored_path in stored_uploads:
_best_effort_delete(stored_path)
raise UploadError(
"Failed to create job records from uploads",
category=ErrorCategory.INFRA_TRANSIENT,
suggestion="Retry creation. If this keeps happening, verify database availability.",
retriable=True,
) from exc
logger.info("Created explicit job document_id=%s job_id=%s sources=%s", document_id, job.id, len(source_ids))
return JobCreateResult(
document_id=document_id,
job_id=job.id,
source_ids=tuple(source_ids),
)
async def _create_upload_records(
*,
session: AsyncSession,
original_filename: str,
stored_path: Path,
) -> tuple[Document, Job]:
document = Document(
name=Path(original_filename).name,
)
session.add(document)
await session.flush()
job = Job(document_id=document.id)
session.add(job)
await session.flush()
source = Source(
document_id=document.id,
page_number=1,
upload_name=Path(original_filename).name,
filename=stored_path.name,
file_path=str(stored_path),
)
session.add(source)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
await session.refresh(document)
await session.refresh(job)
return document, job
async def _create_job_for_document_records(
*,
session: AsyncSession,
document_id: UUID,
stored_uploads: Sequence[tuple[str, Path]],
provider: str | None,
model: str | None,
prompt_name: str | None,
) -> tuple[Job, list[UUID]]:
document = await session.get(Document, document_id)
if document is None:
raise UploadError(
f"Document with id {document_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Select an existing document and retry.",
)
existing_sources = (
await session.exec(select(Source).where(Source.document_id == document_id))
).all()
next_page_number = (max((source.page_number for source in existing_sources), default=0) + 1)
job = Job(
document_id=document_id,
provider=(provider or None),
model=(model or None),
prompt_name=(prompt_name or None),
)
session.add(job)
await session.flush()
source_ids: list[UUID] = []
for page_offset, (original_filename, stored_path) in enumerate(stored_uploads):
source = Source(
document_id=document_id,
page_number=next_page_number + page_offset,
upload_name=Path(original_filename).name,
filename=stored_path.name,
file_path=str(stored_path),
)
session.add(source)
await session.flush()
source_ids.append(source.id)
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
await session.refresh(job)
return job, source_ids
def _best_effort_delete(path: Path) -> None:
try:
if path.exists():
path.unlink()
except OSError:
logger.warning("Failed to clean up upload file after DB error: %s", path)
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
"""Persist an uploaded file to the configured upload directory."""
runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_UPLOAD_EXTENSIONS)
return _store_file_bytes(filename=filename, file_bytes=file_bytes, settings=runtime_settings)
def store_person_portrait(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
"""Persist a portrait upload under uploads/portraits/person."""
runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_PORTRAIT_EXTENSIONS)
return _store_file_bytes(
filename=filename,
file_bytes=file_bytes,
settings=runtime_settings,
relative_directory=Path("portraits") / "person",
)
def _store_file_bytes(
*,
filename: str,
file_bytes: bytes,
settings: Settings,
relative_directory: Path | None = None,
) -> Path:
upload_dir = settings.upload_dir
target_dir = upload_dir if relative_directory is None else upload_dir / relative_directory
target_dir.mkdir(parents=True, exist_ok=True)
stored_name = _build_stored_filename(filename)
stored_path = target_dir / stored_name
try:
stored_path.write_bytes(file_bytes)
except OSError as exc:
raise UploadError(
"Failed to persist upload file",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Check upload directory permissions and available disk space, then retry.",
) from exc
logger.info("Stored uploaded file: %s", stored_path)
return stored_path
def _validate_upload(*, filename: str, file_bytes: bytes, supported_extensions: set[str]) -> None:
if not file_bytes:
raise UploadError(
"Upload payload is empty",
category=ErrorCategory.VALIDATION,
suggestion="Select a non-empty file and try again.",
)
safe_name = Path(filename).name
if not safe_name:
raise UploadError(
"Upload filename is required",
category=ErrorCategory.VALIDATION,
suggestion="Choose a file with a valid filename and retry.",
)
suffix = Path(safe_name).suffix.lower()
if suffix not in supported_extensions:
raise UploadError(
f"Unsupported upload extension: {suffix}",
category=ErrorCategory.USER_INPUT,
suggestion="Upload a supported image or document file and retry.",
)
def _build_stored_filename(filename: str) -> str:
safe_name = Path(filename).name
suffix = Path(safe_name).suffix.lower()
return f"{uuid4()}{suffix}"
+396 -23
View File
@@ -4,10 +4,24 @@ from __future__ import annotations
import logging
import mimetypes
from collections.abc import Sequence
from contextlib import contextmanager
from datetime import UTC
from datetime import datetime
from pathlib import Path
from uuid import UUID
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import selectinload
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings
from transcription.config import get_settings
from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import Source
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.providers import ProviderAuthError
@@ -17,6 +31,8 @@ from transcription.providers import TranscriptionProvider
from transcription.providers import TranscriptionResult
from transcription.providers import get_transcription_provider
from .base import ServiceBase
logger = logging.getLogger(__name__)
DEFAULT_PROMPT_FILE = "transcribe_document.md"
@@ -31,6 +47,382 @@ class TranscriptionError(AppError):
"""Raised when transcription execution fails."""
class TranscriptionNotFoundError(TranscriptionError):
"""Raised when a transcription-related resource is not found."""
class SourceDeleteBlockedError(TranscriptionError):
"""Raised when source deletion is blocked by dependency policy."""
class TranscriptionService(ServiceBase):
"""Service class for job transcription output and page-level source revisions."""
provider: TranscriptionProvider
def __init__(self, session_factory: async_sessionmaker[AsyncSession] | None = None):
super().__init__(session_factory=session_factory)
self.provider = get_transcription_provider(settings=self.settings)
async def create_source(self, source: Source, *, session: AsyncSession | None = None) -> Source:
"""Create a new source page record in the database."""
async with self._session_scope(session) as _session:
_session.add(source)
await self._finalize(session=_session, caller_session=session, refresh=(source,))
return source
async def read_source(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source:
"""Read an existing source page record."""
async with self._session_scope(session) as _session:
source = await _session.get(Source, source_id)
if source is None:
raise TranscriptionNotFoundError(
f"Source with id {source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the source id and retry.",
)
return source
async def read_source_detail(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source:
"""Read a source page record with job-source context for UI detail rendering."""
async with self._session_scope(session) as _session:
query = (
select(Source)
.options(
selectinload(Source.job_sources).selectinload(JobSource.job), # pyright: ignore[reportArgumentType]
)
.where(Source.id == source_id)
.execution_options(populate_existing=True)
)
source = (await _session.exec(query)).first()
if source is None:
raise TranscriptionNotFoundError(
f"Source with id {source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the source id and retry.",
)
return source
async def update_source(self, source: Source, *, session: AsyncSession | None = None) -> Source:
"""Update an existing source page record."""
async with self._session_scope(session) as _session:
merged = await _session.merge(source)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
async def delete_source(self, source: Source, *, session: AsyncSession | None = None) -> None:
"""Delete a source page record."""
async with self._session_scope(session) as _session:
await _session.delete(source)
await self._finalize(session=_session, caller_session=session)
async def list_sources(
self,
*,
document_id: UUID | None = None,
session: AsyncSession | None = None,
) -> Sequence[Source]:
"""List source pages, optionally filtered by document."""
async with self._session_scope(session) as _session:
query = select(Source)
if document_id is not None:
query = query.where(Source.document_id == document_id)
result = await _session.exec(query)
return result.all()
async def query_sources(
self,
*,
document_id: UUID | None = None,
page_number: int | None = None,
session: AsyncSession | None = None,
) -> Sequence[Source]:
"""Query source pages using the provided filters."""
async with self._session_scope(session) as _session:
query = select(Source)
if document_id is not None:
query = query.where(Source.document_id == document_id)
if page_number is not None:
query = query.where(Source.page_number == page_number)
result = await _session.exec(query)
return result.all()
async def create_job_source(
self,
job_source: JobSource,
*,
session: AsyncSession | None = None,
) -> JobSource:
"""Create a new job_source execution record in the database."""
async with self._session_scope(session) as _session:
_session.add(job_source)
await self._finalize(session=_session, caller_session=session, refresh=(job_source,))
return job_source
async def read_job_source(self, job_source_id: UUID, *, session: AsyncSession | None = None) -> JobSource:
"""Read an existing job_source record."""
async with self._session_scope(session) as _session:
job_source = await _session.get(
JobSource,
job_source_id,
options=(selectinload(JobSource.source),), # pyright: ignore[reportArgumentType]
)
if job_source is None:
raise TranscriptionNotFoundError(
f"JobSource with id {job_source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the job source id and retry.",
)
return job_source
async def update_job_source(self, job_source: JobSource, *, session: AsyncSession | None = None) -> JobSource:
"""Update an existing job_source record."""
async with self._session_scope(session) as _session:
merged = await _session.merge(job_source)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
async def delete_job_source(self, job_source: JobSource, *, session: AsyncSession | None = None) -> None:
"""Delete a job_source record."""
async with self._session_scope(session) as _session:
await _session.delete(job_source)
await self._finalize(session=_session, caller_session=session)
async def delete_source_from_job_context(
self,
*,
job_id: UUID,
source_id: UUID,
session: AsyncSession | None = None,
) -> None:
"""Delete a source from an active job context with dependency guardrails.
Policy:
- Allowed when exactly one JobSource link exists and it points at ``job_id``.
- Blocked when additional JobSource links exist (history/shared dependencies).
"""
async with self._session_scope(session) as _session:
source = await _session.get(
Source,
source_id,
options=(
selectinload(Source.job_sources), # pyright: ignore[reportArgumentType]
),
)
if source is None:
raise TranscriptionNotFoundError(
f"Source with id {source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the source id and retry.",
)
linked_job_sources = list(source.job_sources)
matching_links = [job_source for job_source in linked_job_sources if job_source.job_id == job_id]
if not matching_links:
raise TranscriptionNotFoundError(
f"Source {source_id} is not linked to job {job_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Open the source from its linked job context and retry.",
)
if len(linked_job_sources) > len(matching_links):
raise SourceDeleteBlockedError(
"Source delete blocked by related job history",
category=ErrorCategory.VALIDATION,
suggestion="Remove additional JobSource links first, then retry deletion.",
)
for job_source in matching_links:
await _session.delete(job_source)
await _session.delete(source)
await self._finalize(session=_session, caller_session=session)
async def list_job_sources(
self,
*,
job_id: UUID | None = None,
session: AsyncSession | None = None,
) -> Sequence[JobSource]:
"""List job-source records, optionally filtered by job."""
async with self._session_scope(session) as _session:
query = select(JobSource).options(
selectinload(JobSource.job), # pyright: ignore[reportArgumentType]
selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
)
if job_id is not None:
query = query.where(JobSource.job_id == job_id)
result = await _session.exec(query)
return result.all()
async def transcribe_document(
self,
image_path: str | Path,
job_id: UUID,
*,
prompt_name: str = DEFAULT_PROMPT_FILE,
session: AsyncSession | None = None,
) -> None:
"""Transcribe a local image using the configured prompt and provider."""
result = await transcribe_document_image(
image_path=image_path,
prompt_name=prompt_name,
settings=self.settings,
provider=self.provider,
)
await self.update_job_transcription(
job_id=job_id,
text=result.text,
error_detail=None,
provider=result.provider,
model=result.model,
prompt_name=result.prompt_name,
session=session,
)
async def update_job_transcription(
self,
*,
job_id: UUID,
text: str | None,
error_detail: str | None = None,
provider: str | None = None,
model: str | None = None,
prompt_name: str = DEFAULT_PROMPT_FILE,
session: AsyncSession | None = None,
) -> Job:
"""Persist original transcription output fields on a job."""
async with self._session_scope(session) as _session:
job = await _session.get(Job, job_id)
if job is None:
raise TranscriptionNotFoundError(
f"Job with id {job_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the job id and retry.",
)
job.provider = provider or job.provider or self.settings.provider.value
job.model = model or job.model or _resolve_transcript_model(provider=self.provider, settings=self.settings)
job.prompt_name = prompt_name or job.prompt_name or DEFAULT_PROMPT_FILE
job.date_updated = datetime.now(UTC)
source = await _session.exec(
select(Source)
.where(Source.document_id == job.document_id)
.order_by(Source.page_number) # pyright: ignore[reportArgumentType]
)
source_row = source.first()
if source_row is not None:
existing_job_source = await _session.exec(
select(JobSource).where(JobSource.job_id == job.id).where(JobSource.source_id == source_row.id)
)
job_source = existing_job_source.first()
if job_source is None:
job_source = JobSource(
job_id=job.id,
source_id=source_row.id,
status=JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED,
raw_transcription=text,
error_detail=error_detail,
)
_session.add(job_source)
else:
job_source.raw_transcription = text
job_source.error_detail = error_detail
job_source.status = JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED
job_source.executed_at = datetime.now(UTC)
await self._finalize(session=_session, caller_session=session, refresh=(job,))
return job
async def upsert_revision_for_source(
self,
*,
source_id: UUID,
text: str,
session: AsyncSession | None = None,
) -> Source:
"""Persist a human revision on a source page."""
async with self._session_scope(session) as _session:
source = await _session.get(Source, source_id)
if source is None:
raise TranscriptionNotFoundError(
f"Source with id {source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the source id and retry.",
)
source.revised_text = text
source.date_revised = datetime.now(UTC)
await self._finalize(session=_session, caller_session=session, refresh=(source,))
return source
async def read_revision_by_source(
self,
source_id: UUID,
*,
session: AsyncSession | None = None,
) -> Source | None:
"""Read the source record for a given page, including any revision text."""
async with self._session_scope(session) as _session:
return await _session.get(Source, source_id)
async def list_revisions_by_job(
self,
job_id: UUID,
*,
session: AsyncSession | None = None,
) -> Sequence[Source]:
"""List source pages for a job that carry revision text."""
async with self._session_scope(session) as _session:
query = (
select(Source)
.join(JobSource, JobSource.source_id == Source.id)
.where(JobSource.job_id == job_id)
.where(Source.revised_text.is_not(None))
.order_by(Source.date_revised) # pyright: ignore[reportArgumentType]
)
result = await _session.exec(query)
return result.all()
def _resolve_transcript_model(*, provider: TranscriptionProvider, settings: Settings) -> str:
provider_model = getattr(provider, "model", None)
if isinstance(provider_model, str) and provider_model.strip():
return provider_model
if settings.provider_model and settings.provider_model.strip():
return settings.provider_model
return "unknown"
async def transcribe_document_image(
image_path: str | Path,
*,
prompt_name: str = DEFAULT_PROMPT_FILE,
settings: Settings | None = None,
provider: TranscriptionProvider | None = None,
) -> TranscriptionResult:
"""Transcribe a local image using the configured prompt and provider."""
runtime_settings = settings or get_settings()
prompt_text = load_prompt_text(prompt_name=prompt_name, settings=runtime_settings)
image_bytes, mime_type = load_image_payload(image_path)
adapter = provider or get_transcription_provider(settings=runtime_settings)
logger.info("Starting transcription for image=%s mime_type=%s", image_path, mime_type)
with handle_transcription_errors():
result = await adapter.transcribe(
prompt_text=prompt_text,
image_bytes=image_bytes,
mime_type=mime_type,
)
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
return result
def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settings | None = None) -> str:
"""Load and validate prompt text from PROMPT_DIR."""
runtime_settings = settings or get_settings()
@@ -87,27 +479,11 @@ def load_image_payload(image_path: str | Path) -> tuple[bytes, str]:
return path.read_bytes(), mime_type
def transcribe_document_image(
image_path: str | Path,
*,
prompt_name: str = DEFAULT_PROMPT_FILE,
settings: Settings | None = None,
provider: TranscriptionProvider | None = None,
) -> TranscriptionResult:
"""Transcribe a local image using the configured prompt and provider."""
runtime_settings = settings or get_settings()
prompt_text = load_prompt_text(prompt_name=prompt_name, settings=runtime_settings)
image_bytes, mime_type = load_image_payload(image_path)
adapter = provider or get_transcription_provider(settings=runtime_settings)
logger.info("Starting transcription for image=%s mime_type=%s", image_path, mime_type)
@contextmanager
def handle_transcription_errors():
"""Context manager to handle transcription errors."""
try:
result = adapter.transcribe(
prompt_text=prompt_text,
image_bytes=image_bytes,
mime_type=mime_type,
)
yield
except ProviderAuthError as exc:
raise TranscriptionError(
"Provider authentication failed",
@@ -128,6 +504,3 @@ def transcribe_document_image(
suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
retriable=True,
) from exc
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
return result
-158
View File
@@ -1,158 +0,0 @@
"""Upload service for storing files and creating queued transcription jobs."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from uuid import UUID
from uuid import uuid4
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings
from transcription.config import get_settings
from transcription.db import get_session
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
logger = logging.getLogger(__name__)
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
class UploadError(AppError):
"""Raised when uploaded content cannot be persisted safely."""
@dataclass(frozen=True)
class UploadJobResult:
"""Summary of created upload records."""
document_id: UUID
job_id: UUID
stored_path: Path
original_filename: str
async def create_upload_job(
*,
filename: str,
file_bytes: bytes,
session: AsyncSession | None = None,
settings: Settings | None = None,
) -> UploadJobResult:
"""Persist an uploaded file and create document/job records."""
runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes)
upload_dir = runtime_settings.upload_dir
upload_dir.mkdir(parents=True, exist_ok=True)
stored_name = _build_stored_filename(filename)
stored_path = upload_dir / stored_name
try:
stored_path.write_bytes(file_bytes)
except OSError as exc:
raise UploadError(
"Failed to persist upload file",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Check upload directory permissions and available disk space, then retry.",
) from exc
try:
if session is not None:
document, job = await _create_upload_records(
session=session,
original_filename=filename,
stored_path=stored_path,
)
else:
async with get_session() as local_session:
document, job = await _create_upload_records(
session=local_session,
original_filename=filename,
stored_path=stored_path,
)
except Exception as exc:
_best_effort_delete(stored_path)
raise UploadError(
"Failed to create upload database records",
category=ErrorCategory.INFRA_TRANSIENT,
suggestion="Retry upload. If this keeps happening, verify database availability.",
retriable=True,
) from exc
logger.info("Created upload job document_id=%s job_id=%s", document.id, job.id)
return UploadJobResult(
document_id=document.id,
job_id=job.id,
stored_path=stored_path,
original_filename=Path(filename).name,
)
def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
if not file_bytes:
raise UploadError(
"Upload payload is empty",
category=ErrorCategory.VALIDATION,
suggestion="Select a non-empty file and try again.",
)
safe_name = Path(filename).name
if not safe_name:
raise UploadError(
"Upload filename is required",
category=ErrorCategory.VALIDATION,
suggestion="Choose a file with a valid filename and retry.",
)
suffix = Path(safe_name).suffix.lower()
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS:
raise UploadError(
f"Unsupported upload extension: {suffix}",
category=ErrorCategory.USER_INPUT,
suggestion="Upload JPG, JPEG, PNG, TIFF, or PDF files only.",
)
def _build_stored_filename(filename: str) -> str:
safe_name = Path(filename).name
return f"{uuid4()}_{safe_name}"
async def _create_upload_records(
*,
session: AsyncSession,
original_filename: str,
stored_path: Path,
) -> tuple[Document, Job]:
document = Document(
filename=Path(original_filename).name,
file_path=str(stored_path),
)
session.add(document)
await session.flush()
job = Job(
document_id=document.id,
status=JobStatus.QUEUED,
)
session.add(job)
await session.commit()
await session.refresh(document)
await session.refresh(job)
return document, job
def _best_effort_delete(path: Path) -> None:
try:
if path.exists():
path.unlink()
except OSError:
logger.warning("Failed to clean up upload file after DB error: %s", path)
+356
View File
@@ -0,0 +1,356 @@
import asyncio
import logging
from sqlmodel.ext.asyncio.session import AsyncSession
from ..config import Settings
from ..config import get_settings
from ..db.models import Job
from ..db.models import JobStatus
from ..db.models import Source
from ..errors import AppError
from ..errors import ErrorCategory
from ..errors import classify_unexpected_error
from ..errors import format_error_detail
from ..providers import TranscriptionResult
from . import ServiceBundle
from .transcription import DEFAULT_PROMPT_FILE
from .transcription import transcribe_document_image
logger = logging.getLogger(__name__)
async def advance_job(
job: Job,
services: ServiceBundle,
settings: Settings | None = None,
session: AsyncSession | None = None,
) -> Job | None:
"""Advance a single job by lifecycle status."""
settings = settings or get_settings()
match job.status:
case JobStatus.QUEUED:
return await process_queued_job(job=job, services=services, settings=settings, session=session)
case JobStatus.FAILED:
if job.retry_count < settings.worker_max_retries:
return await services.jobs.update_job_state(
job_id=job.id,
status=JobStatus.QUEUED,
retry_count_increment=1,
session=session,
)
else:
logger.error(f"Job {job.id} has failed and reached max retries.")
return
case _:
return
async def process_queued_job(
*,
job: Job,
services: ServiceBundle,
settings: Settings | None = None,
session: AsyncSession | None = None,
) -> Job | None:
"""Process one complete transcription attempt for a queued job."""
runtime_settings = settings or get_settings()
if job.status != JobStatus.QUEUED:
logger.warning(f"Job {job.id} is not queued. Current status: {job.status}")
return
# Transaction A: claim job for processing.
if session is None:
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING)
else:
# If we're sharing the session, need to make sure setting the Job to PROCESSING is committed before we start
# the transcription, otherwise other workers may see the job as still QUEUED and try to process it.
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING, session=session)
await session.commit()
source_job = await services.jobs.read_job(job_id=job.id, session=session)
source = _resolve_primary_source(source_job)
if source is None:
candidate_sources = await services.transcriptions.list_sources(document_id=job.document_id, session=session)
source = next(iter(sorted(candidate_sources, key=lambda item: item.page_number)), None)
if source is None:
error = AppError(
f"Job {job.id} has no associated source record.",
category=ErrorCategory.VALIDATION,
suggestion="Attach at least one source to the job and retry.",
)
return await _finalize_failed(job=job, services=services, error=error, session=session)
started_at = asyncio.get_running_loop().time()
try:
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)
logger.info(
"Job transcribed operation=worker.process_job job_id=%s document_id=%s source_id=%s provider=%s",
job.id,
job.document_id,
source.id,
result.provider,
)
except TimeoutError:
error = AppError(
f"Provider call timed out after {runtime_settings.worker_provider_timeout_seconds:.1f}s",
category=ErrorCategory.EXTERNAL_PROVIDER,
suggestion="Retry the job. If this repeats, verify provider latency and request payload size.",
retriable=True,
)
job = await _finalize_failed(job=job, services=services, error=error, session=session)
logger.error(
"Job failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
job.id,
job.document_id,
source.id,
error.error_id,
error.category.value,
)
except Exception as exc: # noqa: BLE001
match exc:
case AppError() as error:
pass
case _:
error = classify_unexpected_error(exc, operation="worker.process_job")
job = await _finalize_failed(job=job, services=services, error=error, session=session)
logger.error(
"Job failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
job.id,
job.document_id,
source.id,
error.error_id,
error.category.value,
)
return job
async def process_next_queued_job(
*,
services: ServiceBundle,
settings: Settings | None = None,
session: AsyncSession | None = None,
) -> bool:
"""Process the next queued job if one exists."""
job = await services.jobs.read_next_queued_job(session=session)
if job is None:
return False
await advance_job(job=job, services=services, settings=settings, session=session)
return True
async def _finalize_transcribed(
*,
job: Job,
services: ServiceBundle,
result: TranscriptionResult,
session: AsyncSession | None = None,
) -> Job:
"""Transaction B: job transcription output + TRANSCRIBED in one commit."""
if session is None:
async with services.jobs._session_scope() as local_session:
await services.transcriptions.update_job_transcription(
job_id=job.id,
text=result.text,
error_detail=None,
provider=result.provider,
model=result.model,
prompt_name=result.prompt_name,
session=local_session,
)
updated_job = await services.jobs.mark_job_status(
job.id,
JobStatus.TRANSCRIBED,
session=local_session,
)
await local_session.commit()
return updated_job
await services.transcriptions.update_job_transcription(
job_id=job.id,
text=result.text,
error_detail=None,
provider=result.provider,
model=result.model,
prompt_name=result.prompt_name,
session=session,
)
updated_job = await services.jobs.mark_job_status(
job.id,
JobStatus.TRANSCRIBED,
session=session,
)
await session.commit()
return updated_job
async def _finalize_retry(
*,
job: Job,
services: ServiceBundle,
error: AppError,
settings: Settings,
session: AsyncSession | None = None,
) -> Job:
"""Transaction C: job error detail + QUEUED + retry increment in one commit."""
if session is None:
async with services.jobs._session_scope() as local_session:
await services.transcriptions.update_job_transcription(
job_id=job.id,
text=None,
error_detail=format_error_detail(error),
prompt_name=DEFAULT_PROMPT_FILE,
session=local_session,
)
updated_job = await services.jobs.update_job_state(
job_id=job.id,
status=JobStatus.QUEUED,
retry_count_increment=1,
session=local_session,
)
await local_session.commit()
else:
await services.transcriptions.update_job_transcription(
job_id=job.id,
text=None,
error_detail=format_error_detail(error),
prompt_name=DEFAULT_PROMPT_FILE,
session=session,
)
updated_job = await services.jobs.update_job_state(
job_id=job.id,
status=JobStatus.QUEUED,
retry_count_increment=1,
session=session,
)
await session.commit()
if settings.worker_retry_backoff_seconds > 0:
await asyncio.sleep(settings.worker_retry_backoff_seconds)
return updated_job
async def _finalize_failed(
*,
job: Job,
services: ServiceBundle,
error: AppError,
session: AsyncSession | None = None,
) -> Job:
"""Transaction B: job error detail + FAILED in one commit."""
if session is None:
async with services.jobs._session_scope() as local_session:
await services.transcriptions.update_job_transcription(
job_id=job.id,
text=None,
error_detail=format_error_detail(error),
prompt_name=DEFAULT_PROMPT_FILE,
session=local_session,
)
updated_job = await services.jobs.mark_job_status(
job.id,
JobStatus.FAILED,
session=local_session,
)
await local_session.commit()
return updated_job
await services.transcriptions.update_job_transcription(
job_id=job.id,
text=None,
error_detail=format_error_detail(error),
prompt_name=DEFAULT_PROMPT_FILE,
session=session,
)
updated_job = await services.jobs.mark_job_status(
job.id,
JobStatus.FAILED,
session=session,
)
await session.commit()
return updated_job
def _resolve_primary_source(job: Job) -> Source | None:
if not job.job_sources:
return None
return next((job_source.source for job_source in job.job_sources if job_source.source is not None), None)
def _validate_transcription_quality(*, result: TranscriptionResult, settings: Settings) -> None:
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())
-14
View File
@@ -1,14 +0,0 @@
"""UI page registration exports."""
from fastapi import FastAPI
from nicegui import ui
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
from transcription.ui.pages.upload_page import register_page as register_upload_page
def register_pages(app: FastAPI) -> None:
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
register_upload_page()
register_jobs_page()
ui.run_with(app, mount_path="/ui", show_welcome_message=False)
@@ -0,0 +1,19 @@
"""Reusable UI component exports."""
from transcription.ui.components.app_shell import NAV_ITEMS
from transcription.ui.components.app_shell import render_app_shell
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
__all__ = [
"NAV_ITEMS",
"destructive_button",
"render_app_shell",
"render_document_panzoom",
"render_empty_state",
"render_navigation_header",
"section_header_row",
]
@@ -0,0 +1,71 @@
"""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], ...] = (
("Documents", "/documents", "description"),
("People", "/people", "group"),
("Sources", "/sources", "folder"),
("Jobs", "/jobs", "work_history"),
)
from transcription.ui.theme import VIBESCRIBE_LOGO_SVG
def _is_active_path(*, current_path: str, item_path: str) -> bool:
if item_path == "/jobs":
return current_path == "/jobs" or current_path.startswith("/jobs/")
if item_path == "/documents":
return current_path == "/documents" or current_path.startswith("/documents/")
if item_path == "/people":
return current_path == "/people" or current_path.startswith("/people/")
if item_path == "/sources":
return current_path == "/sources" or current_path.startswith("/sources/")
return current_path == item_path
def _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 "/jobs"
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.html(VIBESCRIBE_LOGO_SVG).classes("app-shell__brand-mark")
ui.label("VibeScribe").classes("app-shell__brand-name")
with ui.element("nav").props('aria-label="Primary navigation"').classes("app-shell__nav"):
for label, path, icon in NAV_ITEMS:
_render_nav_button(label=label, path=path, icon=icon, current_path=normalized_path)
with ui.row().classes("app-shell__actions no-wrap"):
ui.label("Saved").classes("app-shell__save-state")
ui.button(icon="more_horiz").props("flat round dense").tooltip("More actions")
def render_navigation_header(*, current_path: str | None = None) -> None:
"""Render the app shell using the legacy page-level entry point."""
render_app_shell(current_path=current_path)

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