generated from john/python-template
Compare commits
138
Commits
going-back
...
86b8e83ff4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86b8e83ff4 | ||
|
|
efe7785392 | ||
|
|
94db493756 | ||
|
|
0d554c0648 | ||
|
|
63c21d4a14 | ||
|
|
cf49c3c127 | ||
|
|
bf2f3ac09c | ||
|
|
eaeb0bc806 | ||
|
|
8b08478c9d | ||
|
|
450d33d507 | ||
|
|
796216087c | ||
|
|
afd1dba4d4 | ||
|
|
7daa0b9808 | ||
|
|
7c4300f9c2 | ||
|
|
443a1e29c8 | ||
|
|
cdd846fe29 | ||
|
|
30fcef3892 | ||
|
|
de8cdb6e1a | ||
|
|
b6a5a89a84 | ||
|
|
c261fbb3bd | ||
|
|
5404224079 | ||
|
|
2c26177d0c | ||
|
|
edcfba9cb2 | ||
|
|
fca959fa5d | ||
|
|
110f40a28b | ||
|
|
7dd0d2c9bf | ||
|
|
11097b9cfe | ||
|
|
7285a87dfb | ||
|
|
f86c0ff27b | ||
|
|
246d7f9434 | ||
|
|
22d47574f2 | ||
|
|
4488280097 | ||
|
|
012dc15042 | ||
|
|
66e2dce465 | ||
|
|
597be2691c | ||
|
|
4e8c562f92 | ||
|
|
0b63b53f53 | ||
|
|
6a3ee26733 | ||
|
|
97b3d0fd62 | ||
|
|
7b9715b3f1 | ||
|
|
3e418a0889 | ||
|
|
2ccea77520 | ||
|
|
b3d8eb6e97 | ||
|
|
1ee9ebbffc | ||
|
|
aec89b3a7a | ||
|
|
d1321fd709 | ||
|
|
7054cd8af9 | ||
|
|
bdb1b31b0a | ||
|
|
5b97c759fe | ||
|
|
7db4df1729 | ||
|
|
63373bf24d | ||
|
|
936af9b1d3 | ||
|
|
a78b58ff40 | ||
|
|
aed827babe | ||
|
|
178347e086 | ||
|
|
c9f5dca064 | ||
|
|
6bd4cbb0a7 | ||
|
|
28811d79ce | ||
|
|
171132919d | ||
|
|
89cf69f8a2 | ||
|
|
1e8d8572d4 | ||
|
|
888a8c380a | ||
|
|
b8be27f0c9 | ||
|
|
8d5aec4301 | ||
|
|
0ace10269f | ||
|
|
ccf2c78ff4 | ||
|
|
4b3baf5a3e | ||
|
|
9b4d6f0340 | ||
|
|
5753eb0135 | ||
|
|
e6549277c6 | ||
|
|
b59d3da23e | ||
|
|
4bf6c9e2f3 | ||
|
|
4dac9349c1 | ||
|
|
5a741de0a9 | ||
|
|
58faa00d7b | ||
|
|
89cac3c378 | ||
|
|
4b5e7ac23e | ||
|
|
21a7e83563 | ||
|
|
fce7107863 | ||
|
|
5090e238ff | ||
|
|
75f263c2b6 | ||
|
|
be152a028e | ||
|
|
9219adaf0c | ||
|
|
fd3ca60008 | ||
|
|
72bc96ab3a | ||
|
|
4eeb552273 | ||
|
|
d9f5fbb1a4 | ||
|
|
271633d1d5 | ||
|
|
6c6589d8ff | ||
|
|
759d4c2434 | ||
|
|
323f12d911 | ||
|
|
f80834d589 | ||
|
|
752346025b | ||
|
|
4f6e1fd913 | ||
|
|
47aef0e26e | ||
|
|
c098013a68 | ||
|
|
49e2e48df1 | ||
|
|
0ab7ad50f2 | ||
|
|
9653060c2a | ||
|
|
ed6f9dfe25 | ||
|
|
5946867ff3 | ||
|
|
646a360aca | ||
|
|
2b3d33e50e | ||
|
|
dfe6f121ff | ||
|
|
51ac2d0b98 | ||
|
|
61cc8a200b | ||
|
|
c46d1bd0bc | ||
|
|
00ed176ac1 | ||
|
|
99a128e981 | ||
|
|
aa94f34de4 | ||
|
|
661e2b1bec | ||
|
|
d75083a666 | ||
|
|
d0a3ca0289 | ||
|
|
209c48987c | ||
|
|
4ed1f43eda | ||
|
|
ce8fcce6b0 | ||
|
|
4ae8e5be4f | ||
|
|
6b5b0500b3 | ||
|
|
bbf7fe28c2 | ||
|
|
c4d25c1be8 | ||
|
|
1fa5eb1127 | ||
|
|
3eefc36239 | ||
|
|
ec6617a1c4 | ||
|
|
9eb0f40c08 | ||
|
|
f769d29da1 | ||
|
|
8afc462a6d | ||
|
|
3d6daec561 | ||
|
|
1cc2f319d5 | ||
|
|
d2b793ea69 | ||
|
|
a975ca299a | ||
|
|
a3b3bab571 | ||
|
|
bc21a97019 | ||
|
|
0973311d9f | ||
|
|
eaf9805121 | ||
|
|
ec61013b47 | ||
|
|
97cb7055d4 | ||
|
|
f975e25093 | ||
|
|
90ba8fefdd |
+62
-8
@@ -1,8 +1,62 @@
|
|||||||
PROVIDER=openrouter
|
# Canonical settings mirror for src/transcription/config.py (Settings).
|
||||||
OPENROUTER_API_KEY=sk-or-...
|
# Any value here overrides the in-code default.
|
||||||
# PROVIDER_MODEL= # optional: OpenRouter adapter supplies default
|
|
||||||
# OPENROUTER_HTTP_REFERER=https://example.com
|
# --- NiceGUI Server ---
|
||||||
# OPENROUTER_APP_TITLE=Historical Transcription MVP
|
HOST=0.0.0.0
|
||||||
# DATABASE_URL=sqlite:///./transcription.db
|
PORT=8000
|
||||||
# UPLOAD_DIR=./uploads
|
# LOG_LEVEL: critical | error | warning | info | debug | trace
|
||||||
# PROMPT_DIR=./prompts
|
LOG_LEVEL=info
|
||||||
|
RELOAD=false
|
||||||
|
LOG_DIR=./data/logs
|
||||||
|
LOG_FILE_NAME=transcription.log
|
||||||
|
LOG_FILE_MAX_BYTES=10485760
|
||||||
|
LOG_FILE_BACKUP_COUNT=5
|
||||||
|
|
||||||
|
# --- AI provider ---
|
||||||
|
# PROVIDER: openrouter
|
||||||
|
PROVIDER=openrouter
|
||||||
|
# Required.
|
||||||
|
OPENROUTER_API_KEY=your-api-key-goes-here
|
||||||
|
PROVIDER_MODEL=google/gemini-2.5-flash
|
||||||
|
# PROVIDER_MODELS default: derived from PROVIDER_MODEL when omitted.
|
||||||
|
# If provided, use a non-empty JSON array.
|
||||||
|
# PROVIDER_MODELS=["google/gemini-2.5-flash","anthropic/claude-sonnet-4"]
|
||||||
|
# OPENROUTER_HTTP_REFERER=
|
||||||
|
# OPENROUTER_APP_TITLE=
|
||||||
|
DEFAULT_PROMPT_NAME=transcribe_document.md
|
||||||
|
# TRANSCRIPTION_TEMPERATURE default: unset (optional range 0.0..2.0)
|
||||||
|
# TRANSCRIPTION_TEMPERATURE=
|
||||||
|
# TRANSCRIPTION_TOP_P default: unset (optional range 0.0..1.0)
|
||||||
|
# TRANSCRIPTION_TOP_P=
|
||||||
|
|
||||||
|
# --- runtime environment ---
|
||||||
|
# ENVIRONMENT: development | test | production
|
||||||
|
ENVIRONMENT=development
|
||||||
|
# TRANSCRIPTION_COMMIT default: unset (optional build/commit identifier for provenance evidence)
|
||||||
|
# TRANSCRIPTION_COMMIT=
|
||||||
|
|
||||||
|
# --- persistence ---
|
||||||
|
# Use nested keys (env_nested_delimiter="__").
|
||||||
|
DATABASE__DRIVER=sqlite
|
||||||
|
DATABASE__PATH=./data/transcription.db
|
||||||
|
# Postgres example:
|
||||||
|
# DATABASE__DRIVER=postgres
|
||||||
|
# DATABASE__HOST=localhost
|
||||||
|
# DATABASE__PORT=5432
|
||||||
|
# DATABASE__DATABASE=transcription
|
||||||
|
# DATABASE__USER=postgres
|
||||||
|
# DATABASE__PASSWORD=change-me
|
||||||
|
BOOTSTRAP_SCHEMA_ON_STARTUP=false
|
||||||
|
SQLITE_CHECK_SAME_THREAD=false
|
||||||
|
|
||||||
|
# --- filesystem paths ---
|
||||||
|
UPLOAD_DIR=./data
|
||||||
|
PROMPT_DIR=./prompts
|
||||||
|
DATABASE_BACKUP_DIR=./data/backups
|
||||||
|
|
||||||
|
# --- worker reliability ---
|
||||||
|
WORKER_MAX_RETRIES=0
|
||||||
|
WORKER_PROVIDER_TIMEOUT_SECONDS=30.0
|
||||||
|
WORKER_MIN_TRANSCRIPTION_CHARS=0
|
||||||
|
WORKER_MIN_TRANSCRIPTION_LINES=0
|
||||||
|
WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: Python Architect Reviewer
|
||||||
|
description: Evidence-based senior architect reviewer for FastAPI, NiceGUI, and SQLModel codebases.
|
||||||
|
tools:
|
||||||
|
- read_file
|
||||||
|
- list_dir
|
||||||
|
- file_search
|
||||||
|
- grep_search
|
||||||
|
- run_in_terminal
|
||||||
|
skills:
|
||||||
|
- python-code-reviewer
|
||||||
|
---
|
||||||
|
|
||||||
|
# Python Architect Reviewer
|
||||||
|
|
||||||
|
You are a Senior Python Architect performing an evidence-based, read-only code review.
|
||||||
|
|
||||||
|
## Operating Principles
|
||||||
|
|
||||||
|
- **Stack Context:** Python 3.12+, FastAPI, NiceGUI, SQLModel, SQLAlchemy (SQLite/PostgreSQL), Pydantic V2, asyncio workers, and OpenRouter adapters.
|
||||||
|
- **Evidence-Based:** Always inspect real files. Every finding must reference concrete file paths and line numbers (e.g., `app/services/worker.py:45-78`). Do not speculate.
|
||||||
|
- **Tool Verification:** Run linters and tests via the terminal (`ruff check`, `pytest`, `ty`) to verify issues before reporting.
|
||||||
|
- **Skill Execution:** Adhere strictly to the review dimensions, duplication analysis, and report scaffolding defined in the `python-code-reviewer` skill.
|
||||||
|
- **Report Target:** Output all complete review reports as Markdown files written to `./docs`.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
description: Require documentation updates whenever code changes alter contracts, behavior, or scope.
|
||||||
|
applyTo: 'src/transcription/**/*.py'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Documentation Sync Requirements
|
||||||
|
|
||||||
|
Keep docs in sync in the same change whenever implementation alters a documented contract, behavior, or roadmap decision.
|
||||||
|
|
||||||
|
## Update documentation when any of these change
|
||||||
|
|
||||||
|
1. **Schema/Data contract**
|
||||||
|
- Models, fields, enums, constraints, indexes, relationships, loading semantics.
|
||||||
|
- **Required doc update:** `docs/schema.md`.
|
||||||
|
|
||||||
|
2. **Configuration contract**
|
||||||
|
- `Settings` keys, defaults, required/optional environment values.
|
||||||
|
- **Required doc update:** `.env.example` and any directly related setup docs.
|
||||||
|
|
||||||
|
3. **User-visible UI behavior**
|
||||||
|
- Page flow, routes, button/action behavior, labels, status wording, empty/error states.
|
||||||
|
- **Required doc update:** relevant `docs/ui/pages/*.md` docs and feature docs when applicable.
|
||||||
|
|
||||||
|
4. **Error handling semantics**
|
||||||
|
- Error categories, retry behavior, envelope structure, translation boundaries.
|
||||||
|
- **Required doc update:** `docs/error_handling.md` and `docs/invariant/error_handling.md`.
|
||||||
|
|
||||||
|
5. **Roadmap/scope decisions**
|
||||||
|
- Version targets, sequencing, deferrals, and accepted alternatives.
|
||||||
|
- **Required doc update:** `docs/roadmap_plan.md` and related backlog docs (for example `docs/ver4.8/feature_backlog_v4_8.md`).
|
||||||
|
|
||||||
|
## Working rule
|
||||||
|
|
||||||
|
If none of the categories above changed, documentation edits are optional.
|
||||||
|
If any category changed, update docs in the same PR/change set rather than deferring.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
description: Cross-cutting error handling rules for services, API, and UI.
|
||||||
|
applyTo: 'src/transcription/**/*.py'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Error Handling (Cross-cutting)
|
||||||
|
|
||||||
|
Primary references:
|
||||||
|
|
||||||
|
- `docs/error_handling.md`
|
||||||
|
- `docs/invariant/error_handling.md`
|
||||||
|
- `docs/requirements.md`
|
||||||
|
|
||||||
|
## Taxonomy and Categories
|
||||||
|
|
||||||
|
Use category-driven semantics aligned to canonical policy:
|
||||||
|
|
||||||
|
- `validation`
|
||||||
|
- `not_found`
|
||||||
|
- `conflict`
|
||||||
|
- `external`
|
||||||
|
- `timeout`
|
||||||
|
- `internal`
|
||||||
|
|
||||||
|
Do not invent ad hoc categories in user/API-facing envelopes unless canonical docs are updated.
|
||||||
|
|
||||||
|
Runtime/internal categories may be more specific for diagnostics and persistence, but they must map
|
||||||
|
deterministically to the canonical envelope categories through the centralized mapper in
|
||||||
|
`transcription.errors.canonical_error_category`.
|
||||||
|
|
||||||
|
Current internal categories:
|
||||||
|
|
||||||
|
- `validation_error`
|
||||||
|
- `user_input_error`
|
||||||
|
- `not_found_error`
|
||||||
|
- `conflict_error`
|
||||||
|
- `external_provider_error`
|
||||||
|
- `external_timeout_error`
|
||||||
|
- `processing_error`
|
||||||
|
- `infrastructure_transient_error`
|
||||||
|
- `infrastructure_persistent_error`
|
||||||
|
- `internal_unexpected_error`
|
||||||
|
|
||||||
|
Required internal -> canonical mapping:
|
||||||
|
|
||||||
|
- `validation_error`, `user_input_error` -> `validation`
|
||||||
|
- `not_found_error` -> `not_found`
|
||||||
|
- `conflict_error` -> `conflict`
|
||||||
|
- `external_provider_error` -> `external`
|
||||||
|
- `external_timeout_error`, `infrastructure_transient_error` -> `timeout`
|
||||||
|
- `processing_error`, `infrastructure_persistent_error`, `internal_unexpected_error` -> `internal`
|
||||||
|
|
||||||
|
## Translation Boundaries
|
||||||
|
|
||||||
|
- **Provider/adapters:** raise provider/domain exceptions; do not emit UI text.
|
||||||
|
- **Services:** map raw exceptions into internal categories and preserve causal chain (`raise ... from ...`).
|
||||||
|
- **UI/API:** map internal category -> canonical envelope category and emit user-safe, actionable messages.
|
||||||
|
|
||||||
|
## Retry Rules
|
||||||
|
|
||||||
|
- No auto-retry for `validation`, `not_found`, `conflict`.
|
||||||
|
- `external`/`timeout` may be retried when operation semantics are safe.
|
||||||
|
- Preserve each retry as new evidence where applicable (no history rewrite).
|
||||||
|
|
||||||
|
## Job/Page Failure Semantics
|
||||||
|
|
||||||
|
- Page-level (`JobSource`): `pending`, `transcribed`, `failed`, `cancelled`.
|
||||||
|
- Job terminals: `transcribed`, `partial_success`, `failed`.
|
||||||
|
- Cancellation must keep job-level and page-level semantics explicit and consistent.
|
||||||
|
- Do not emit legacy terminal state language such as `completed` in active user/API lifecycle contracts.
|
||||||
|
|
||||||
|
## User-Safe Messaging
|
||||||
|
|
||||||
|
- Never leak stack traces, credentials, auth headers, or local filesystem paths in user-facing output.
|
||||||
|
- Include actionable remediation guidance aligned to category.
|
||||||
|
- Keep envelope structure consistent across API endpoints.
|
||||||
|
|
||||||
|
## Logging and Diagnostics
|
||||||
|
|
||||||
|
- Log operation identifiers and error IDs where available.
|
||||||
|
- Preserve category + cause-chain context.
|
||||||
|
- Distinguish no-response timeout/network failures from returned provider error responses.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- No broad catch-and-swallow patterns.
|
||||||
|
- No success-shaped fallback values after exceptions.
|
||||||
|
- Category mapping must remain deterministic and testable.
|
||||||
|
|
||||||
|
## Contract Sync Rule
|
||||||
|
|
||||||
|
If taxonomy, retries, or envelope semantics change:
|
||||||
|
|
||||||
|
1. Update canonical docs (`docs/error_handling.md`, and invariant docs if needed).
|
||||||
|
2. Update tests in the same change.
|
||||||
|
3. Update related instruction/skill references.
|
||||||
|
4. If change affects persisted status/category fields, update `docs/schema.md` when applicable.
|
||||||
@@ -7,29 +7,94 @@ applyTo: 'src/transcription/services/*.py'
|
|||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
- Project core data models defined in [models](../../src/transcription/models.py)
|
- Project core data models are defined in [models](../../src/transcription/db/models.py)
|
||||||
- 1 service class per data model
|
- One service class per **aggregate**, not per table. An aggregate is a root model plus
|
||||||
- Only services directly interact with the database, and only through async methods
|
the models that have no independent lifecycle of their own. `DocumentType` has no
|
||||||
- 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.
|
meaning without `Document`, so it belongs to `DocumentService`; it does not get its
|
||||||
|
own service. Splitting per table produces services that must reach across each other
|
||||||
|
for every real operation, which is what line 13 forbids.
|
||||||
|
- Only services interact with the database, and only through async methods.
|
||||||
|
- **A service module must not import another service module.** This is enforced by
|
||||||
|
[test_service_boundaries](../../tests/test_service_boundaries.py). Shared types go in a
|
||||||
|
neutral module that defines no service class (see [errors](../../src/transcription/services/errors.py)).
|
||||||
|
- Not every module in this package is a service. Helper modules that define no `*Service`
|
||||||
|
class (`base`, `errors`, `normalization`, `prompts`, `quality`, `media_storage`,
|
||||||
|
`source_media`) are free-function modules and are exempt from the service rules below.
|
||||||
|
- Cross-cutting error behavior must follow
|
||||||
|
[error-handling instructions](./error-handling.instructions.md).
|
||||||
|
|
||||||
|
## Model Ownership
|
||||||
|
|
||||||
|
Every model has exactly one owning service. The owner defines that model's invariants and
|
||||||
|
is the only service that may **create or delete** its rows.
|
||||||
|
|
||||||
|
| Model | Owner |
|
||||||
|
| --- | --- |
|
||||||
|
| `Document`, `DocumentType` | `DocumentService` |
|
||||||
|
| `Source`, `JobSource` | `SourceService` |
|
||||||
|
| `Job` | `JobService` |
|
||||||
|
| `Person`, `PersonRole`, `DocumentPerson` | `PeopleService` |
|
||||||
|
| `ExecutionAttempt` | `SourceService` |
|
||||||
|
|
||||||
|
### Junction tables
|
||||||
|
|
||||||
|
A junction table is owned by the service that **creates and deletes its rows** — its
|
||||||
|
lifecycle owner. The service on the other side may read through the junction (via
|
||||||
|
`selectinload`) but must not create rows in it.
|
||||||
|
|
||||||
|
- `document_person` -> `PeopleService`. Every write is there; `DocumentService` only
|
||||||
|
eager-loads through it.
|
||||||
|
- `job_source` -> `SourceService`, which creates the row, records each page's outcome,
|
||||||
|
and deletes it.
|
||||||
|
|
||||||
|
Two consequences follow, and both are deliberate:
|
||||||
|
|
||||||
|
- **Cascade deletion is not a violation.** A service deleting the aggregate root it owns
|
||||||
|
may delete junction rows referencing that root, because they cannot outlive it
|
||||||
|
(`JobService.delete_job_with_guardrails`).
|
||||||
|
- **Ownership governs creation and deletion, not every state transition.** `job_source` is
|
||||||
|
both a link and the transcription work queue. `JobService.cancel_job` and
|
||||||
|
`resubmit_failed_sources` transition `job_source.status` across a whole job, because that
|
||||||
|
transition is a Job lifecycle event, not a per-page outcome. They create and delete
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
`EvidenceService` is read-focused and projection-focused. It may coordinate selection
|
||||||
|
flows, but append-only attempt creation remains in `SourceService` write paths.
|
||||||
|
|
||||||
|
If a new operation cannot be expressed within one owner, it belongs in an orchestration
|
||||||
|
module, not in a cross-service import.
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
- Service-specific errors defined at the top of the respective module and inherit from `AppError`
|
- Errors used by a single service are defined at the top of that module and inherit from `AppError`.
|
||||||
- Use a context manager for large `try/except` blocks like in [transcription](../../src/transcription/services/transcription.py)
|
- Errors shared by more than one service go in [errors](../../src/transcription/services/errors.py),
|
||||||
|
which defines no service class and is therefore importable by any of them.
|
||||||
|
- Use a context manager for large `try/except` blocks, like `handle_transcription_errors` in
|
||||||
|
[sources](../../src/transcription/services/sources.py).
|
||||||
|
- Category mapping, retry behavior, and translation boundaries are defined in
|
||||||
|
[error-handling instructions](./error-handling.instructions.md).
|
||||||
|
- Service-edge exception translation must be deterministic: map to canonical categories and preserve clear provider->service->API/UI boundaries.
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] Uses `ServiceBase` for common logic
|
- [ ] Uses `ServiceBase` for common logic
|
||||||
- [ ] CRUD methods created at the top
|
- [ ] Session kwarg for `AsyncSession` to pass a session object into each method
|
||||||
- [ ] 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 through
|
||||||
- [ ] 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
|
||||||
- Multiple operations on the same object(s) require sharing a session between all the methods used.
|
- [ ] Every model the module touches is either owned by it or reached read-only
|
||||||
|
- [ ] Evidence writes preserve append-only semantics
|
||||||
|
|
||||||
## CRUD Methods
|
## CRUD Methods
|
||||||
|
|
||||||
- Create, read, update, and delete, created in that order
|
- Name format `<operation>_<model>`, for example `create_document` or `update_job`.
|
||||||
- Name format `<operation>_<model >`, for example `create_document` or `update_job`
|
- Where a service exposes create/read/update/delete for its root model, define them at the
|
||||||
- All services must define these 4 methods first, and in that order
|
top of the class in that order, before derived reads and workflow helpers.
|
||||||
|
- Not every aggregate needs all four. `ExecutionAttempt` is append-only evidence written by
|
||||||
|
`SourceService` workflow-facing methods, so `EvidenceService` deliberately exposes reads and
|
||||||
|
no create or delete.
|
||||||
|
Do not add unused CRUD methods to satisfy symmetry.
|
||||||
|
- `RegistryService` is generic across small lookup models and uses `<operation>_entry`
|
||||||
|
naming instead.
|
||||||
|
|
||||||
## Transaction Finalization
|
## Transaction Finalization
|
||||||
|
|
||||||
@@ -39,18 +104,9 @@ When a service method accepts an optional `session` kwarg, write methods must us
|
|||||||
- If `session` is provided: the method must **not** commit; it should `flush()` so IDs and FK values are available to the caller's transaction.
|
- 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).
|
- 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
|
## Workflow Transaction Boundaries
|
||||||
|
|
||||||
For multi-step job lifecycles (for example queued transcription jobs), orchestration functions must use explicit transaction phases.
|
For multi-step job lifecycles, orchestration functions must use explicit transaction phases.
|
||||||
|
|
||||||
Required boundary model:
|
|
||||||
|
|
||||||
- **Transaction A (claim):** transition `JobStatus.QUEUED -> JobStatus.PROCESSING` and commit immediately.
|
- **Transaction A (claim):** transition `JobStatus.QUEUED -> JobStatus.PROCESSING` and commit immediately.
|
||||||
- Perform provider/network work **outside** database transactions.
|
- Perform provider/network work **outside** database transactions.
|
||||||
@@ -64,14 +120,32 @@ Atomicity rules:
|
|||||||
- Terminal state (`TRANSCRIBED` or `FAILED`) and transcript row changes must succeed or roll back together.
|
- 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.
|
- Retry persistence (`QUEUED` + retry increment + error detail) must succeed or roll back together.
|
||||||
|
|
||||||
Separation of concerns:
|
## Contract Alignment
|
||||||
|
|
||||||
- Worker modules should stay lightweight and delegate lifecycle transitions to service/workflow orchestration functions.
|
- Treat `docs/` as the active architecture and requirements baseline.
|
||||||
- In `workflows.py`, `process_queued_job` should own one complete attempt lifecycle: `QUEUED -> PROCESSING -> TRANSCRIBED|FAILED`.
|
- Legacy revision trees are out of scope for active implementation decisions and must not be referenced as authoritative service guidance.
|
||||||
- In `workflows.py`, `advance_job` should coordinate broader status progression around attempts (for example retry scheduling from `FAILED -> QUEUED`).
|
- Treat `src/transcription/db/models.py` as runtime schema ground truth and `docs/schema.md` as the field-accurate contract mirror.
|
||||||
- Services should expose session-aware write helpers (flush on caller-owned session) so orchestration controls commit boundaries.
|
- `Job.status` success path is `TRANSCRIBED`.
|
||||||
- Backoff/sleep behavior must run outside transactional scopes.
|
- `JobSource.status` is queue/projection state only (`PENDING`, `TRANSCRIBED`, `FAILED`, `CANCELLED`).
|
||||||
|
- Source ingest may normalize media before persistence; persisted bytes/hash are canonical for processing and provenance.
|
||||||
|
- `ExecutionAttempt` is append-only evidence history; do not mutate historical attempt rows in runtime code.
|
||||||
|
- `Source.raw_transcription` is a projection, not authoritative history.
|
||||||
|
- Service/UI read paths that touch relationships must be eager-loaded for `lazy="raise"` compatibility.
|
||||||
|
- If model fields, enums, constraints, indexes, or relationship-loading semantics change, update `docs/schema.md` in the same change.
|
||||||
|
- If `Settings` fields or defaults change in `src/transcription/config.py`, update `.env.example` in the same change so keys/defaults remain synchronized and no stale settings remain documented.
|
||||||
|
|
||||||
|
## Schema Drift and Legacy Compatibility Policy
|
||||||
|
|
||||||
|
- Prefer schema migration or startup reconciliation over runtime compatibility paths in service writes.
|
||||||
|
- Do not add legacy read/write compatibility code in service workflows by default.
|
||||||
|
- If drift is discovered and a migration decision is ambiguous (for example, one-way destructive DDL, uncertain data retention impact, or unknown deployment sequence), pause and ask the user to choose migration vs compatibility before coding.
|
||||||
|
- If a temporary compatibility path is explicitly approved, document an expiration/removal plan in the same change.
|
||||||
|
|
||||||
# Service Composition
|
# 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.
|
A service method may read across models it does not own, using eager loads from its own
|
||||||
|
aggregate root. What it may not do is import another service.
|
||||||
|
|
||||||
|
Operations that must **write** models owned by more than one service are composed in an orchestration module
|
||||||
|
([store](../../src/transcription/services/store.py),
|
||||||
|
[workflows](../../src/transcription/services/workflows.py)).
|
||||||
|
|||||||
@@ -1,6 +1,78 @@
|
|||||||
---
|
---
|
||||||
description: Copilot rules for modifying the UI
|
description: "Use when modifying the NiceGUI application under src/transcription/ui. Defines ownership and dependency boundaries for UI registration, pages, components, services, persistence, state, and static assets."
|
||||||
applyTo: 'src/transcription/ui/**/*.py'
|
applyTo: 'src/transcription/ui/**/*.py'
|
||||||
---
|
---
|
||||||
|
|
||||||
The UI is forbidden from directly touching the database. All interactions should be done using the [services](../../src/transcription/services/)
|
# UI Conceptual Boundaries
|
||||||
|
|
||||||
|
Keep dependencies flowing in this direction:
|
||||||
|
|
||||||
|
`ui/__init__.py` -> `pages` -> `components`
|
||||||
|
|
||||||
|
Pages may depend on application services and framework-provided dependencies. Components may depend on smaller components and shared presentation helpers. Services and domain modules must never depend on the UI.
|
||||||
|
|
||||||
|
Cross-cutting error behavior must follow
|
||||||
|
[error-handling instructions](./error-handling.instructions.md).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- Use `components/media_urls.py` for media URL generation; do not hand-build upload/static paths in page code.
|
||||||
|
|
||||||
|
## CSS Assets
|
||||||
|
|
||||||
|
- Keep all application CSS in `ui/static/theme.css`; do not add page- or component-specific stylesheets or embed style blocks in Python components.
|
||||||
|
- Load `theme.css` once from the composition root with `ui.add_css(..., shared=True)`.
|
||||||
|
- Read stylesheet text through `importlib.resources.files(...)` so loading works from installed packages and is independent of the working directory.
|
||||||
|
- Centralize CSS reading in one typed helper cached by resource path.
|
||||||
|
- 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.
|
||||||
|
- Keep filesystem, network, provider, and worker orchestration behind application services or dedicated adapters.
|
||||||
|
|
||||||
|
## Media Route Safety Rules
|
||||||
|
|
||||||
|
Two patterns are approved:
|
||||||
|
|
||||||
|
1. **Record-validated API routes** for print/export contexts.
|
||||||
|
2. **Controlled upload URL resolver** (`components/media_urls.py`) for general UI media.
|
||||||
|
|
||||||
|
Prohibited patterns:
|
||||||
|
|
||||||
|
- Direct `file://` links or exposing local filesystem paths.
|
||||||
|
- Manual URL construction from raw `Path` values in pages/components.
|
||||||
|
- User-facing payloads containing local absolute paths.
|
||||||
|
|
||||||
|
## Contract Alignment
|
||||||
|
|
||||||
|
- Treat `docs/` as the active baseline.
|
||||||
|
- Resolve lifecycle and status semantics against `src/transcription/db/models.py` and `docs/schema.md`; do not introduce alternate status labels or implied legacy states in UI behavior.
|
||||||
|
- Use status vocabulary exactly as modeled (`queued`, `processing`, `transcribed`, `partial_success`, `failed`; and `pending`, `transcribed`, `failed`, `cancelled`).
|
||||||
|
- Print/export media flows must use record-validated routes; direct local filesystem paths are prohibited.
|
||||||
|
- If lifecycle wording/behavior changes, update corresponding `docs/ui/pages/*.md` contracts in the same change.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: Review Python Architecture
|
||||||
|
description: Run an evidence-based architectural code review using the Python Architect Reviewer agent and python-code-reviewer skill.
|
||||||
|
agent: Python Architect Reviewer
|
||||||
|
---
|
||||||
|
|
||||||
|
# Instructions
|
||||||
|
|
||||||
|
Execute a comprehensive, evidence-based code review of the target codebase.
|
||||||
|
|
||||||
|
## Target Scope
|
||||||
|
- **Review Target:** ${{input:target_path:./}}
|
||||||
|
- **Source Root:** `src/`
|
||||||
|
- **Docs Root:** `docs/`
|
||||||
|
- **Focus Areas:** FastAPI endpoints, NiceGUI components, SQLModel persistence, asyncio workers, Pydantic V2 models, and OpenRouter provider adapters.
|
||||||
|
|
||||||
|
## Execution Rules
|
||||||
|
1. Map repository layout, dependency manifests, and configuration files from the project root before inspecting modules.
|
||||||
|
2. Read real code modules under `src/` (or the specified target path); cite exact file paths and line ranges for every finding.
|
||||||
|
3. Validate issues using terminal tools (`ruff check`, `pytest`, `ty`) where appropriate.
|
||||||
|
4. Check for duplication, divergent implementations, and extractable helpers.
|
||||||
|
5. Format the entire review following the standardized 6-section template defined in the `python-code-reviewer` skill.
|
||||||
|
6. Write the final report as a Markdown file to `./docs/code-review-${{current_date}}.md`.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
name: evidence-provenance-auditor
|
||||||
|
description: Deterministic reviewer for transcription evidence/provenance guarantees. Use when changes touch execution attempts, source storage, retries, transport evidence, artifact provenance, or evidence exports.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Evidence & Provenance Auditor
|
||||||
|
|
||||||
|
Perform focused, deterministic audits of evidence integrity and provenance behavior.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Reviewing changes in:
|
||||||
|
- `src/transcription/services/sources.py`
|
||||||
|
- `src/transcription/services/store.py`
|
||||||
|
- `src/transcription/services/workflows.py`
|
||||||
|
- `src/transcription/services/evidence.py`
|
||||||
|
- `src/transcription/db/models.py`
|
||||||
|
- Auditing evidence exports/imports or evidence-display behavior.
|
||||||
|
- Verifying no drift from canonical provenance invariants.
|
||||||
|
|
||||||
|
## Normative References (must be used)
|
||||||
|
|
||||||
|
1. `docs/invariant/ai_evidence_and_provenance.md`
|
||||||
|
2. `docs/schema.md`
|
||||||
|
3. `docs/requirements.md`
|
||||||
|
4. `docs/error_handling.md`
|
||||||
|
|
||||||
|
## Deterministic Pass/Fail Checks
|
||||||
|
|
||||||
|
### A. Append-only history
|
||||||
|
- Every provider call results in a new `ExecutionAttempt`.
|
||||||
|
- Runtime paths do not mutate historical attempts to represent new outcomes.
|
||||||
|
- Retry behavior appends attempts rather than rewriting prior rows.
|
||||||
|
|
||||||
|
### B. Projection vs authority separation
|
||||||
|
- `Source.raw_transcription` and preferred pointers are mutable projection surfaces.
|
||||||
|
- Attempt rows remain authoritative historical evidence.
|
||||||
|
- Candidate promotion updates projection pointers without rewriting history.
|
||||||
|
|
||||||
|
### C. Transport evidence semantics
|
||||||
|
- Transport evidence is correctly labeled as application-boundary capture.
|
||||||
|
- SDK snapshots/normalized metadata are not mislabeled as native upstream payload.
|
||||||
|
- No-response timeout/network states are explicit.
|
||||||
|
|
||||||
|
### D. Canonical source identity
|
||||||
|
- Canonical stored bytes/hash/size are internally consistent.
|
||||||
|
- If ingest normalization is applied, code/docs consistently represent resulting canonical identity.
|
||||||
|
- Post-ingest derivatives do not overwrite canonical source bytes.
|
||||||
|
|
||||||
|
### E. Secret safety
|
||||||
|
- No credentials/auth headers/cookies/unrestricted headers persisted.
|
||||||
|
- Header persistence uses explicit allowlist semantics.
|
||||||
|
|
||||||
|
### F. Route/path safety
|
||||||
|
- Print/export source access is record-validated.
|
||||||
|
- UI/media path construction does not expose local filesystem paths.
|
||||||
|
|
||||||
|
### G. Schema/docs alignment
|
||||||
|
- Evidence-related model fields and semantics align with canonical docs.
|
||||||
|
- Evidence model changes require same-change doc updates.
|
||||||
|
|
||||||
|
### H. Canonical authority boundaries
|
||||||
|
- Active guidance resolves against `docs/*` and current instruction files.
|
||||||
|
|
||||||
|
## Review Workflow
|
||||||
|
|
||||||
|
1. Read normative references first.
|
||||||
|
2. Inspect model + service + workflow write paths.
|
||||||
|
3. Inspect evidence read/display/export paths.
|
||||||
|
4. Report high-confidence findings with concrete path/line evidence.
|
||||||
|
5. Classify each finding by invariant family (A-H).
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Use this structure:
|
||||||
|
|
||||||
|
- Verdict by invariant family (A-H)
|
||||||
|
- Findings with `Location`, `Observed Behavior`, `Risk`, `Recommended Fix`
|
||||||
|
- Drift table (`Doc claim` vs `Code reality` vs `Action`)
|
||||||
|
- Regression guards needed
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
---
|
||||||
|
name: python-code-reviewer
|
||||||
|
description: Perform an evidence-based, senior architect code review for Python codebases using FastAPI, NiceGUI, SQLModel, SQLAlchemy, Pydantic V2, asyncio, and OpenRouter. Use when asked to review Python repositories, perform architectural or code audits, or evaluate code against Python 3.12+ best practices.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Python Code Reviewer
|
||||||
|
|
||||||
|
Perform thorough, evidence-based code reviews for Python projects. Every finding must cite concrete file paths and line ranges, avoid speculation, and include recommended fixes.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Performing an architectural or code quality review of a Python codebase.
|
||||||
|
- Auditing applications using FastAPI, NiceGUI, SQLModel/SQLAlchemy, Pydantic V2, or asyncio workers.
|
||||||
|
- Generating structured Markdown review reports in `./docs`.
|
||||||
|
|
||||||
|
## Technical Stack Scope
|
||||||
|
|
||||||
|
- **Runtime:** Python 3.12+
|
||||||
|
- **Web Application:** FastAPI and NiceGUI
|
||||||
|
- **Persistence:** SQLModel, SQLAlchemy (SQLite and PostgreSQL support)
|
||||||
|
- **Validation & Settings:** Pydantic V2 and pydantic-settings
|
||||||
|
- **Concurrency:** Python asyncio workers
|
||||||
|
- **Vision/LLM Integration:** OpenRouter / provider adapters
|
||||||
|
- **Quality & Testing:** pytest, pytest-asyncio, Ruff, and ty
|
||||||
|
|
||||||
|
## Review Workflow
|
||||||
|
|
||||||
|
1. **Map the Repository First:** Inspect entry points, package layout, configurations, dependency manifests, and any project-specific rule files (`AGENTS.md`, `CLAUDE.md`, `.github/instructions/`). Project-specific conventions override generic advice.
|
||||||
|
2. **Establish Canonical Authority First:** Read architecture/contracts (`docs/*`, `docs/invariant/*`, UI docs) and active instructions/skills before evaluating source behavior.
|
||||||
|
3. **Read Representative Modules:** Sample across all layers (routes/pages, UI components, services, workers, persistence, provider adapters, settings, tests) before drawing conclusions.
|
||||||
|
4. **Run Drift Analysis:** Compare documented intended behavior versus repository ground truth; identify both implementation drift and undocumented-but-repeatable conventions that should be formalized.
|
||||||
|
5. **Run Dead-Code/Orphan Sweep:** Identify candidate orphan modules/functions/classes with zero inbound references, then verify expected exceptions (entrypoints, framework/plugin registration, dynamic imports/reflection, CLI hooks, test-only utilities) before marking as orphaned.
|
||||||
|
6. **Assess Boundary and Coupling Health:** Evaluate UI/service/persistence/provider dependency flow, identify circular dependencies, leaky abstractions, and transaction ownership ambiguity.
|
||||||
|
7. **Assess Invariant Placement:** For each hard rule, decide whether it belongs in docs (rationale), instructions (active steering), skills (periodic audit procedure), or deterministic tests (enforcement).
|
||||||
|
8. **Verify Claims:** Run or reference project tooling (`ruff check`, `ty`, `pytest`) rather than guessing.
|
||||||
|
9. **Prioritize Hot Paths:** Focus deeply on request handling, database sessions, background workers, and external API calls.
|
||||||
|
10. **Enforce Read-Only Safety:** Do not modify code unless explicitly instructed.
|
||||||
|
11. **Escalate Provenance Audits:** For evidence/provenance-heavy changes, apply invariant checks from `.github/skills/evidence-provenance-auditor/skill.md` and include pass/fail outcomes in the report.
|
||||||
|
|
||||||
|
## Repo-Specific Deterministic Checks (Transcription)
|
||||||
|
|
||||||
|
When reviewing this repository, always include explicit pass/fail checks for:
|
||||||
|
|
||||||
|
1. **Service boundary rule:** no service-to-service imports (`tests/test_service_boundaries.py`).
|
||||||
|
2. **UI boundary rule:** pages/components do not perform persistence access (`tests/test_ui_boundaries.py`).
|
||||||
|
3. **Status vocabulary conformance:** `JobStatus`/`JobSourceStatus` usage matches current enums in `src/transcription/db/models.py`.
|
||||||
|
4. **Evidence ownership conformance:** append-only attempt history is preserved and projection writes are not mistaken for history mutation (`src/transcription/services/sources.py`, `src/transcription/services/evidence.py`).
|
||||||
|
5. **Canonical authority:** findings must resolve against `docs/*` first.
|
||||||
|
6. **Schema contract fidelity:** when model/persistence behavior changes, `docs/schema.md` remains field-accurate with `src/transcription/db/models.py`.
|
||||||
|
7. **Media boundary conformance:** print/export media is record-validated and UI media URL generation uses controlled resolver paths.
|
||||||
|
8. **Eager-loading conformance:** service/UI read paths satisfy `lazy="raise"` expectations.
|
||||||
|
9. **Cross-cutting error conformance:** service/API/UI translation and retry behavior align with `.github/instructions/error-handling.instructions.md`.
|
||||||
|
10. **Orphaned/dead-code conformance:** include a deterministic orphan sweep and report confirmed orphans removed/retained with rationale.
|
||||||
|
|
||||||
|
## Core Review Areas
|
||||||
|
|
||||||
|
### 1. Python Best Practices (3.12+)
|
||||||
|
- **Type Annotations:** Ensure completeness, modern syntax (`X | None`, builtin generics, `Self`, `type` statements), and avoid unparameterized containers or bare `Any`.
|
||||||
|
- **Error Handling:** Identify bare/broad `except`, swallowed exceptions, missing `raise ... from`, and exceptions used for control flow.
|
||||||
|
- **Resource Management:** Verify context managers for files, DB sessions, HTTP clients, and locks. Check for leaked tasks or connections.
|
||||||
|
- **Data Modeling:** Check proper use of dataclasses vs. Pydantic models vs. dictionaries. Eliminate mutable default arguments and stringly-typed payloads.
|
||||||
|
- **Idioms & Clean Code:** Verify `pathlib` usage over `os.path`, comprehensions vs manual loops, removal of dead code, and elimination of magic numbers.
|
||||||
|
|
||||||
|
### 2. FastAPI
|
||||||
|
- **Dependency Injection:** Verify `Depends` is used for shared resources (DB sessions, settings, clients) rather than global singletons.
|
||||||
|
- **Route Design:** Validate HTTP verbs, status codes, path/query/body typing, `response_model`, and domain-based router organization.
|
||||||
|
- **Lifecycle & Concurrency:** Ensure lifespan handlers are used instead of deprecated `@app.on_event`. Flag blocking synchronous calls in `async def` endpoints.
|
||||||
|
|
||||||
|
### 3. NiceGUI
|
||||||
|
- **Separation of Concerns:** Ensure UI components delegate business logic and persistence to service layers.
|
||||||
|
- **Client State Handling:** Verify correct use of client-scoped state vs global state to avoid state leaks across sessions.
|
||||||
|
- **Async Execution:** Check for blocking operations on the UI event loop and unbounded timers/pollers.
|
||||||
|
|
||||||
|
### 4. Persistence (SQLModel / SQLAlchemy)
|
||||||
|
- **Session Lifecycle:** Enforce one session per request/unit of work with explicit commit/rollback/close boundaries.
|
||||||
|
- **Query Optimization:** Detect N+1 patterns, missing eager loads (`selectinload`/`joinedload`), queries inside loops, and unindexed filters.
|
||||||
|
- **Cross-Dialect Portability:** Check compatibility for both SQLite (WAL mode, pragmas) and PostgreSQL (JSONB, locking, autoincrement).
|
||||||
|
|
||||||
|
### 5. Pydantic V2 & Settings
|
||||||
|
- **V2 Migration:** Flag legacy V1 patterns (`@validator`, `Config` class, `.dict()`, `parse_obj`) and use V2 equivalents (`@field_validator`, `model_config = ConfigDict(...)`, `model_dump()`).
|
||||||
|
- **Settings Management:** Ensure `BaseSettings` is the single source of truth without scattered `os.getenv` calls or committed secrets.
|
||||||
|
|
||||||
|
### 6. Concurrency & Asyncio Workers
|
||||||
|
- **Task Lifecycle:** Flag unreferenced `create_task` calls that risk garbage collection, missing cancellation handling, and lack of graceful shutdown.
|
||||||
|
- **Backpressure & Synchronization:** Check for appropriate use of `asyncio.Queue`, `TaskGroup`, `Lock`, and backoff retries.
|
||||||
|
|
||||||
|
### 7. Provider Adapters (OpenRouter / APIs)
|
||||||
|
- **Adapter Encapsulation:** Verify provider-specific details (headers, model names, payload formats) do not leak into UI or business logic.
|
||||||
|
- **Client Lifecycle:** Reuse shared `AsyncClient` instances with proper connection pooling and timeouts. Validate API responses using Pydantic schemas.
|
||||||
|
|
||||||
|
### 8. Testing & Quality Tooling
|
||||||
|
- **Test Isolation:** Verify tests do not rely on live external services, real clocks, or shared global state.
|
||||||
|
- **Async Test Setup:** Check `pytest-asyncio` configuration and fixture lifecycle.
|
||||||
|
|
||||||
|
### 9. Duplication & Consolidation
|
||||||
|
- Identify repeated code blocks, candidate helper abstractions, divergent patterns for identical operations, and duplicated domain constants.
|
||||||
|
|
||||||
|
### 10. Orphaned/Dead Code Audit
|
||||||
|
- Find candidate orphan modules/functions/classes with no inbound references.
|
||||||
|
- Validate each candidate against dynamic wiring exceptions (entrypoints, plugin registration, reflection/dynamic imports, CLI hooks, test utilities).
|
||||||
|
- Report outcomes as: removed orphan, retained-with-justification, or uncertain-follow-up.
|
||||||
|
|
||||||
|
### 11. Architecture & Governance
|
||||||
|
- **Architectural Drift:** Compare intended architecture rules against implementation behavior and cite concrete drift points.
|
||||||
|
- **Systemic Health:** Evaluate domain cohesion, dependency direction, lifecycle consistency, and operational reliability seams.
|
||||||
|
- **Invariant Routing:** Recommend the correct enforcement layer per rule (docs vs instructions vs skills vs tests).
|
||||||
|
- **Meta-Tooling Alignment:** Recommend updates for instruction files and skills when repository patterns or contracts evolve.
|
||||||
|
|
||||||
|
## Output Report Structure & Template
|
||||||
|
|
||||||
|
Generate Markdown reports in `./docs` following this exact template structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Architecture & Code Review Report
|
||||||
|
|
||||||
|
**Repository Target:** `project-root/`
|
||||||
|
**Target Stack:** Python 3.12+ | FastAPI | NiceGUI | SQLModel/SQLAlchemy | Pydantic V2 | asyncio | OpenRouter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
- 5-10 bullets on overall health, top risks, and high-leverage refactors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Executive Architecture Assessment
|
||||||
|
- High-level verdict on domain cohesion, boundary clarity, and architecture fitness.
|
||||||
|
- Top 3-5 systemic risks or bottlenecks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Findings by Severity
|
||||||
|
|
||||||
|
### Critical Severity
|
||||||
|
#### [CRIT-01] Title
|
||||||
|
- **Location:** `path/to/file.py:lines`
|
||||||
|
- **Problem & Consequence:** Concrete consequence, not a style opinion.
|
||||||
|
- **Recommendation:** Fix with before/after sketch.
|
||||||
|
- **Effort:** S / M / L
|
||||||
|
|
||||||
|
### High Severity
|
||||||
|
#### [HIGH-01] Title
|
||||||
|
...
|
||||||
|
|
||||||
|
### Medium Severity
|
||||||
|
#### [MED-01] Title
|
||||||
|
...
|
||||||
|
|
||||||
|
### Low Severity
|
||||||
|
#### [LOW-01] Title
|
||||||
|
...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architectural Drift & Gap Analysis
|
||||||
|
| Area / Component | Documented / Intended Rule | Actual Implementation State | Severity | Recommended Resolution |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Invariant Inventory & Routing Recommendations
|
||||||
|
| Invariant / Constraint | Current Location | Recommended Target Layer | Rationale |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Stack-Specific Analysis
|
||||||
|
- Python 3.12+ Best Practices
|
||||||
|
- FastAPI
|
||||||
|
- NiceGUI
|
||||||
|
- SQLModel & SQLAlchemy
|
||||||
|
- Pydantic V2 & Settings
|
||||||
|
- Asyncio Workers
|
||||||
|
- OpenRouter / Adapter Boundary
|
||||||
|
- Testing & Quality Tooling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Duplication & Consolidation Report
|
||||||
|
| Pattern / Duplication | Locations | Proposed Canonical Home | Estimated Lines Removed |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
|
||||||
|
### Proposed Canonical Abstractions
|
||||||
|
- Code signatures and implementation homes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Meta-Tooling & Instruction Update Recommendations
|
||||||
|
- Required updates to docs/instructions/skills/tests to keep enforcement current.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Prioritized Dependency-Ordered Action Plan
|
||||||
|
1. **Phase 1: Blocking fixes**
|
||||||
|
2. **Phase 2: Enforcement hardening**
|
||||||
|
3. **Phase 3: Reliability & concurrency**
|
||||||
|
4. **Phase 4: Consolidation & refactoring**
|
||||||
|
5. **Phase 5: Non-blocking governance/documentation depth**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Preserved Strengths
|
||||||
|
- Existing patterns worth maintaining.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
name: test-effectiveness-auditor
|
||||||
|
description: Periodic reviewer for test-suite signal quality. Detects low-value or redundant tests, validates contract coverage, and recommends pruning or strengthening actions.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Effectiveness Auditor
|
||||||
|
|
||||||
|
Run a deterministic audit of test usefulness. Focus on whether tests catch real regressions, not whether they merely execute code.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Monthly/quarterly test-health review.
|
||||||
|
- Pre-release hardening when test count grows quickly.
|
||||||
|
- After major AI-assisted test generation.
|
||||||
|
- When suite runtime is increasing without clear quality gains.
|
||||||
|
|
||||||
|
## Primary Objectives
|
||||||
|
|
||||||
|
1. Identify tests that are weak, redundant, or non-diagnostic.
|
||||||
|
2. Confirm critical contracts are guarded by meaningful assertions.
|
||||||
|
3. Produce a prune/strengthen backlog with explicit risk and effort.
|
||||||
|
|
||||||
|
## Normative References (Transcription Repo)
|
||||||
|
|
||||||
|
1. `docs/*`
|
||||||
|
2. `docs/invariant/*`
|
||||||
|
3. `.github/instructions/*.instructions.md`
|
||||||
|
4. `tests/test_meta_contract_guards.py`
|
||||||
|
5. Contract-specific guards (`tests/test_service_boundaries.py`, `tests/test_ui_boundaries.py`, worker/evidence/media/error suites)
|
||||||
|
|
||||||
|
## Deterministic Audit Checks
|
||||||
|
|
||||||
|
### A. Contract Traceability
|
||||||
|
- Each high-risk contract maps to at least one focused regression test file.
|
||||||
|
- Missing mapping is a gap.
|
||||||
|
|
||||||
|
### B. Assertion Strength
|
||||||
|
- Flag tests that only assert status code, non-null, or “no exception” without validating state transitions or persisted outcomes.
|
||||||
|
- Prefer assertions on domain effects: DB rows, status changes, error categories, evidence writes, or emitted payload shape.
|
||||||
|
|
||||||
|
### C. Failure-Path Coverage
|
||||||
|
- Critical paths must include negative-path tests (timeouts, provider errors, validation failures, cancellation paths, retries).
|
||||||
|
- Happy-path-only coverage on critical modules is a gap.
|
||||||
|
|
||||||
|
### D. Redundancy and Noise
|
||||||
|
- Detect near-duplicate tests asserting the same behavior at multiple layers with no extra signal.
|
||||||
|
- Recommend canonical location (unit/integration) and prune overlaps.
|
||||||
|
|
||||||
|
### E. Mutation/Change Sensitivity
|
||||||
|
- Prefer mutation testing for high-risk modules when practical.
|
||||||
|
- If not run, identify tests likely to survive meaningful code mutations (low sensitivity).
|
||||||
|
|
||||||
|
### F. Drift Guards
|
||||||
|
- Verify config/doc/instruction contracts have deterministic guards and are current.
|
||||||
|
- Ensure settings/docs synchronization checks remain active.
|
||||||
|
|
||||||
|
## Evidence Standards
|
||||||
|
|
||||||
|
- Every finding must include concrete file paths and line ranges.
|
||||||
|
- No speculative claims.
|
||||||
|
- Distinguish clearly between:
|
||||||
|
- **Confirmed ineffective tests**
|
||||||
|
- **Likely weak tests (needs mutation/probe confirmation)**
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Produce a Markdown report in `docs/`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Test Effectiveness Audit Report
|
||||||
|
|
||||||
|
## 1. Executive Verdict
|
||||||
|
- Effective / Effective with Conditions / Needs Remediation
|
||||||
|
- Top risks to confidence
|
||||||
|
|
||||||
|
## 2. Contract Coverage Matrix
|
||||||
|
| Contract | Guarding Tests | Signal Quality | Gap | Action |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
|
||||||
|
## 3. Weak/Redundant Test Findings
|
||||||
|
| Finding ID | Location | Why Low-Signal | Risk | Recommendation |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
|
||||||
|
## 4. Prune/Strengthen Backlog
|
||||||
|
| Task ID | Goal | Files | Acceptance Criteria | Validation |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
|
||||||
|
## 5. Confidence Recommendation
|
||||||
|
- Go / Go with Conditions / No-Go for release confidence
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decision Rules
|
||||||
|
|
||||||
|
- Do not recommend deleting a test unless equivalent or stronger coverage is identified.
|
||||||
|
- Prefer strengthening assertions before adding more tests.
|
||||||
|
- Prioritize deterministic contract guards over broad snapshot-style tests.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Quality Gate
|
||||||
|
|
||||||
|
# V4.7 Phase 6 / review log [40]. Before this, ruff, ty and pytest were enforced
|
||||||
|
# only by .pre-commit-config.yaml, and only for developers who had actually run
|
||||||
|
# `pre-commit install`.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out the commit under test
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Install dependencies from the lockfile
|
||||||
|
# --locked fails if uv.lock has drifted from pyproject.toml, so a stale
|
||||||
|
# lockfile is caught here rather than producing an untested dependency set.
|
||||||
|
run: uv sync --locked
|
||||||
|
|
||||||
|
- name: Write placeholder configuration
|
||||||
|
# Settings requires openrouter_api_key and 115 tests cannot construct
|
||||||
|
# Settings without it. This is written to a .env file rather than exported
|
||||||
|
# as an environment variable on purpose: the external tests guard on
|
||||||
|
# os.getenv("OPENROUTER_API_KEY"), which reads the process environment and
|
||||||
|
# not the file, so writing the file reproduces the local result exactly -
|
||||||
|
# the 4 external tests skip instead of running against a fake key and
|
||||||
|
# failing. Exporting it instead produces 3 failures.
|
||||||
|
run: echo "OPENROUTER_API_KEY=ci-placeholder-not-a-real-key" > .env
|
||||||
|
|
||||||
|
- name: Lint and type check
|
||||||
|
# Runs the hooks defined in .pre-commit-config.yaml instead of repeating
|
||||||
|
# "ruff check" and "ty check" here. The commands then have one definition,
|
||||||
|
# so the local and CI gates cannot drift apart.
|
||||||
|
run: uv run pre-commit run --all-files --show-diff-on-failure
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: uv run pytest
|
||||||
+6
-4
@@ -15,7 +15,9 @@ wheels/
|
|||||||
# SQLite database
|
# SQLite database
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
upload/
|
# Document images
|
||||||
*.jpg
|
uploads/*
|
||||||
*.jpeg
|
data/*
|
||||||
*.png
|
|
||||||
|
# Local destructive-test backups
|
||||||
|
.test-backups/
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Quality gate for V4.6 [HIGH-06]. Both hooks are blocking: a regression in
|
||||||
|
# `ruff check` or `ty check` fails the commit.
|
||||||
|
repos:
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
name: ruff check
|
||||||
|
entry: ruff check
|
||||||
|
language: system
|
||||||
|
types_or: [python, pyi]
|
||||||
|
require_serial: true
|
||||||
|
- id: ty
|
||||||
|
name: ty check
|
||||||
|
entry: ty check
|
||||||
|
language: system
|
||||||
|
types_or: [python, pyi]
|
||||||
|
pass_filenames: false
|
||||||
|
require_serial: true
|
||||||
Vendored
+4
-8
@@ -8,14 +8,10 @@
|
|||||||
"module": "debugpy",
|
"module": "debugpy",
|
||||||
"args": [
|
"args": [
|
||||||
"-m",
|
"-m",
|
||||||
"uvicorn",
|
"transcription",
|
||||||
"transcription.app:create_app",
|
"--host", "127.0.0.1",
|
||||||
"--factory",
|
"--port", "9999",
|
||||||
"--host",
|
"--database.driver", "sqlite"
|
||||||
// "127.0.0.1",
|
|
||||||
"0.0.0.0",
|
|
||||||
"--port",
|
|
||||||
"8080"
|
|
||||||
],
|
],
|
||||||
"justMyCode": true,
|
"justMyCode": true,
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"chat.sessionSync.enabled": true
|
||||||
|
}
|
||||||
@@ -22,30 +22,105 @@ uv sync
|
|||||||
|
|
||||||
### 2) Configure environment
|
### 2) Configure environment
|
||||||
|
|
||||||
Create a `.env` file in the project root (minimum required setting shown):
|
Create a `.env` file in the project root with the required OpenRouter API key:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
OPENROUTER_API_KEY=your_openrouter_api_key
|
OPENROUTER_API_KEY=your_openrouter_api_key
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional settings (defaults shown):
|
Settings are read from CLI arguments first, then environment variables, then `.env`, then the defaults below.
|
||||||
|
|
||||||
|
### Configuration Source Precedence
|
||||||
|
|
||||||
|
When the same setting is provided in multiple places, the value is chosen in this order (highest priority first):
|
||||||
|
|
||||||
|
1. CLI arguments (for example `--port 8000`)
|
||||||
|
2. Settings constructor arguments (used mainly in tests)
|
||||||
|
3. Environment variables
|
||||||
|
4. `.env` file values
|
||||||
|
5. Model defaults in code
|
||||||
|
|
||||||
|
Practical examples:
|
||||||
|
|
||||||
|
- `--port 8000` overrides both `PORT=8000` in the shell and `PORT=7000` in `.env`.
|
||||||
|
- `DATABASE__PATH=prod.db` in the shell overrides `DATABASE__PATH=dev.db` in `.env`.
|
||||||
|
|
||||||
|
#### Server and runtime
|
||||||
|
|
||||||
|
| Environment variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `HOST` | `0.0.0.0` | Address on which the server listens. |
|
||||||
|
| `PORT` | `8000` | Server port. |
|
||||||
|
| `LOG_LEVEL` | `info` | Uvicorn and application log level. |
|
||||||
|
| `RELOAD` | `false` | Restart the development server when source files change. |
|
||||||
|
| `ENVIRONMENT` | `development` | Runtime environment: `development`, `test`, or `production`. |
|
||||||
|
|
||||||
|
#### Provider
|
||||||
|
|
||||||
|
| Environment variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PROVIDER` | `openrouter` | Transcription provider. |
|
||||||
|
| `OPENROUTER_API_KEY` | Required | OpenRouter API key. |
|
||||||
|
| `PROVIDER_MODEL` | Provider default | Optional model override. |
|
||||||
|
| `OPENROUTER_HTTP_REFERER` | Unset | Optional OpenRouter attribution URL. |
|
||||||
|
| `OPENROUTER_APP_TITLE` | Unset | Optional OpenRouter attribution title. |
|
||||||
|
|
||||||
|
#### Database and files
|
||||||
|
|
||||||
|
Use nested env vars for database settings (recommended):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL=sqlite:///./transcription.db
|
DATABASE__DRIVER=sqlite
|
||||||
|
DATABASE__PATH=app.db
|
||||||
|
# BOOTSTRAP_SCHEMA_ON_STARTUP=true
|
||||||
|
SQLITE_CHECK_SAME_THREAD=false
|
||||||
UPLOAD_DIR=./uploads
|
UPLOAD_DIR=./uploads
|
||||||
PROMPT_DIR=./prompts
|
PROMPT_DIR=./prompts
|
||||||
|
DEFAULT_PROMPT_NAME=transcribe_document.md
|
||||||
|
# TRANSCRIPTION_TEMPERATURE=0.2 # range: 0.0-2.0
|
||||||
|
# TRANSCRIPTION_TOP_P=0.9 # range: 0.0-1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
For PostgreSQL:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE__DRIVER=postgres
|
||||||
|
DATABASE__HOST=localhost
|
||||||
|
DATABASE__PORT=5432
|
||||||
|
DATABASE__DATABASE=transcription
|
||||||
|
DATABASE__USER=postgres
|
||||||
|
DATABASE__PASSWORD=change-me
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses Pydantic nested settings (`env_nested_delimiter='__'`) and avoids JSON blobs in `.env`. A top-level `DATABASE={...}` JSON value is still supported as a fallback, and nested keys such as `DATABASE__PATH` take precedence over conflicting JSON keys.
|
||||||
|
|
||||||
|
`BOOTSTRAP_SCHEMA_ON_STARTUP` creates missing tables when the app starts. When unset, it is enabled in `development` and `test`, and disabled in `production`; set it explicitly to override that policy. `SQLITE_CHECK_SAME_THREAD` defaults to `false`.
|
||||||
|
|
||||||
|
#### Worker
|
||||||
|
|
||||||
|
```env
|
||||||
|
WORKER_MAX_RETRIES=0
|
||||||
|
WORKER_RETRY_BACKOFF_SECONDS=0
|
||||||
|
WORKER_PROVIDER_TIMEOUT_SECONDS=20
|
||||||
|
WORKER_MIN_TRANSCRIPTION_CHARS=0
|
||||||
|
WORKER_MIN_TRANSCRIPTION_LINES=0
|
||||||
|
WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3) Run the app
|
### 3) Run the app
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run uvicorn transcription.app:create_app --factory --reload
|
uv run python -m transcription --port 8000 --reload --database.driver sqlite --bootstrap-schema-on-startup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This starts the development server with SQLite, creates missing tables, and enables automatic reload. Run `uv run python -m transcription --help` for all CLI options; CLI names use kebab case and nested database options use dot notation, such as `--database.path ./data/transcription.db`.
|
||||||
|
|
||||||
### 4) Open in browser
|
### 4) Open in browser
|
||||||
|
|
||||||
- GUI: [http://[IP_ADDRESS]:8000/ui](http://[IP_ADDRESS]:8000/ui)
|
- GUI: [http://localhost:8000/ui](http://localhost:8000/ui)
|
||||||
- Health check: [http://[IP_ADDRESS]:8000/healthz](http://[IP_ADDRESS]:8000/healthz)
|
- Health check: [http://localhost:8000/healthz](http://localhost:8000/healthz)
|
||||||
|
|
||||||
|
Replace `localhost` with the server's hostname or IP address when connecting from another machine.
|
||||||
|
|
||||||
## How to navigate the GUI
|
## How to navigate the GUI
|
||||||
|
|
||||||
@@ -66,7 +141,70 @@ uv run uvicorn transcription.app:create_app --factory --reload
|
|||||||
|
|
||||||
## Prompt artifacts
|
## Prompt artifacts
|
||||||
|
|
||||||
Prompt files are stored in `prompts/` and loaded from `PROMPT_DIR` (default: `./prompts`).
|
Prompt files are stored directly in `PROMPT_DIR` (default: `./prompts`). `DEFAULT_PROMPT_NAME` must be a filename,
|
||||||
|
not a path. Each job snapshots the validated prompt text, SHA-256 hash, and sampling values for reproducibility.
|
||||||
|
|
||||||
The canonical MVP prompt is:
|
The canonical MVP prompt is:
|
||||||
- `prompts/transcribe_document.md`
|
- `prompts/transcribe_document.md`
|
||||||
|
|
||||||
|
## Database migration workflow
|
||||||
|
|
||||||
|
Schema upgrades use an explicit export/import rebuild flow (no runtime legacy write compatibility).
|
||||||
|
See `docs/data_migration.md` for commands and cutover steps.
|
||||||
|
|
||||||
|
## Destructive test procedure (with data backup)
|
||||||
|
|
||||||
|
AI execution policy: before the first unit-test run in a test/fix cycle, create one backup of `./data`. Reuse that same backup for every subsequent test run in the cycle. After tests succeed, always pause and ask whether to restore now.
|
||||||
|
|
||||||
|
Use the cross-platform Python wrapper below whenever an AI agent runs tests against this repository.
|
||||||
|
|
||||||
|
1. Create one backup of `./data` and mark it as the active test-cycle backup.
|
||||||
|
2. Run your test command.
|
||||||
|
3. On failure, fix the errors and run the wrapper again; it reuses the active backup and never backs up post-test data.
|
||||||
|
4. On success, always prompt whether to restore now (do not auto-restore unless explicitly approved).
|
||||||
|
5. Close the cycle only by restoring the active backup or explicitly accepting the current data.
|
||||||
|
|
||||||
|
Preflight behavior:
|
||||||
|
|
||||||
|
- Backup preflight is warning-only when `data/transcription.db` appears in use.
|
||||||
|
- Restore preflight is blocking: the script prompts you to close conflicting applications, then type `retry` to re-check or `cancel` to skip restore.
|
||||||
|
|
||||||
|
### Run with confirmation-gated restore (default)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python tools/run_destructive_tests.py -- pytest tests/services/test_job_service.py tests/ui/test_jobs_page.py
|
||||||
|
```
|
||||||
|
|
||||||
|
After tests pass, the script asks whether to restore backup immediately.
|
||||||
|
|
||||||
|
This is the required default mode for AI-assisted test runs because it gives time to verify and accept code changes before any restoration happens.
|
||||||
|
|
||||||
|
### Run with automatic restore (non-interactive)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python tools/run_destructive_tests.py --auto-restore -- pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run without terminal prompt (decide restore later)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python tools/run_destructive_tests.py --skip-restore-prompt -- pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps both the current post-test state and the backup, so restore can be decided explicitly later.
|
||||||
|
|
||||||
|
Repeated wrapper invocations reuse the backup recorded in `.test-backups/.active-backup`. If that backup is missing, the wrapper stops rather than creating a replacement from potentially destructive post-test data.
|
||||||
|
|
||||||
|
### Restore later from a saved backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python tools/run_destructive_tests.py --restore-from data-backup-YYYYMMDD-HHMMSS
|
||||||
|
```
|
||||||
|
|
||||||
|
To keep the current data and close the active cycle without restoring:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python tools/run_destructive_tests.py --accept-current-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Backups are stored in `.test-backups/` and ignored by git.
|
||||||
|
|||||||
+4
-1
@@ -7,7 +7,10 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: sqlite:////app/data/transcription.db
|
# Database configuration uses nested settings names (env_nested_delimiter="__").
|
||||||
|
# DATABASE_URL is NOT read by the application and must not be used here.
|
||||||
|
DATABASE__DRIVER: sqlite
|
||||||
|
DATABASE__PATH: /app/data/transcription.db
|
||||||
UPLOAD_DIR: /app/uploads
|
UPLOAD_DIR: /app/uploads
|
||||||
PROMPT_DIR: /app/prompts
|
PROMPT_DIR: /app/prompts
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# Historical Document Transcription
|
|
||||||
I have several thousand pages of family history told through letters, postcards, books, and other documents that I want to transcribe to text.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
1. Preserve our family history
|
|
||||||
2. Unburden my family (and descendants) from having to store and care for the physical media. Once the documents have been transcribed and organized, they can be donated (or kept by a family member that wants to retain them).
|
|
||||||
3. Make the text easily available and searchable by family members, as well as AI (which may have different requirements).
|
|
||||||
4. Ability create timelines or assemble the historical record of the family or specific individuals from across the complete document archive. Perhaps use AI to create the timelines in a more narrative form.
|
|
||||||
|
|
||||||
## Source material
|
|
||||||
1. **letters, cards, diaries** - handwritten; mostly stored in tubs, with little organization
|
|
||||||
2. **books** - typed or typeset; mostly self-published books 50-100 pages in length. This may be expanded to include selected pages from other publications.
|
|
||||||
3. **newspaper clippings, event programs, invitations, and other ephemera**
|
|
||||||
|
|
||||||
## Methodology
|
|
||||||
### Verbatim vs. Clean Copy
|
|
||||||
Transcriptions should be Verbatim and follow scholarly research guidelines, with no modifications to the original text.
|
|
||||||
|
|
||||||
### Prompt Curation Policy
|
|
||||||
Transcription behavior should be implemented with prompt assets that are human-maintainable over time.
|
|
||||||
|
|
||||||
1. Each transcription prompt is stored as an individual Markdown file.
|
|
||||||
2. Prompt files are refined iteratively as document quality and edge cases are discovered.
|
|
||||||
3. Prompt changes should be scoped to one prompt file at a time whenever possible to keep review history clear.
|
|
||||||
|
|
||||||
### Potential Document Issues
|
|
||||||
| Document Issue | How to Handle It | Example |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| **Misspellings & Errors** | Retain original spelling and insert italicized `[sic]` directly after the error. | `The weather was very cold and publick [sic] business delayed.` |
|
|
||||||
| **Missing Words / Slips** | Insert the missing word inside square brackets to restore basic readability. | `We went [to] the store to buy supplies.` |
|
|
||||||
| **Uncertain / Guesswork** | Place your best hypothesis followed by a question mark inside square brackets. | `He went to [Boston?] yesterday to meet the governor.` |
|
|
||||||
| **Completely Illegible** | Use a clear descriptive term like `[illegible]` or specify the reason (e.g., `[torn]`, `[ink blot]`). | `The total cost was [illegible] dollars.` or `The letter ends here [remainder of page torn].` |
|
|
||||||
| **Crossed-out Text** | Wrap the removed word or phrase in a deleted tag to preserve the author's edits. | `We left at [deleted: noon] one o'clock instead.` |
|
|
||||||
| **Squeezed-in Text** | Wrap text that was added above the line or in a tight space in an inserted tag. | `The [inserted: red] house on the hill was abandoned.` |
|
|
||||||
| **Superscripts & Abbreviations** | Bring raised letters down to the main line, or optionally expand them in brackets. | `Change Gen^l to Genl` OR `Change to Gen[era]l depending on project preference.` |
|
|
||||||
| **Images / Seals / Signs** | Describe the non-textual element using italicized text inside square brackets. | `[wax notary seal attached here]` or `[sketch of a fort layout]` |
|
|
||||||
| **Marginalia / Notes** | Note the spatial transition clearly before transcribing the note itself. | `[written in left margin:] Do not share this with anyone.` |
|
|
||||||
| **Line Breaks / Hyphens** | Rejoin words split across a page margin silently, dropping the line-break hyphen. | `Original: "estab- / lishment" becomes "establishment"` |
|
|
||||||
| **Ambiguous Capitalization** | Default to modern capitalization rules unless an archaic uppercase letter is clearly intentional. | `If a standard noun like 'Farm' looks randomly capitalized, type 'farm'.` |
|
|
||||||
**Hierarchical Outlines** | Preserve exact numbering characters (including lowercase Roman numerals or terminal 'j'). Replicate indentation levels using spaces/tabs. Do not correct math or sequence errors silently. | `I. Main Topic`<br>` a. Sub-point`<br>` b. Next point`<br>`III. [sic] Third Topic` |
|
|
||||||
+113
-238
@@ -1,294 +1,169 @@
|
|||||||
# Architecture
|
# System Architecture (Version 4)
|
||||||
|
|
||||||
This document describes the production architecture of the personal historical-document transcription system. The system is intentionally optimized for single-user operation, low operational overhead, and clean internal boundaries that support future growth without rewrites.
|
This document defines the current Version 4 architecture baseline.
|
||||||
|
|
||||||
## Architecture Objectives
|
## Architecture Objectives
|
||||||
|
|
||||||
The production architecture is designed to:
|
- Preserve durable archival records for Documents, Sources, People, and processing runs.
|
||||||
|
- Execute page transcription asynchronously with bounded worker behavior.
|
||||||
|
- Preserve append-only machine-attempt evidence with request/response provenance.
|
||||||
|
- Keep UI, API, service, persistence, and provider boundaries explicit and testable.
|
||||||
|
|
||||||
- preserve verbatim family-history source material as searchable text
|
## Technical Stack
|
||||||
- keep operational complexity low for a personal deployment
|
|
||||||
- support asynchronous transcription without requiring distributed infrastructure
|
|
||||||
- maintain clear module boundaries so extensions can be added incrementally
|
|
||||||
|
|
||||||
## Production Scope And Scale
|
- **Runtime:** Python 3.12+
|
||||||
|
- **Web application:** FastAPI + NiceGUI
|
||||||
|
- **Persistence:** SQLModel / SQLAlchemy (SQLite-first, PostgreSQL-compatible model design)
|
||||||
|
- **Validation and settings:** Pydantic V2 + pydantic-settings
|
||||||
|
- **Concurrency:** asyncio worker loop
|
||||||
|
- **Provider integration:** OpenRouter adapter behind provider interface
|
||||||
|
- **Quality and tests:** Ruff, ty, pytest, pytest-asyncio
|
||||||
|
|
||||||
The deployed system targets personal use and a corpus of several thousand documents processed over time. The architecture favors simple, composable building blocks over distributed orchestration.
|
## Runtime Topology
|
||||||
|
|
||||||
Current scope includes:
|
|
||||||
|
|
||||||
- document upload and metadata capture
|
|
||||||
- asynchronous transcription jobs
|
|
||||||
- prompt-library driven transcription behavior, with one Markdown file per prompt
|
|
||||||
- transcript review and revision history
|
|
||||||
- full-text search over accepted transcripts
|
|
||||||
- export of transcript data
|
|
||||||
|
|
||||||
## Deployment Topology
|
|
||||||
|
|
||||||
The production deployment uses [Docker Compose](https://docs.docker.com/compose/) and treats containerized databases as extremely lightweight operational dependencies.
|
|
||||||
|
|
||||||
Running [PostgreSQL](https://www.postgresql.org/docs/) in its own container is considered simple by default for this system.
|
|
||||||
|
|
||||||
Running [MongoDB](https://www.mongodb.com/docs/) in its own container is also considered simple when document-centric storage is enabled.
|
|
||||||
|
|
||||||
Container count is not a hard architectural limit; a three-container deployment (app, PostgreSQL, MongoDB) is an acceptable baseline.
|
|
||||||
|
|
||||||
### Baseline Topology (Two Containers)
|
|
||||||
|
|
||||||
- one application container
|
|
||||||
- one PostgreSQL container
|
|
||||||
- embedded background worker execution inside the app process
|
|
||||||
|
|
||||||
### Expanded Topology (Three Containers)
|
|
||||||
|
|
||||||
- application container
|
|
||||||
- PostgreSQL container
|
|
||||||
- MongoDB container
|
|
||||||
|
|
||||||
No additional queue, scheduler, or search-engine containers are required in the baseline production setup.
|
|
||||||
|
|
||||||
## Runtime Architecture
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
User[Browser User] --> App[FastAPI + NiceGUI Service]
|
U[Browser User] --> A[FastAPI + NiceGUI App]
|
||||||
App --> Worker[In-process Background Worker]
|
A --> W[Asyncio Worker]
|
||||||
App --> PG[(PostgreSQL)]
|
A --> DB[(SQLite/PostgreSQL Model)]
|
||||||
App --> MG[(MongoDB Document Store)]
|
W --> P[Provider Adapter]
|
||||||
Worker --> AI[Transcription Provider]
|
W --> DB
|
||||||
Worker --> PG
|
|
||||||
Worker --> MG
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Runtime Ownership And Startup Policy (V1 Step 1)
|
## Layered Boundaries
|
||||||
|
|
||||||
The current implementation now uses explicit lifespan-owned runtime resources.
|
|
||||||
|
|
||||||
- application lifespan initializes and disposes database runtime resources
|
|
||||||
- worker lifecycle is owned by application lifespan startup/shutdown
|
|
||||||
- worker receives lifespan-owned database engine dependency explicitly
|
|
||||||
- schema bootstrap policy is environment-aware and explicit:
|
|
||||||
- development/test default to bootstrap enabled
|
|
||||||
- production defaults to bootstrap disabled
|
|
||||||
- explicit override is available via configuration
|
|
||||||
|
|
||||||
This aligns implementation toward REQ-7 and REQ-10 while preserving personal-scale operational simplicity.
|
|
||||||
|
|
||||||
## Layered Module Structure
|
|
||||||
|
|
||||||
### Interface Layer
|
### Interface Layer
|
||||||
|
|
||||||
Responsibility:
|
- `src/transcription/ui/**`
|
||||||
|
- `src/transcription/api/**`
|
||||||
|
|
||||||
- HTTP API and UI routes
|
Responsibilities:
|
||||||
- request/response validation
|
|
||||||
- status and result presentation
|
|
||||||
|
|
||||||
Out of scope:
|
- Route registration, page orchestration, presentation adapters.
|
||||||
|
- Structured user messaging through shared error presenter.
|
||||||
|
- No direct persistence access from pages/components.
|
||||||
|
|
||||||
- business-rule enforcement
|
### Service and Orchestration Layer
|
||||||
- data-access implementation
|
|
||||||
|
|
||||||
### Application Layer
|
- `src/transcription/services/documents.py`
|
||||||
|
- `src/transcription/services/people.py`
|
||||||
|
- `src/transcription/services/jobs.py`
|
||||||
|
- `src/transcription/services/sources.py`
|
||||||
|
- `src/transcription/services/evidence.py`
|
||||||
|
- `src/transcription/services/store.py`
|
||||||
|
- `src/transcription/services/workflows.py`
|
||||||
|
|
||||||
Responsibility:
|
Responsibilities:
|
||||||
|
|
||||||
- upload and job orchestration
|
- Aggregate ownership and invariants.
|
||||||
- state transitions and retry policy
|
- Transaction-aware write helpers.
|
||||||
- coordination across domain and infrastructure ports
|
- Cross-service workflows in orchestration modules (`store.py`, `workflows.py`).
|
||||||
|
|
||||||
Out of scope:
|
### Persistence Layer
|
||||||
|
|
||||||
- provider-specific protocol details
|
- `src/transcription/db/**`
|
||||||
- ORM or storage-specific logic
|
|
||||||
|
|
||||||
### Domain Layer
|
Responsibilities:
|
||||||
|
|
||||||
Responsibility:
|
- SQLModel definitions, async session/engine runtime, registry bootstrap.
|
||||||
|
- Loader helpers that enforce explicit eager loading with `lazy="raise"` relationships.
|
||||||
|
|
||||||
- verbatim transcription policy
|
### Provider Layer
|
||||||
- revision and provenance invariants
|
|
||||||
- confidence and annotation semantics
|
|
||||||
|
|
||||||
Out of scope:
|
- `src/transcription/providers/**`
|
||||||
|
|
||||||
- web framework concerns
|
Responsibilities:
|
||||||
- database and network I/O
|
|
||||||
|
|
||||||
### Infrastructure Layer
|
- Provider API encapsulation.
|
||||||
|
- Request manifest and transport evidence capture.
|
||||||
|
- Normalized transcription result contract.
|
||||||
|
|
||||||
Responsibility:
|
## Core Domain Model
|
||||||
|
|
||||||
- persistence adapters (PostgreSQL and MongoDB)
|
- `Document` owns archival metadata and links to `Source`, `Job`, and `DocumentPerson`.
|
||||||
- transcription-provider adapter
|
- `Source` is a document page/file record with selected machine projection and human revision.
|
||||||
|
- `Job` is an aggregate processing run with status and frozen prompt/runtime settings.
|
||||||
|
- `JobSource` is queue/membership state for one `(job, source)` pair.
|
||||||
|
- `ExecutionAttempt` is append-only evidence for each provider call.
|
||||||
|
- `DocumentType` and `PersonRole` are UUID-backed registries with optional protected `semantic_key`.
|
||||||
|
|
||||||
Out of scope:
|
## Processing and Evidence Workflow
|
||||||
|
|
||||||
- business policy decisions
|
1. User creates/updates Document metadata and linked People atomically through workflow orchestration.
|
||||||
|
2. User creates a Job by uploading one or more Source files or by retranscribing an existing Source.
|
||||||
|
3. Source files are validated and stored; orientation normalization may be applied at ingest, and stored bytes become the canonical processing bytes.
|
||||||
|
4. Worker claims queued Job, transitions to `processing`, and processes pending pages in deterministic order.
|
||||||
|
5. Each provider call writes one immutable `ExecutionAttempt` with:
|
||||||
|
- request manifest + hash
|
||||||
|
- transport evidence (when response exists)
|
||||||
|
- SDK snapshot and normalized metadata
|
||||||
|
- outcome, timing, and error details when applicable
|
||||||
|
6. `JobSource` status is updated as queue/projection state; `Source.raw_transcription` is set on first successful attempt and can be explicitly re-pointed by candidate promotion.
|
||||||
|
7. Job terminal status resolves to `transcribed`, `partial_success`, or `failed`.
|
||||||
|
|
||||||
## Processing Workflow
|
## Status Semantics
|
||||||
|
|
||||||
Production transcription flow:
|
- **Job statuses:** `queued`, `processing`, `transcribed`, `partial_success`, `failed`
|
||||||
|
- Operational success path resolves to `transcribed`.
|
||||||
|
- **JobSource statuses:** `pending`, `transcribed`, `failed`, `cancelled`
|
||||||
|
|
||||||
1. A user uploads an image or PDF through the UI or API.
|
## Security and Path Handling Boundaries
|
||||||
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.
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Data Model Ownership
|
- Print media delivery uses record-validated API route:
|
||||||
|
- `src/transcription/api/print_api.py`
|
||||||
|
- General UI media links resolve through:
|
||||||
|
- `src/transcription/ui/components/media_urls.py`
|
||||||
|
- Local filesystem paths must never be accepted from user input as trusted media routes.
|
||||||
|
|
||||||
System-of-record entities:
|
## Concurrency and Reliability Principles
|
||||||
|
|
||||||
- documents and pages
|
- Worker loop reuses service bundle/provider resources for pooled calls.
|
||||||
- transcription jobs and status events
|
- Provider-call timeout is explicit and bounded.
|
||||||
- transcript revisions
|
- Non-retriable worker-loop faults are surfaced and stop loop spin.
|
||||||
- provenance metadata
|
- Per-page outcomes are durably persisted before processing next page.
|
||||||
|
|
||||||
Storage strategy:
|
## Design Decisions and Rationale
|
||||||
|
|
||||||
- PostgreSQL for relational system-of-record entities
|
### Why `transcribed` is the success terminal state
|
||||||
- MongoDB for document-oriented payloads and large transcription artifacts
|
|
||||||
- versioned prompt artifacts stored as individual Markdown files for human editing and refinement
|
|
||||||
- in-memory execution state treated as ephemeral
|
|
||||||
|
|
||||||
## Transcription Prompt Asset Policy
|
- The worker and job orchestration resolve successful completion to `JobStatus.TRANSCRIBED`, with mixed and failure outcomes represented by `partial_success` and `failed`.
|
||||||
|
- This keeps terminal status vocabulary aligned with what the pipeline actually produces: transcribed page content and evidence, not a generic completion marker.
|
||||||
|
|
||||||
The production system treats transcription prompts as maintainable content assets.
|
### Why evidence history is append-only while page text is a projection
|
||||||
|
|
||||||
- each transcription prompt is stored in its own Markdown file
|
- `ExecutionAttempt` stores immutable per-call evidence and preserves full attempt history across retries.
|
||||||
- prompt files are designed for direct human editing and iterative refinement
|
- `Source.raw_transcription` is intentionally a mutable projection so UI and exports can show a selected current machine text without mutating historical evidence.
|
||||||
- prompt updates are independent and do not require bundling unrelated prompt changes
|
- This split keeps auditability and UX both first-class: history is durable, presentation is editable.
|
||||||
- prompt file identity and revision history are tracked through normal repository version control
|
|
||||||
|
|
||||||
## Simplicity Guardrails
|
### Why orchestration modules own cross-service workflows
|
||||||
|
|
||||||
The production system enforces these constraints to prevent accidental over-engineering:
|
- Service modules do not import each other; aggregate ownership remains local to each service.
|
||||||
|
- Multi-aggregate writes are coordinated in orchestration modules (`store.py`, `workflows.py`) so transaction boundaries are explicit and testable.
|
||||||
|
- This avoids circular dependencies and keeps cross-cutting workflow logic centralized.
|
||||||
|
|
||||||
- PostgreSQL in a container is treated as a lightweight default dependency
|
### Why explicit eager loading is required
|
||||||
- MongoDB in a container is treated as a lightweight optional dependency
|
|
||||||
- three containers (app, PostgreSQL, MongoDB) is an acceptable simple deployment
|
|
||||||
- no dedicated queue or search cluster is introduced without measured need
|
|
||||||
- external infrastructure is added only behind existing ports/adapters
|
|
||||||
|
|
||||||
## Extension Path
|
- ORM relationships are configured with `lazy="raise"` in key paths, so code must request needed relationships up front.
|
||||||
|
- This prevents hidden query behavior in UI/service code and makes read shape deterministic and reviewable.
|
||||||
|
|
||||||
The architecture supports additive growth without changing domain contracts.
|
### Why canonical source bytes may be ingest-normalized
|
||||||
|
|
||||||
### Stage 1: Foundation (Current)
|
- Ingest normalization can correct orientation before persistence so provider calls, evidence hashes, and rendered processing source are consistent.
|
||||||
|
- The canonical stored bytes, digest, and size become the durable processing identity for that source.
|
||||||
|
|
||||||
- upload, transcription, review, search, export
|
### Why media access uses controlled routes/helpers
|
||||||
- in-process worker execution
|
|
||||||
- single provider adapter
|
|
||||||
- app plus PostgreSQL deployment
|
|
||||||
|
|
||||||
### Stage 2: Throughput Hardening
|
- Print/export media uses record-validated API endpoints to avoid direct filesystem path exposure.
|
||||||
|
- General UI media URLs are generated through shared resolver helpers to keep path handling consistent and centralized.
|
||||||
|
|
||||||
- optional MongoDB document-store enablement
|
## Scope Boundary
|
||||||
- optional external worker/queue process
|
|
||||||
- stronger retry and dead-letter handling
|
|
||||||
|
|
||||||
### Stage 3: Intelligence Features
|
Current architecture rules live in `docs/*`.
|
||||||
|
|
||||||
- entity extraction and cross-document linking
|
## Related References
|
||||||
- timeline and narrative assembly
|
|
||||||
- optional multi-provider routing
|
|
||||||
|
|
||||||
Each stage preserves existing module boundaries and keeps migration risk low.
|
- [System Requirements](requirements.md)
|
||||||
|
- [Data Model](schema.md)
|
||||||
## Test Strategy
|
- [Error Handling Policy](error_handling.md)
|
||||||
|
- [Error Handling invariant](../invariant/error_handling.md)
|
||||||
The test strategy is aligned to personal-scale operation with fast, deterministic feedback.
|
- [AI evidence invariant](../invariant/ai_evidence_and_provenance.md)
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
- domain transcription rules and annotation behavior
|
|
||||||
- revision-history invariants
|
|
||||||
- job state-transition logic
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
- repository behavior and transaction boundaries
|
|
||||||
- persistence-adapter and provider adapter contract mapping
|
|
||||||
- upload-to-persistence roundtrip
|
|
||||||
|
|
||||||
### End-to-End Tests
|
|
||||||
|
|
||||||
- happy path: upload, transcribe, review, search, export
|
|
||||||
- failure path: provider error, retry, surfaced failed status
|
|
||||||
|
|
||||||
### CI Execution Model
|
|
||||||
|
|
||||||
- fast suite on each push
|
|
||||||
- optional slower provider-sandbox checks on scheduled runs
|
|
||||||
|
|
||||||
## Risks And Controls
|
|
||||||
|
|
||||||
### Runtime Responsiveness
|
|
||||||
|
|
||||||
Risk:
|
|
||||||
|
|
||||||
- long jobs can reduce responsiveness in a single-process deployment
|
|
||||||
|
|
||||||
Control:
|
|
||||||
|
|
||||||
- bounded concurrency and visible job status in the UI
|
|
||||||
|
|
||||||
### Database Concurrency Limits
|
|
||||||
|
|
||||||
Risk:
|
|
||||||
|
|
||||||
- contention can appear under sustained concurrent writes in personal-scale infrastructure
|
|
||||||
|
|
||||||
Control:
|
|
||||||
|
|
||||||
- tuned connection pooling and phased use of MongoDB for document-heavy workloads
|
|
||||||
|
|
||||||
### Provider Output Variance
|
|
||||||
|
|
||||||
Risk:
|
|
||||||
|
|
||||||
- transcription quality varies by document type, handwriting legibility, and image quality
|
|
||||||
|
|
||||||
Control:
|
|
||||||
|
|
||||||
- first-class human review and immutable revision history
|
|
||||||
|
|
||||||
## Technology References
|
|
||||||
|
|
||||||
- [FastAPI documentation](https://fastapi.tiangolo.com/)
|
|
||||||
- [NiceGUI documentation](https://nicegui.io/documentation)
|
|
||||||
- [Docker Compose documentation](https://docs.docker.com/compose/)
|
|
||||||
- [PostgreSQL documentation](https://www.postgresql.org/docs/)
|
|
||||||
- [MongoDB documentation](https://www.mongodb.com/docs/)
|
|
||||||
|
|
||||||
## Related Pages
|
|
||||||
|
|
||||||
- [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)
|
|
||||||
|
|
||||||
## Glossary
|
|
||||||
|
|
||||||
- Adapter: A component that translates between internal interfaces and external systems such as databases or AI services.
|
|
||||||
- Background job: Work executed outside the request/response path so the UI remains responsive.
|
|
||||||
- Boundary: A strict separation between modules with different responsibilities.
|
|
||||||
- CI (Continuous Integration): Automated test execution for code changes.
|
|
||||||
- Contract test: A test that verifies an adapter follows expected input/output behavior at a boundary.
|
|
||||||
- Domain layer: The module that contains core business rules and invariants.
|
|
||||||
- End-to-end test: A test that validates a full user flow across the running system.
|
|
||||||
- Full-text search: Text indexing and querying optimized for natural-language search.
|
|
||||||
- In-process worker: A background executor that runs within the same application process.
|
|
||||||
- Integration test: A test that verifies interactions between real modules and infrastructure components.
|
|
||||||
- MongoDB: A document-oriented database used for flexible, high-variance data structures.
|
|
||||||
- Modular monolith: A single deployable application with strongly separated internal modules.
|
|
||||||
- Port/Interface: A stable contract used by application/domain code to call infrastructure implementations.
|
|
||||||
- Prompt artifact: A single Markdown file that defines one transcription prompt and can be revised independently.
|
|
||||||
- Provenance: Metadata that records where generated data came from and how it was produced.
|
|
||||||
- Revision history: Versioned record of transcript 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.
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Database Rebuild Migration Workflow
|
||||||
|
|
||||||
|
This project uses an explicit **export/import rebuild workflow** for schema migration.
|
||||||
|
|
||||||
|
Policy:
|
||||||
|
- Do not add runtime legacy-compatibility write paths.
|
||||||
|
- Rebuild a fresh target database from current models.
|
||||||
|
- Export current data/media, then import into the fresh target.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### 1) Export current DB + uploads into a bundle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python tools/export_import_migration.py export --bundle-dir .migration-bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional source overrides:
|
||||||
|
- `--source-db <path-or-sqlalchemy-url>`
|
||||||
|
- `--source-upload-dir <path>`
|
||||||
|
|
||||||
|
### 2) Import bundle into a fresh DB + uploads root
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python tools/export_import_migration.py import --bundle-dir .migration-bundle --target-db .\data\transcription-new.db --target-upload-dir .\data-new
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) One-shot export+import
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python tools/export_import_migration.py migrate --bundle-dir .migration-bundle --target-db .\data\transcription-new.db --target-upload-dir .\data-new
|
||||||
|
```
|
||||||
|
|
||||||
|
## What gets migrated
|
||||||
|
|
||||||
|
- Tables (in dependency order): `document_type`, `person_role`, `tag`, `document`, `person`, `photo`, `document_person`, `document_tag`, `job`, `source`, `job_source`, `execution_attempt`.
|
||||||
|
- Media tree under `UPLOAD_DIR`.
|
||||||
|
|
||||||
|
The bundle contains:
|
||||||
|
- `database.json` (row export)
|
||||||
|
- `uploads/` (copied media files)
|
||||||
|
|
||||||
|
Path normalization during export/import:
|
||||||
|
- `source.file_path` is normalized to `documents/...` (upload-root-relative POSIX).
|
||||||
|
- `photo.path` is normalized to `photos/...` (upload-root-relative POSIX).
|
||||||
|
|
||||||
|
Legacy V4.x portrait/homepage backfill in the export step:
|
||||||
|
- If the source DB has no `photo` table, the exporter synthesizes `photo` rows from legacy `person.portrait_path` values and from legacy homepage image files under `UPLOAD_DIR/homepage`.
|
||||||
|
- Legacy portrait and homepage image files are copied into the unified `UPLOAD_DIR/photos/{photo_id}{suffix}` layout in the migration bundle.
|
||||||
|
- Legacy homepage markdown is relocated from `UPLOAD_DIR/homepage/homepage.md` to `UPLOAD_DIR/homepage.md`.
|
||||||
|
|
||||||
|
## Cutover
|
||||||
|
|
||||||
|
After importing to a fresh target:
|
||||||
|
1. Stop the app.
|
||||||
|
2. Point `DATABASE__*` and `UPLOAD_DIR` to the new targets.
|
||||||
|
3. Start the app and run smoke checks (`/healthz`, create/upload/process one job).
|
||||||
+84
-248
@@ -1,282 +1,118 @@
|
|||||||
# Error Handling
|
# Error Handling Policy (Version 4)
|
||||||
|
|
||||||
This document defines the canonical error-handling policy for the document transcription system. It is the single source of truth for how errors are classified, surfaced to users, logged for diagnosis, and handled across UI, API, service, worker, and provider boundaries.
|
This policy defines the active Version 4 error taxonomy, translation boundaries, and retry semantics.
|
||||||
|
|
||||||
## Error Handling Objectives
|
## Error Categories
|
||||||
|
|
||||||
The production error-handling model is designed to:
|
| Category | Meaning | Typical Origin | User Treatment |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `validation` | Input payload/selection is invalid | UI form parsing, service validators | Inline correction guidance |
|
||||||
|
| `not_found` | Target record is missing | ID lookup in service layer | Non-blocking warning or redirect |
|
||||||
|
| `conflict` | State prevents requested action | lifecycle transitions, duplicate semantic keys | Explain required precondition |
|
||||||
|
| `external` | Provider/network dependency failure | OpenRouter/provider adapter | Retry path and evidence retained |
|
||||||
|
| `timeout` | Provider call exceeded configured bound | worker/provider client timeout | Retry path and bounded messaging |
|
||||||
|
| `internal` | Unexpected local failure | unhandled service/runtime faults | Safe generic message + diagnostics capture |
|
||||||
|
|
||||||
- make failures visible to the user in clear, actionable language
|
## Runtime Taxonomy and Canonical Mapping
|
||||||
- preserve enough diagnostic detail for fast troubleshooting
|
|
||||||
- keep module behavior consistent across all boundaries
|
|
||||||
- distinguish expected domain failures from unexpected defects
|
|
||||||
- support safe retries for transient failures without hiding persistent faults
|
|
||||||
|
|
||||||
## Scope And Authority
|
Runtime code uses a richer internal taxonomy for diagnostics and persisted evidence, then maps that
|
||||||
|
taxonomy to the six canonical categories at the API/UI envelope boundary.
|
||||||
|
|
||||||
This page governs error-handling behavior for:
|
### Internal runtime categories
|
||||||
|
|
||||||
- UI interactions (NiceGUI pages)
|
- `validation_error`
|
||||||
- API endpoints (FastAPI routes)
|
- `user_input_error`
|
||||||
- application services and orchestration logic
|
- `not_found_error`
|
||||||
- in-process background worker execution
|
- `conflict_error`
|
||||||
- external provider adapters and persistence adapters
|
- `external_provider_error`
|
||||||
|
- `external_timeout_error`
|
||||||
|
- `processing_error`
|
||||||
|
- `infrastructure_transient_error`
|
||||||
|
- `infrastructure_persistent_error`
|
||||||
|
- `internal_unexpected_error`
|
||||||
|
|
||||||
If implementation behavior conflicts with this document, this document is authoritative and implementation should be updated.
|
### Internal -> Canonical mapping
|
||||||
|
|
||||||
## Core Principles
|
| Internal category | Canonical envelope category |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `validation_error` | `validation` |
|
||||||
|
| `user_input_error` | `validation` |
|
||||||
|
| `not_found_error` | `not_found` |
|
||||||
|
| `conflict_error` | `conflict` |
|
||||||
|
| `external_provider_error` | `external` |
|
||||||
|
| `external_timeout_error` | `timeout` |
|
||||||
|
| `infrastructure_transient_error` | `timeout` |
|
||||||
|
| `processing_error` | `internal` |
|
||||||
|
| `infrastructure_persistent_error` | `internal` |
|
||||||
|
| `internal_unexpected_error` | `internal` |
|
||||||
|
|
||||||
- **Clarity first:** user-facing messages should explain what failed in plain language.
|
`ExecutionAttempt.error_category` stores the internal category value so diagnostics remain specific.
|
||||||
- **Actionability required:** each surfaced error should include a suggested next step.
|
|
||||||
- **Safety by default:** internal details are logged; sensitive details are not exposed by default in UI/API.
|
|
||||||
- **Consistency across boundaries:** category and structure should remain stable from source to surface.
|
|
||||||
- **Fail explicitly:** silent failure is prohibited.
|
|
||||||
- **Traceability:** every non-trivial error should be traceable with an error reference ID.
|
|
||||||
|
|
||||||
## Error Taxonomy
|
## Translation Boundaries
|
||||||
|
|
||||||
The system uses stable, implementation-independent categories:
|
- **Provider layer:** raise provider-scoped exceptions with provider context; do not emit UI text.
|
||||||
|
- **Service layer:** map raw exceptions into internal categories and preserve causal chain.
|
||||||
|
- **UI/API layer:** convert internal categories to canonical categories using the centralized mapping.
|
||||||
|
|
||||||
| Category | Definition | Typical Source | Retriable |
|
## Decision Context
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `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 |
|
|
||||||
| `conflict_error` | Requested operation violates current state constraints | invalid state transition | no |
|
|
||||||
| `external_provider_error` | External AI/provider call fails | upstream HTTP/API/provider failures | sometimes |
|
|
||||||
| `infrastructure_transient_error` | Temporary environment issue | network timeout, DB connection reset | yes |
|
|
||||||
| `infrastructure_persistent_error` | Non-transient environment issue | missing permissions, misconfiguration | no |
|
|
||||||
| `internal_unexpected_error` | Unhandled defect or unknown failure | uncaught exceptions, logic errors | unknown (default no) |
|
|
||||||
|
|
||||||
### Classification Rules
|
### Why taxonomy is category-based (not exception-class-based)
|
||||||
|
|
||||||
- Classification occurs as close as possible to the origin boundary.
|
- Categories encode operator-facing recovery semantics (fix input, retry later, investigate internal failure) independent of low-level exception type.
|
||||||
- Provider-specific exceptions must be normalized into taxonomy categories before crossing service boundaries.
|
- This keeps retry and messaging behavior consistent even when provider/client libraries change.
|
||||||
- Unknown exceptions are classified as `internal_unexpected_error` and logged with traceback.
|
|
||||||
- Category names are stable contracts and must not be changed casually.
|
|
||||||
|
|
||||||
## User-Facing Error Experience Contract
|
### Why page-level failure is isolated
|
||||||
|
|
||||||
When an error is shown in the GUI, it must include:
|
- Multi-page archival documents often contain a mix of readable and degraded pages.
|
||||||
|
- Isolating failures to page scope preserves successful results and avoids all-or-nothing loss when one page fails.
|
||||||
|
- Aggregate job status then communicates overall outcome (`transcribed`, `partial_success`, `failed`) without hiding page detail.
|
||||||
|
|
||||||
1. **Title** (short context, e.g., “Upload failed”)
|
### Why retries append evidence instead of mutating rows
|
||||||
2. **Message** (plain-language explanation)
|
|
||||||
3. **Suggested action** (explicit next step)
|
|
||||||
4. **Error reference ID** (for support/debug traceability)
|
|
||||||
5. **Technical details** (optional/collapsible for advanced users)
|
|
||||||
|
|
||||||
### UI Message Rules
|
- Retry operations are new observations, not corrections of history.
|
||||||
|
- Appending attempts preserves forensic traceability, timing history, and provider variability analysis.
|
||||||
|
- Projection updates remain explicit user/workflow decisions, separate from immutable evidence.
|
||||||
|
|
||||||
- Do not expose raw stack traces by default.
|
## Job and Page Failure Semantics
|
||||||
- Do not expose secrets, credentials, connection strings, or filesystem internals unless explicitly in debug tooling.
|
|
||||||
- Prefer domain language over implementation language.
|
|
||||||
- Use persistent visibility for important failures (dialog/card), not only transient toasts.
|
|
||||||
|
|
||||||
### Suggested Action Requirements
|
### Page-Level (`JobSource`)
|
||||||
|
|
||||||
Every user-visible error must include a suggested course of action, such as:
|
- `pending` -> `transcribed` when attempt succeeds.
|
||||||
|
- `pending` -> `failed` when attempt fails terminally.
|
||||||
|
- `pending` -> `cancelled` on job cancellation before processing.
|
||||||
|
|
||||||
- retry the operation
|
### Job-Level (`Job`)
|
||||||
- check file type/size constraints
|
|
||||||
- refresh the jobs page
|
|
||||||
- verify environment configuration
|
|
||||||
- contact operator with error ID and timestamp
|
|
||||||
|
|
||||||
## API Error Response Contract
|
- `transcribed` when all pages transcribe successfully.
|
||||||
|
- `partial_success` when mixed success/failure outcomes exist.
|
||||||
|
- `failed` when no page transcribes successfully.
|
||||||
|
|
||||||
API errors should return a structured envelope with stable fields:
|
## Retry and Retranscription Rules
|
||||||
|
|
||||||
- `error_id`: short unique reference ID
|
1. Failed/cancelled pages may be re-queued through retranscription workflows.
|
||||||
- `category`: taxonomy category
|
2. Retry attempts must append new `ExecutionAttempt` rows; prior evidence remains immutable.
|
||||||
- `message`: safe human-readable summary
|
3. Selecting a better candidate must update projection pointers, not mutate historical attempt rows.
|
||||||
- `suggestion`: recommended next step
|
|
||||||
- `details`: optional, only when safe and appropriate
|
|
||||||
- `timestamp`: UTC ISO-8601
|
|
||||||
|
|
||||||
HTTP status mapping guidance:
|
## Logging and Diagnostics Rules
|
||||||
|
|
||||||
- `validation_error`, `user_input_error` -> `400`
|
1. Persist sufficient attempt error metadata (`error_category`, `error_message`, transport evidence) for post-hoc analysis.
|
||||||
- `not_found_error` -> `404`
|
2. Avoid leaking stack traces or local paths into user-facing message envelopes.
|
||||||
- `conflict_error` -> `409`
|
3. Preserve causal exception chains for internal diagnostics.
|
||||||
- `external_provider_error` -> `502` or `503` (depending on failure mode)
|
|
||||||
- `infrastructure_transient_error` -> `503`
|
|
||||||
- `infrastructure_persistent_error` -> `500`
|
|
||||||
- `internal_unexpected_error` -> `500`
|
|
||||||
|
|
||||||
## Logging And Observability Contract
|
## Operator Recovery Guidance
|
||||||
|
|
||||||
All logged errors must include, where available:
|
- **validation/conflict:** correct input or state and retry manually.
|
||||||
|
- **external/timeout:** allow bounded retries and keep prior attempt evidence visible.
|
||||||
|
- **internal:** stop automatic retries, surface a safe message, and inspect diagnostics with correlation context.
|
||||||
|
|
||||||
- `error_id`
|
## UI Messaging Contract
|
||||||
- `category`
|
|
||||||
- `operation` (e.g., `upload.submit`, `worker.process_job`, `jobs.refresh`)
|
|
||||||
- `exception_type`
|
|
||||||
- `job_id`, `document_id` (when relevant)
|
|
||||||
- UTC timestamp
|
|
||||||
|
|
||||||
Rules:
|
- User-visible errors must be actionable, bounded, and category-consistent.
|
||||||
|
- Multi-page jobs must show partial outcomes instead of collapsing into a single opaque failure.
|
||||||
|
- Recovery actions (`retry`, `retranscribe`, `edit input`) must be offered where available.
|
||||||
|
|
||||||
- Use structured logging fields where practical.
|
## Cross-Reference
|
||||||
- Use full traceback for unexpected errors (`internal_unexpected_error`).
|
|
||||||
- Log at boundary handoff points to preserve causal trail.
|
|
||||||
- Avoid duplicate noisy logging for the same exception at every layer.
|
|
||||||
|
|
||||||
## Recovery And Retry Policy
|
- [Error Handling invariant](../invariant/error_handling.md)
|
||||||
|
- [System Requirements](requirements.md)
|
||||||
### Retriable Conditions
|
- [Data Model](schema.md)
|
||||||
|
|
||||||
Retriable failures include:
|
|
||||||
|
|
||||||
- transient network/provider timeouts
|
|
||||||
- intermittent provider unavailability
|
|
||||||
- temporary DB/network interruptions
|
|
||||||
|
|
||||||
### Non-Retriable Conditions
|
|
||||||
|
|
||||||
Non-retriable failures include:
|
|
||||||
|
|
||||||
- invalid file formats
|
|
||||||
- missing required data
|
|
||||||
- permission/configuration failures
|
|
||||||
- deterministic domain conflicts
|
|
||||||
|
|
||||||
### Worker Behavior
|
|
||||||
|
|
||||||
- The worker must classify and persist failure details consistently.
|
|
||||||
- Retries should be bounded by configured limits.
|
|
||||||
- Exhausted retries must end in explicit failed status with recorded reason.
|
|
||||||
- No infinite retry loops are allowed.
|
|
||||||
|
|
||||||
## Boundary-Specific Responsibilities
|
|
||||||
|
|
||||||
### UI Layer
|
|
||||||
|
|
||||||
Responsibility:
|
|
||||||
|
|
||||||
- display user-safe error summaries and suggested actions
|
|
||||||
- show persistent error visibility for critical failures
|
|
||||||
- include error reference IDs in visible output
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- low-level exception parsing
|
|
||||||
- provider-specific protocol interpretation
|
|
||||||
|
|
||||||
### API Layer
|
|
||||||
|
|
||||||
Responsibility:
|
|
||||||
|
|
||||||
- map application exceptions into stable error envelopes and HTTP statuses
|
|
||||||
- preserve category and error_id continuity
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- domain-specific remediation logic
|
|
||||||
|
|
||||||
### Service Layer
|
|
||||||
|
|
||||||
Responsibility:
|
|
||||||
|
|
||||||
- classify domain and infrastructure exceptions
|
|
||||||
- convert adapter-specific failures into taxonomy categories
|
|
||||||
- return deterministic error types to callers
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- presentation formatting for UI
|
|
||||||
|
|
||||||
### Worker Layer
|
|
||||||
|
|
||||||
Responsibility:
|
|
||||||
|
|
||||||
- execute retry policy for retriable failures
|
|
||||||
- persist terminal failure details for jobs
|
|
||||||
- emit operational logs with category and identifiers
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- direct UI messaging
|
|
||||||
|
|
||||||
### Provider Adapter Layer
|
|
||||||
|
|
||||||
Responsibility:
|
|
||||||
|
|
||||||
- normalize provider SDK/HTTP failures into domain-neutral exceptions
|
|
||||||
- preserve raw provider context for logs (safely)
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- choosing user-facing wording
|
|
||||||
|
|
||||||
## Error Lifecycle Workflow
|
|
||||||
|
|
||||||
Standard lifecycle:
|
|
||||||
|
|
||||||
1. Failure occurs at a boundary or operation.
|
|
||||||
2. Exception is classified into taxonomy category.
|
|
||||||
3. `error_id` is created (or propagated).
|
|
||||||
4. Error is logged with required structured fields.
|
|
||||||
5. User/API receives safe message + suggested action.
|
|
||||||
6. Persistent job/resource state is updated when applicable.
|
|
||||||
7. Tests verify contract behavior for the pathway.
|
|
||||||
|
|
||||||
## Test Strategy For Error Handling
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
- category classification behavior
|
|
||||||
- retry eligibility decisions
|
|
||||||
- exception-to-message mapping safety
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
- UI pathways show clear message + suggested action for known failures
|
|
||||||
- API returns structured error envelope with expected status/category
|
|
||||||
- worker persists failed status and failure detail as required
|
|
||||||
|
|
||||||
### Regression Tests
|
|
||||||
|
|
||||||
- each previously observed production issue should have a guarding test
|
|
||||||
- contract tests must cover adapter error normalization behavior
|
|
||||||
|
|
||||||
## Known Failure Patterns And Prescribed Responses
|
|
||||||
|
|
||||||
| Pattern | Category | User Message | Suggested Action |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Upload payload cannot be parsed by UI handler | `internal_unexpected_error` (until narrowed) | Upload failed due to unexpected processing error | Retry once; if repeated, report error ID and check runtime version compatibility |
|
|
||||||
| Unsupported extension | `user_input_error` | File type is not supported | Upload JPG, PNG, TIFF, or PDF |
|
|
||||||
| Empty file upload | `validation_error` | Uploaded file is empty | Choose a valid non-empty file and retry |
|
|
||||||
| Provider timeout | `external_provider_error` or `infrastructure_transient_error` | Transcription provider timed out | Retry from jobs page; if repeated, check provider status |
|
|
||||||
| Job lookup missing | `not_found_error` | Requested job was not found | Refresh jobs list and open a valid job |
|
|
||||||
|
|
||||||
## Governance And Update Process
|
|
||||||
|
|
||||||
This document is a living policy artifact.
|
|
||||||
|
|
||||||
Update this document when:
|
|
||||||
|
|
||||||
- new error categories are introduced
|
|
||||||
- handling behavior changes at any boundary
|
|
||||||
- a production incident reveals missing guidance
|
|
||||||
- API/UI error contracts change
|
|
||||||
|
|
||||||
Change requirements:
|
|
||||||
|
|
||||||
- update this document and associated tests in the same change set
|
|
||||||
- preserve taxonomy stability; if changed, document migration impact
|
|
||||||
- record noteworthy policy changes in project release notes or changelog
|
|
||||||
|
|
||||||
## Related Pages
|
|
||||||
|
|
||||||
- [System overview](index.md)
|
|
||||||
- [Architecture](architecture.md)
|
|
||||||
- [Requirements](requirements.md)
|
|
||||||
- [Intent](intent.md)
|
|
||||||
|
|
||||||
## Glossary
|
|
||||||
|
|
||||||
- Error category: Stable classification used to drive handling, messaging, and status mapping.
|
|
||||||
- Error envelope: Structured API payload describing a failure.
|
|
||||||
- Error reference ID: Short identifier used to correlate user-visible failure with logs.
|
|
||||||
- Retriable error: Failure likely to succeed on a later attempt without code changes.
|
|
||||||
- Terminal failure: Failure state after retries are exhausted or retry is not allowed.
|
|
||||||
|
|||||||
+16
-50
@@ -1,57 +1,23 @@
|
|||||||
## Document Transcription System
|
# Document Transcription System Overview (Version 4)
|
||||||
|
|
||||||
This project is a production application for transcribing and preserving historical family documents. It is intentionally designed for personal-scale use, with a simplicity-first architecture that is easy to operate and easy to extend.
|
This directory is the single source of truth for current Version 4 behavior and architecture.
|
||||||
|
|
||||||
## Start Here
|
## Canonical Reading Order
|
||||||
|
|
||||||
Read [architecture.md](architecture.md) first.
|
1. [System Architecture](architecture.md) for runtime topology, boundaries, and lifecycle ownership.
|
||||||
|
2. [System Requirements](requirements.md) for verifiable current-state requirements.
|
||||||
|
3. [Data Model](schema.md) for entities, constraints, and evidence persistence rules.
|
||||||
|
4. [Error Handling Policy](error_handling.md) for category, translation, and retry behavior.
|
||||||
|
|
||||||
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.
|
## Cross-Version Invariants
|
||||||
|
|
||||||
The architecture page is the primary technical reference and defines:
|
- [Historical Document Transcription Design Intent](../invariant/intent.md)
|
||||||
|
- [Transcription Methodology](../invariant/transcription_methodology.md)
|
||||||
|
- [Error Handling](../invariant/error_handling.md)
|
||||||
|
- [Digital Evidence and AI Processing Provenance](../invariant/ai_evidence_and_provenance.md)
|
||||||
|
- [UI Style Guide](../invariant/ui_style_guide.md)
|
||||||
|
|
||||||
- deployed topology and infrastructure limits
|
## Baseline Statement
|
||||||
- module boundaries and dependency flow
|
|
||||||
- processing life cycle and data ownership
|
|
||||||
- test strategy, risk controls, and extension path
|
|
||||||
|
|
||||||
## What The Application Does
|
The current Version 4 baseline includes behavior delivered through the architectural cleanup phases.
|
||||||
|
Use this `docs/*` canonical set for active design and implementation decisions.
|
||||||
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.
|
|
||||||
|
|
||||||
Core capabilities:
|
|
||||||
|
|
||||||
- document upload and metadata capture
|
|
||||||
- asynchronous transcription with visible job status
|
|
||||||
- transcription prompt management with one Markdown file per prompt for human refinement over time
|
|
||||||
- revision history for transcript edits
|
|
||||||
- full-text search over accepted transcripts
|
|
||||||
- export of transcript data
|
|
||||||
|
|
||||||
## Production Operating Model
|
|
||||||
|
|
||||||
The system runs with minimal operational overhead:
|
|
||||||
|
|
||||||
- PostgreSQL in a dedicated Docker container is considered extremely lightweight and simple for this system
|
|
||||||
- MongoDB in a dedicated Docker container is also considered extremely lightweight and simple for document-centric persistence
|
|
||||||
- a three-container deployment (app, PostgreSQL, MongoDB) is a simple and acceptable baseline
|
|
||||||
- no required queue or search-engine containers in the baseline setup
|
|
||||||
|
|
||||||
This operating model keeps deployment and maintenance simple while preserving clean boundaries for future scale.
|
|
||||||
|
|
||||||
## Documentation Map
|
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
## Glossary
|
|
||||||
|
|
||||||
- Document-oriented persistence: Storing data as flexible records instead of fixed relational rows.
|
|
||||||
- Prompt artifact: A single Markdown file that defines one transcription prompt and is edited independently.
|
|
||||||
- System of record: The authoritative persistent store for canonical data.
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Digital Evidence and AI Processing Provenance (Invariant)
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document defines non-negotiable evidence and provenance rules for the transcription application.
|
||||||
|
|
||||||
|
The application exists to preserve historical source material and produce useful transcriptions without losing the ability to inspect, reinterpret, or reprocess the evidence later. Provider integrations, model names, schemas, and user interfaces may change; the principles below must remain true.
|
||||||
|
|
||||||
|
## 2. Evidence Model
|
||||||
|
|
||||||
|
The application distinguishes five kinds of information:
|
||||||
|
|
||||||
|
1. **Source evidence**: the canonical stored media used for processing and the facts needed to identify and verify it.
|
||||||
|
2. **Execution specification**: the frozen instructions, parameters, source identity, and software context for one processing attempt.
|
||||||
|
3. **Transport evidence**: the response received at the application/provider boundary, including safe protocol metadata.
|
||||||
|
4. **Normalized data**: selected fields extracted for search, display, accounting, and workflow behavior.
|
||||||
|
5. **Derived artifacts**: outputs produced from source evidence, such as transcription text, OCR geometry, confidence data, layout analysis, or entity extraction.
|
||||||
|
|
||||||
|
Normalized data and derived artifacts never replace source or transport evidence.
|
||||||
|
|
||||||
|
## 3. Core Invariants
|
||||||
|
|
||||||
|
### 3.1 Canonical Source Preservation
|
||||||
|
|
||||||
|
1. Each source must have one canonical stored byte stream used for processing and provenance.
|
||||||
|
2. Canonical storage may apply deterministic ingest normalization before persistence.
|
||||||
|
3. Canonical stored bytes must have a cryptographic content digest, byte size, and stable identity.
|
||||||
|
4. Post-ingest processing derivatives must not overwrite canonical stored bytes.
|
||||||
|
5. Moving or renaming a stored file must not change its evidence identity.
|
||||||
|
|
||||||
|
### 3.2 Append-Only Processing History
|
||||||
|
|
||||||
|
1. Every processing attempt must have a distinct execution record, whether it succeeds, partially succeeds, times out, or fails.
|
||||||
|
2. A later attempt must not overwrite the evidence from an earlier attempt.
|
||||||
|
3. A convenient “latest transcription” value may be maintained as a cache or projection, but it is not the authoritative execution history.
|
||||||
|
4. Human revisions must remain distinguishable from all machine-generated outputs.
|
||||||
|
5. Reprocessing a source must create new evidence rather than rewriting historical evidence.
|
||||||
|
|
||||||
|
### 3.3 Frozen Execution Specification
|
||||||
|
|
||||||
|
Each execution must preserve enough information to understand what the application asked the processor to do:
|
||||||
|
|
||||||
|
1. Requested provider, model, and provider-routing constraints.
|
||||||
|
2. Full effective system and user instructions.
|
||||||
|
3. Prompt asset name and content digest when a prompt asset is used.
|
||||||
|
4. Every explicitly supplied generation or processing parameter.
|
||||||
|
5. Whether an optional parameter was explicitly set or omitted.
|
||||||
|
6. Canonical source digest (and derivative digests when used), media type, dimensions or page geometry when known, and page identity.
|
||||||
|
7. A secret-safe representation of the request structure.
|
||||||
|
8. Application, provider-adapter, and client-library versions sufficient to interpret the execution.
|
||||||
|
|
||||||
|
The execution specification must not contain credentials, authorization headers, secret query values, or unnecessary duplicate source binaries.
|
||||||
|
|
||||||
|
### 3.4 Evidence-Layer Terminology
|
||||||
|
|
||||||
|
The following terms are not interchangeable:
|
||||||
|
|
||||||
|
- **Transport response**: the status, safe headers, and exact response body received by the application at its HTTP boundary.
|
||||||
|
- **Router-normalized response**: a response transformed by an intermediary into its common schema.
|
||||||
|
- **SDK-parsed response**: an object created when a client library validates or filters a response.
|
||||||
|
- **Normalized metadata**: application-selected fields derived from a response.
|
||||||
|
- **Native provider response**: the upstream provider's own response before any intermediary transformation.
|
||||||
|
|
||||||
|
The application and its documentation must identify which layer is stored. A response must not be described as “raw,” “complete,” or “native” without naming the boundary at which that claim is true.
|
||||||
|
|
||||||
|
### 3.5 Transport Evidence
|
||||||
|
|
||||||
|
1. Preserve the exact successful response body received at the application's transport boundary before SDK model parsing can discard unknown fields.
|
||||||
|
2. Preserve the response status and an allowlisted set of non-secret headers needed for correlation, content interpretation, rate-limit diagnosis, or audit.
|
||||||
|
3. Preserve provider/router request and generation identifiers when available.
|
||||||
|
4. Preserve safe response evidence for unsuccessful calls when a response was received.
|
||||||
|
5. Record explicitly when no response was received, such as a local timeout or connection failure.
|
||||||
|
6. Retain parsed and normalized forms only as additional representations of the preserved response.
|
||||||
|
|
||||||
|
Wire-level packet capture, TLS session data, credentials, and unrestricted headers are neither required nor permitted.
|
||||||
|
|
||||||
|
These requirements apply to executions performed after transport capture is implemented. For earlier executions, the absence of transport evidence must be represented explicitly. An SDK snapshot or normalized record must never be relabeled or backfilled as transport evidence.
|
||||||
|
|
||||||
|
### 3.6 Derived Artifact Provenance
|
||||||
|
|
||||||
|
1. Every derived artifact must identify its source evidence and producing execution.
|
||||||
|
2. Each artifact must declare its semantic type, media/serialization format, schema name and version, producer, producer version, and creation time.
|
||||||
|
3. Artifact content must be stored directly or referenced by a stable path or object identifier and protected by a cryptographic digest.
|
||||||
|
4. Coordinates must declare their coordinate system, units, origin, page/image dimensions, and transformation history.
|
||||||
|
5. Confidence values must identify the producer and scope to which they apply; values from different producers must not be treated as directly comparable without validation.
|
||||||
|
6. Provider-specific payloads may be retained, but durable application behavior must not depend on undocumented provider fields.
|
||||||
|
|
||||||
|
This model must accommodate future OCR text, word or line polygons, layout regions, confidence data, alternate transcriptions, and structured extraction without adding a dedicated column for every possible feature.
|
||||||
|
|
||||||
|
### 3.7 Integrity and Auditability
|
||||||
|
|
||||||
|
1. Stored evidence must be exportable with enough identifiers and metadata to verify relationships and digests outside the application.
|
||||||
|
2. Evidence mutation, deletion, and retention behavior must be explicit and testable.
|
||||||
|
3. Schema upgrades must preserve existing evidence and its original meaning.
|
||||||
|
4. Backfills must be identified as backfills; they must not imply that previously uncaptured evidence existed.
|
||||||
|
5. Integrity verification must distinguish a missing file, digest mismatch, unavailable external artifact, and malformed metadata.
|
||||||
|
|
||||||
|
### 3.8 Security and Privacy
|
||||||
|
|
||||||
|
1. API keys, authorization headers, cookies, and credentials must never be persisted as provenance.
|
||||||
|
2. Persist only headers and metadata fields that appear on an explicit allowlist of known-safe fields. Discard all other fields before storage; never persist an unrestricted capture and attempt to redact it afterward.
|
||||||
|
3. Request manifests should reference source content by identity instead of duplicating base64 source data.
|
||||||
|
4. Diagnostic displays and exports must avoid exposing secrets or machine-local details that are not necessary for evidence interpretation.
|
||||||
|
|
||||||
|
## 4. Reproducibility Limits
|
||||||
|
|
||||||
|
Provenance supports explanation, comparison, and best-effort reproduction; it does not guarantee identical output.
|
||||||
|
|
||||||
|
Identical requests may produce different results because of model updates, provider routing, nondeterministic computation, undocumented defaults, safety systems, or retired endpoints. The application must preserve whether a parameter was omitted rather than pretending to know the provider default used at that time.
|
||||||
|
|
||||||
|
Likewise, preserving a general vision-model response does not create OCR coordinates that were never returned. Future coordinate extraction remains possible because canonical source evidence is preserved and can be processed again by a suitable system.
|
||||||
|
|
||||||
|
## 5. Model Evaluation Policy
|
||||||
|
|
||||||
|
Model selection must be based on a representative sample of the actual archive rather than vendor claims alone.
|
||||||
|
|
||||||
|
Evaluation should:
|
||||||
|
|
||||||
|
1. Use manually reviewed reference transcriptions following the project's [Transcription Methodology](transcription_methodology.md).
|
||||||
|
2. Represent printed, typed, handwritten, degraded, tabular, multilingual, and spatially complex material present in the archive.
|
||||||
|
3. Measure character and word error rates where appropriate.
|
||||||
|
4. Separately record silent corrections, invented text, omitted text, uncertainty handling, layout fidelity, cost, and latency.
|
||||||
|
5. Preserve the exact model, endpoint or route, parameters, prompt, source digest, and scoring method for every comparison.
|
||||||
|
6. Treat model rankings as corpus- and version-specific, not permanent declarations of a universal “best” model.
|
||||||
|
|
||||||
|
Benchmark material containing family records remains private application data unless explicitly approved for publication.
|
||||||
|
|
||||||
|
## 6. Ownership and Change Policy
|
||||||
|
|
||||||
|
1. Canonical V4 architecture, schema, requirements, and error-policy documents define how current behavior satisfies this invariant.
|
||||||
|
2. Provider adapters own the capture of provider-boundary evidence.
|
||||||
|
3. Services own validation, persistence, retention, and export behavior.
|
||||||
|
4. UI pages may inspect evidence through service contracts but do not define evidence semantics.
|
||||||
|
5. If implementation conflicts with this invariant, either correct the implementation or explicitly revise this document before accepting the behavior.
|
||||||
|
6. Revisions to this document require deliberate review because they change the long-term preservation contract.
|
||||||
|
|
||||||
|
## 7. Related Invariants
|
||||||
|
|
||||||
|
- [Historical Document Transcription Design Intent](intent.md)
|
||||||
|
- [Transcription Methodology & Style Guide](transcription_methodology.md)
|
||||||
|
- [UI Style Guide](ui_style_guide.md)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Error Handling (Invariant)
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document defines the non-negotiable failure-handling principles for the transcription application.
|
||||||
|
|
||||||
|
Error categories, API envelopes, status codes, framework integrations, and persistence fields may change between versions. Failures must nevertheless remain visible, safe, diagnosable, and consistent across every application boundary.
|
||||||
|
|
||||||
|
## 2. Core Invariants
|
||||||
|
|
||||||
|
### 2.1 Failures Are Visible
|
||||||
|
|
||||||
|
1. An operation must not report success when all or part of the requested work failed.
|
||||||
|
2. Invalid input, unavailable dependencies, persistence failures, provider failures, and unexpected defects must be surfaced through the application's established error path.
|
||||||
|
3. Code must not silently discard an exception, provider response, invalid value, or failed state transition.
|
||||||
|
4. When work can partially succeed, the successful and failed portions must be identified separately.
|
||||||
|
|
||||||
|
### 2.2 Messages Are Actionable
|
||||||
|
|
||||||
|
1. Operator-facing errors must explain what failed in concise language.
|
||||||
|
2. When a safe corrective action is known, the error must state it.
|
||||||
|
3. Expected validation or conflict failures must not be presented as unexplained internal defects.
|
||||||
|
4. Internal diagnostics must not replace a usable operator-facing message.
|
||||||
|
|
||||||
|
### 2.3 Errors Have Stable Identity and Classification
|
||||||
|
|
||||||
|
1. Every surfaced failure must have a stable correlation identifier or equivalent trace identity.
|
||||||
|
2. Failures must be classified into a documented, machine-readable category.
|
||||||
|
3. Boundary-specific representations must preserve the original category and correlation identity.
|
||||||
|
4. Unknown exceptions must be converted at an explicit boundary, retain their causal chain for diagnostics, and be classified as unexpected rather than disguised as an expected failure.
|
||||||
|
|
||||||
|
### 2.4 Boundary Translation Is Consistent
|
||||||
|
|
||||||
|
1. UI, API, service, worker, persistence, and provider boundaries must use one shared error model or deterministic translations between documented models.
|
||||||
|
2. A boundary may simplify presentation, but it must not change the meaning, retryability, or identity of a failure.
|
||||||
|
3. Domain and service code must not depend on UI notifications or HTTP response types.
|
||||||
|
4. UI and API layers must not infer error categories by parsing message text.
|
||||||
|
|
||||||
|
### 2.5 State Changes Are Safe
|
||||||
|
|
||||||
|
1. A failed atomic operation must leave persisted state unchanged.
|
||||||
|
2. Batch operations may preserve successful independent items only when partial success is an explicit part of the workflow contract.
|
||||||
|
3. A failed item must retain enough state to identify what was attempted and whether retry is safe.
|
||||||
|
4. Error handling must not overwrite earlier successful results or historical execution evidence.
|
||||||
|
|
||||||
|
### 2.6 Retry Is Explicit and Bounded
|
||||||
|
|
||||||
|
1. Validation, authorization, policy, conflict, and other deterministic failures must not be retried automatically without a relevant input or state change.
|
||||||
|
2. Automatic retry is permitted only for failures classified as transient and only when the operation is idempotent or otherwise protected from duplicate effects.
|
||||||
|
3. Retry count, delay, and terminal behavior must be bounded and observable.
|
||||||
|
4. Exhausted retries must end in a visible terminal failure rather than an indefinitely pending state.
|
||||||
|
|
||||||
|
### 2.7 Diagnostics Are Preserved Safely
|
||||||
|
|
||||||
|
1. Logs and persisted diagnostic evidence must retain enough context to correlate the failure with the affected operation and record.
|
||||||
|
2. Provider and infrastructure failures must preserve safe diagnostic evidence at the boundary where it is available.
|
||||||
|
3. Credentials, authorization headers, cookies, secret values, and unnecessary personal data must not appear in errors, logs, notifications, or exports.
|
||||||
|
4. Diagnostic metadata capture must use explicit safe-field allowlists where unrestricted content could contain secrets.
|
||||||
|
5. User-facing messages must not expose stack traces, local filesystem details, database credentials, or raw internal exceptions.
|
||||||
|
|
||||||
|
AI execution failures also follow the evidence rules in [Digital Evidence and AI Processing Provenance](ai_evidence_and_provenance.md).
|
||||||
|
|
||||||
|
### 2.8 Cancellation and Timeout Are Distinct Outcomes
|
||||||
|
|
||||||
|
1. User cancellation, application shutdown, local timeout, remote timeout, and provider rejection must remain distinguishable.
|
||||||
|
2. Cancellation must not be converted into success or a generic unexpected error.
|
||||||
|
3. Timeout handling must identify whether a provider response was received when that fact is known.
|
||||||
|
4. Cleanup after cancellation or timeout must preserve consistency and must not conceal a completed side effect.
|
||||||
|
|
||||||
|
### 2.9 Logging Must Support Audit Without Becoming the Record
|
||||||
|
|
||||||
|
1. Structured logs must include correlation identity, operation, category, and relevant non-secret record identifiers.
|
||||||
|
2. Expected operator errors may be logged less severely than unexpected defects, but they must remain observable.
|
||||||
|
3. Logs are operational diagnostics and do not replace required database state or archival evidence.
|
||||||
|
4. Duplicate logging of the same failure at every layer should be avoided; ownership of the authoritative log event must be clear.
|
||||||
|
|
||||||
|
## 3. Verification Policy
|
||||||
|
|
||||||
|
Each version must verify:
|
||||||
|
|
||||||
|
1. Every documented error category reaches the intended UI and API representation.
|
||||||
|
2. Failed atomic writes roll back completely.
|
||||||
|
3. Partial-success workflows preserve successful independent results and identify failed items.
|
||||||
|
4. Retry behavior is bounded and restricted to eligible failures.
|
||||||
|
5. Unexpected exceptions retain correlation and causal information without exposing sensitive details.
|
||||||
|
6. Logs, persisted evidence, UI messages, and exports contain no credentials.
|
||||||
|
7. Cancellation, timeout, provider response failure, and no-response failure remain distinguishable.
|
||||||
|
|
||||||
|
## 4. Versioned Ownership
|
||||||
|
|
||||||
|
1. Version-specific error taxonomies, envelopes, HTTP mappings, model fields, and framework behavior belong in the applicable version documentation.
|
||||||
|
2. Each versioned error-handling document must state how it satisfies this invariant.
|
||||||
|
3. A version may add stricter safeguards but must not weaken these principles without first revising this invariant deliberately.
|
||||||
|
4. Implementation and tests must be updated together when a versioned error contract changes.
|
||||||
|
|
||||||
|
## 5. Related Invariants
|
||||||
|
|
||||||
|
- [Historical Document Transcription Design Intent](intent.md)
|
||||||
|
- [Digital Evidence and AI Processing Provenance](ai_evidence_and_provenance.md)
|
||||||
|
- [UI Style Guide](ui_style_guide.md)
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Historical Document Transcription Design Intent
|
||||||
|
I have several thousand pages of family history told through letters, postcards, books, and other documents that I want to transcribe to text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
1. Preserve our family history
|
||||||
|
2. Unburden my family (and descendants) from having to store and care for the physical media. Once the documents have been transcribed and organized, they can be donated (or kept by a family member that wants to retain and preserve them).
|
||||||
|
3. Make the document text easily available and easily searchable.
|
||||||
|
4. Ability create timelines for individuals and/or families through document dates or the data contained in them. Perhaps even use AI to generate biographies or family histories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source material
|
||||||
|
1. **letters, cards, diaries** - handwritten; mostly stored in boxes and tubs with little organization
|
||||||
|
2. **books** - typed or typeset; mostly self-published books 50-100 pages in length. This may be expanded to include selected pages from other publications.
|
||||||
|
3. **photos** - notes written on the backs of photos and the pages of photo albums
|
||||||
|
4. **other ephemera** - newspaper clippings, event programs, invitations, military records, immigration records, etc
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Methodology
|
||||||
|
|
||||||
|
1. Follow current best practices per **A Guide to Documentary Editing** by Mary-Jo Kline. (See [Transcription Methodology](transcription_methodology.md))
|
||||||
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# 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. |
|
||||||
|
|
||||||
|
### 3.4 Document-Body Medium
|
||||||
|
|
||||||
|
Every transcript must identify the predominant document-body medium exactly once at the beginning:
|
||||||
|
|
||||||
|
| Medium | Use | Standard Markup |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Handwritten** | The main body was written by hand. | `[document body handwritten]` |
|
||||||
|
| **Typewritten** | The main body was produced with a typewriter. Uneven impressions, monospaced characters, and mechanical defects remain typewritten rather than handwritten. | `[document body typewritten]` |
|
||||||
|
| **Typeset** | The main body was composed for printing or produced as printed text rather than with a typewriter. | `[document body typeset]` |
|
||||||
|
| **Mixed** | No single medium predominates, or handwritten and printed/typewritten content are structurally interleaved. | `[document body mixed]` |
|
||||||
|
|
||||||
|
- Use exactly one document-body marker.
|
||||||
|
- Do not wrap each line in `[handwritten: ...]` after declaring the body handwritten.
|
||||||
|
- In typewritten or typeset documents, use localized handwriting markers only for genuinely handwritten annotations, insertions, or signatures.
|
||||||
|
- In mixed documents, identify handwritten portions locally while preserving their reading context.
|
||||||
|
- Preserve tables of contents, tables, forms, columns, captions, marginalia, page numbers, dotted leaders, and associated references in their logical reading order.
|
||||||
|
- Produce plain text characters rather than HTML entities for ordinary transcription content.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# UI Style Guide (Invariant)
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
This guide defines non-negotiable UI styling rules for the transcription application.
|
||||||
|
|
||||||
|
The design system is token-first and class-driven:
|
||||||
|
1. Theme tokens are defined in [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css).
|
||||||
|
2. Python UI code composes semantic classes instead of inline color values.
|
||||||
|
3. Pages and components should share a single visual language across Documents, Jobs, People, and Sources flows.
|
||||||
|
|
||||||
|
## 2. Source of Truth
|
||||||
|
Use these files as the style authority:
|
||||||
|
1. [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css) for color tokens, semantic utility classes, table styles, and viewer surfaces.
|
||||||
|
2. [src/transcription/ui/theme.py](src/transcription/ui/theme.py) for runtime NiceGUI theme bridge and shared UI helpers.
|
||||||
|
|
||||||
|
If this document conflicts with implementation, update this document to match the code immediately after intentional style changes.
|
||||||
|
|
||||||
|
## 3. Core Design Invariants
|
||||||
|
1. Flat, high-density surfaces over decorative depth.
|
||||||
|
2. Strong content hierarchy with subdued backgrounds and border-based separation.
|
||||||
|
3. Viewer area remains the highest contrast region in image/transcription workflows.
|
||||||
|
4. Primary actions are consistent and visually recognizable.
|
||||||
|
5. Accessible focus rings are always visible for keyboard users.
|
||||||
|
|
||||||
|
## 4. Token System
|
||||||
|
|
||||||
|
### 4.1 Palette Tokens
|
||||||
|
Base palette variables live under :root in [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css):
|
||||||
|
1. --palette-carbon-black: #1c2321
|
||||||
|
2. --palette-cool-steel: #7d98a1
|
||||||
|
3. --palette-blue-slate: #5e6572
|
||||||
|
4. --palette-powder-blue: #a9b4c2
|
||||||
|
5. --palette-platinum: #eef1ef
|
||||||
|
|
||||||
|
### 4.2 Semantic Theme Tokens
|
||||||
|
Do not style components directly with palette tokens when a semantic token exists.
|
||||||
|
|
||||||
|
Semantic tokens currently include:
|
||||||
|
1. --theme-text and --theme-text-muted
|
||||||
|
2. --theme-page, --theme-surface, --theme-surface-raised, --theme-surface-muted
|
||||||
|
3. --theme-border
|
||||||
|
4. --theme-primary and --theme-primary-hover
|
||||||
|
5. --theme-secondary and --theme-focus
|
||||||
|
6. --theme-inverse-text
|
||||||
|
7. --theme-viewer, --theme-viewer-border, --theme-viewer-muted
|
||||||
|
|
||||||
|
## 5. Approved Semantic Classes
|
||||||
|
|
||||||
|
### 5.1 Text and Background
|
||||||
|
1. ui-text-primary
|
||||||
|
2. ui-text-muted
|
||||||
|
3. ui-text-inverse
|
||||||
|
4. ui-bg-page
|
||||||
|
5. ui-bg-surface
|
||||||
|
6. ui-bg-surface-raised
|
||||||
|
7. ui-bg-surface-muted
|
||||||
|
8. ui-bg-viewer
|
||||||
|
9. ui-bg-viewer-overlay
|
||||||
|
10. ui-bg-viewer-overlay-soft
|
||||||
|
|
||||||
|
### 5.2 Borders and Surfaces
|
||||||
|
1. ui-border-subtle
|
||||||
|
2. ui-border-viewer
|
||||||
|
3. ui-header-divider
|
||||||
|
4. ui-card-surface
|
||||||
|
5. ui-row-surface
|
||||||
|
6. ui-note-box
|
||||||
|
7. ui-card-error
|
||||||
|
|
||||||
|
### 5.3 Interactive Elements
|
||||||
|
1. ui-btn-primary
|
||||||
|
2. ui-btn-secondary
|
||||||
|
3. ui-link-primary
|
||||||
|
4. ui-text-accent
|
||||||
|
5. ui-chip-primary
|
||||||
|
6. ui-badge-secondary
|
||||||
|
7. ui-status and ui-status--<status>
|
||||||
|
|
||||||
|
### 5.4 Table Patterns
|
||||||
|
1. ui-table
|
||||||
|
2. ui-table-header
|
||||||
|
3. ui-table-body
|
||||||
|
|
||||||
|
Use existing class combinations from [src/transcription/ui/components](src/transcription/ui/components) and [src/transcription/ui/pages](src/transcription/ui/pages) as reference implementations.
|
||||||
|
|
||||||
|
## 6. Legacy Class Policy
|
||||||
|
Legacy `vibe-` presentation classes are prohibited. Use `ui-` semantic classes from `theme.css`.
|
||||||
|
|
||||||
|
## 7. Prohibited Patterns
|
||||||
|
1. Inline hex colors in Python UI class strings or style blocks, except in isolated bridge code explicitly marked for migration.
|
||||||
|
2. Ad-hoc one-off class names that duplicate existing semantic class intent.
|
||||||
|
3. Page-specific palette forks that bypass theme tokens.
|
||||||
|
4. Hidden or low-contrast focus states on interactive controls.
|
||||||
|
5. Embedded `<style>` blocks or NiceGUI `.style(...)` calls in Python UI code.
|
||||||
|
6. Additional page- or component-specific stylesheets; `theme.css` is the single CSS source.
|
||||||
|
|
||||||
|
## 8. Implementation Rules For Contributors
|
||||||
|
1. Prefer composing existing semantic classes before creating new ones.
|
||||||
|
2. If a new class is required, add it to [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css) with a semantic name, then reuse it.
|
||||||
|
3. Keep behavior ownership in Python and appearance ownership in CSS.
|
||||||
|
4. Update UI tests that assert exact text or labels when intentional copy changes are made.
|
||||||
|
5. Avoid introducing class churn unrelated to the feature being changed.
|
||||||
|
|
||||||
|
## 9. Verification Checklist
|
||||||
|
Before merging UI changes, verify:
|
||||||
|
1. No new inline hex colors were introduced in UI pages/components.
|
||||||
|
2. New styles are token-backed and added to [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css).
|
||||||
|
3. Primary buttons, links, cards, and tables still render with consistent semantics.
|
||||||
|
4. Keyboard focus ring visibility is preserved.
|
||||||
|
5. Relevant UI and integration tests pass.
|
||||||
@@ -1,572 +0,0 @@
|
|||||||
# Step 1 Implementation Plan: `config.py` + `models.py` + `db.py`
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Establish the foundational data layer and configuration system that every subsequent MVP step builds on. At the end of this step, the project has a runnable Python package with a validated schema, typed configuration, and a test suite proving the data layer works — before any UI, worker, or AI provider code exists.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Prerequisite: Project Structure Scaffolding
|
|
||||||
|
|
||||||
Before writing any logic, create the package skeleton so imports work correctly.
|
|
||||||
|
|
||||||
### Files to create (empty `__init__.py` stubs)
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
└── transcription/
|
|
||||||
├── __init__.py
|
|
||||||
├── providers/
|
|
||||||
│ └── __init__.py
|
|
||||||
├── services/
|
|
||||||
│ └── __init__.py
|
|
||||||
└── ui/
|
|
||||||
└── __init__.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Files to create (with logic — the Step 1 deliverables)
|
|
||||||
|
|
||||||
```
|
|
||||||
src/transcription/config.py
|
|
||||||
src/transcription/models.py
|
|
||||||
src/transcription/db.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test files to create
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
├── __init__.py
|
|
||||||
├── conftest.py
|
|
||||||
├── test_config.py
|
|
||||||
├── test_models.py
|
|
||||||
└── test_db.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update `pyproject.toml`
|
|
||||||
|
|
||||||
Add the dependencies that Step 1 requires and won't change later:
|
|
||||||
|
|
||||||
```toml pyproject.toml
|
|
||||||
[project]
|
|
||||||
name = "transcription"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Historical document transcription system"
|
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
dependencies = [
|
|
||||||
"openrouter>=0.7.0",
|
|
||||||
"pydantic>=2.13.4",
|
|
||||||
"pydantic-settings>=2.9.1",
|
|
||||||
"sqlmodel>=0.0.25",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"pytest>=8.0",
|
|
||||||
"pytest-asyncio>=0.25",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
addopts = "--strict-markers -q"
|
|
||||||
markers = [
|
|
||||||
"unit: pure logic tests with no external dependencies",
|
|
||||||
"integration: tests that touch framework or database contracts",
|
|
||||||
"external: tests that call external services (slow, requires credentials)",
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Key additions:
|
|
||||||
- **`openrouter`** — official OpenRouter Python SDK used for model calls
|
|
||||||
- **`pydantic-settings`** — for `BaseSettings` with env-var loading (this was split out of `pydantic` core in v2)
|
|
||||||
- **`sqlmodel`** — provides SQLModel (which bundles SQLAlchemy + Pydantic model integration) and the SQLite driver
|
|
||||||
- **`pytest` + `pytest-asyncio`** — in `dev` extras for test execution
|
|
||||||
- **`[tool.pytest.ini_options]`** — strict marker checking enabled from the start; markers registered upfront per pytesting skill conventions
|
|
||||||
|
|
||||||
### Delete `hello.py`
|
|
||||||
|
|
||||||
The placeholder file is no longer needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. `config.py` — Centralized Configuration
|
|
||||||
|
|
||||||
**Satisfies:** REQ-8 (centralized config and logging at startup)
|
|
||||||
|
|
||||||
### Design Decisions
|
|
||||||
|
|
||||||
| Decision | Rationale |
|
|
||||||
|----------|-----------|
|
|
||||||
| Use `pydantic-settings` `BaseSettings` | Type-safe, validates on construction, loads from env vars and `.env` files automatically |
|
|
||||||
| `PROVIDER` constrained to `openrouter` for MVP | Keeps configuration explicit while avoiding premature multi-provider complexity |
|
|
||||||
| `OPENROUTER_API_KEY` required | Matches official SDK docs and avoids ambiguous provider-agnostic naming |
|
|
||||||
| `PROVIDER_MODEL` defaults to `None` | OpenRouter adapter (Step 3) supplies a sensible default when `None` |
|
|
||||||
| `OPENROUTER_HTTP_REFERER` and `OPENROUTER_APP_TITLE` optional | Matches SDK optional app-attribution fields |
|
|
||||||
| `DATABASE_URL` defaults to SQLite | Zero-setup local development; PostgreSQL swap is a single env-var change post-MVP |
|
|
||||||
| `UPLOAD_DIR` and `PROMPT_DIR` as `Path` objects | Enables `.mkdir(parents=True, exist_ok=True)` and path validation at startup |
|
|
||||||
| Logging configured via `logging.config.dictConfig` in `setup_logging()` | Centralized, explicit formatter/handler/root logger topology; called once at startup with `disable_existing_loggers=False` |
|
|
||||||
|
|
||||||
### Proposed Implementation
|
|
||||||
|
|
||||||
```python src/transcription/config.py
|
|
||||||
"""Centralized application configuration.
|
|
||||||
|
|
||||||
All settings are loaded from environment variables (or a .env file)
|
|
||||||
once at startup. Provider-specific defaults (model names, base URLs)
|
|
||||||
are resolved by the provider adapters, not here.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from enum import StrEnum
|
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
import logging
|
|
||||||
import logging.config
|
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class Provider(StrEnum):
|
|
||||||
OPENROUTER = "openrouter"
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=".env",
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- AI provider ---
|
|
||||||
provider: Provider = Provider.OPENROUTER
|
|
||||||
openrouter_api_key: str
|
|
||||||
provider_model: str | None = None
|
|
||||||
openrouter_http_referer: str | None = None
|
|
||||||
openrouter_app_title: str | None = None
|
|
||||||
|
|
||||||
# --- persistence ---
|
|
||||||
database_url: str = "sqlite:///./transcription.db"
|
|
||||||
|
|
||||||
# --- filesystem paths ---
|
|
||||||
upload_dir: Path = Path("./uploads")
|
|
||||||
prompt_dir: Path = Path("./prompts")
|
|
||||||
|
|
||||||
|
|
||||||
LOGGING_CONFIG: dict[str, object] = {
|
|
||||||
"version": 1,
|
|
||||||
"disable_existing_loggers": False,
|
|
||||||
"formatters": {
|
|
||||||
"standard": {
|
|
||||||
"format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
|
||||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"handlers": {
|
|
||||||
"console": {
|
|
||||||
"class": "logging.StreamHandler",
|
|
||||||
"formatter": "standard",
|
|
||||||
"stream": "ext://sys.stdout",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"level": "INFO",
|
|
||||||
"handlers": ["console"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
"""Return the singleton Settings instance.
|
|
||||||
|
|
||||||
Cached so the entire application shares one validated config.
|
|
||||||
"""
|
|
||||||
return Settings()
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging() -> None:
|
|
||||||
"""Configure root logging once at startup."""
|
|
||||||
logging.config.dictConfig(LOGGING_CONFIG)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Behaviors
|
|
||||||
|
|
||||||
- **Startup validation**: If `OPENROUTER_API_KEY` is missing from the environment, `Settings()` raises a `ValidationError` immediately — the app won't start with a missing key.
|
|
||||||
- **`.env` support**: Developers can create a `.env` file in the project root for local keys; it's never committed (already covered by the existing `.gitignore` pattern or a new entry).
|
|
||||||
- **`extra="ignore"`**: Unknown env vars don't cause errors, keeping the config resilient to unrelated environment variables.
|
|
||||||
- **`lru_cache`**: `get_settings()` is the single access point. All modules import and call this function rather than constructing `Settings` directly.
|
|
||||||
- **Centralized logging**: `setup_logging()` calls `dictConfig` exactly once at startup; all modules should use `logging.getLogger(__name__)` and avoid `basicConfig`.
|
|
||||||
|
|
||||||
### `.env` template (not committed — add to `.gitignore`)
|
|
||||||
|
|
||||||
```bash .env.example
|
|
||||||
PROVIDER=openrouter
|
|
||||||
OPENROUTER_API_KEY=sk-or-...
|
|
||||||
# PROVIDER_MODEL= # optional: OpenRouter adapter supplies default
|
|
||||||
# OPENROUTER_HTTP_REFERER=https://example.com
|
|
||||||
# OPENROUTER_APP_TITLE=Historical Transcription MVP
|
|
||||||
# DATABASE_URL=sqlite:///./transcription.db
|
|
||||||
# UPLOAD_DIR=./uploads
|
|
||||||
# PROMPT_DIR=./prompts
|
|
||||||
```
|
|
||||||
|
|
||||||
### `.gitignore` addition
|
|
||||||
|
|
||||||
```gitignore .gitignore
|
|
||||||
# ... existing entries ...
|
|
||||||
|
|
||||||
# Environment secrets
|
|
||||||
.env
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. `models.py` — SQLModel Domain Models
|
|
||||||
|
|
||||||
**Satisfies:** REQ-3 (persist and expose job states), REQ-4 (persist transcription output and failure details)
|
|
||||||
|
|
||||||
### Design Decisions
|
|
||||||
|
|
||||||
| Decision | Rationale |
|
|
||||||
|----------|-----------|
|
|
||||||
| Three models: `Document`, `Job`, `Transcript` | Minimal set from MVP Feature 5. One-to-many from Document→Job and one-to-one from Job→Transcript |
|
|
||||||
| `JobStatus` as a `StrEnum` | Readable in the database (`"queued"` not `1`), type-safe in Python, trivially serializable to JSON for the UI |
|
|
||||||
| Status values: `queued`, `processing`, `transcribed`, `failed` | Matches MVP Feature 2 lifecycle. REQ-3 also lists `upload` and `completed` — these are deferred to post-MVP when revision/review workflows exist |
|
|
||||||
| UUIDs for primary keys | Avoids auto-increment collision concerns if we later move to PostgreSQL; safe for distributed ID generation; `uuid4` is simple |
|
|
||||||
| `uploaded_at`, `created_at`, `updated_at` as UTC `datetime` | Timezone-naive UTC by convention for MVP. Sufficient for single-user, single-timezone operation |
|
|
||||||
| `Transcript.text` is nullable | A failed job creates a Transcript with `text=None` and `error_detail` populated, keeping the query model uniform |
|
|
||||||
| Relationships via SQLModel `Relationship` | Enables `document.jobs` and `job.transcript` navigation in service code without manual joins |
|
|
||||||
|
|
||||||
### Proposed Implementation
|
|
||||||
|
|
||||||
- `resource://skills/fastapi-async-sqlalchemy-modernization/document`
|
|
||||||
|
|
||||||
```python src/transcription/models.py
|
|
||||||
"""SQLModel domain models for the transcription system.
|
|
||||||
|
|
||||||
Three models capture the MVP lifecycle:
|
|
||||||
Document → one-to-many → Job → one-to-one → Transcript
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from enum import StrEnum
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(StrEnum):
|
|
||||||
QUEUED = "queued"
|
|
||||||
PROCESSING = "processing"
|
|
||||||
TRANSCRIBED = "transcribed"
|
|
||||||
FAILED = "failed"
|
|
||||||
|
|
||||||
|
|
||||||
class Document(SQLModel, table=True):
|
|
||||||
"""An uploaded document image."""
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
filename: str
|
|
||||||
file_path: str
|
|
||||||
uploaded_at: datetime = Field(
|
|
||||||
default_factory=lambda: datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- relationships ---
|
|
||||||
jobs: list["Job"] = Relationship(back_populates="document")
|
|
||||||
|
|
||||||
|
|
||||||
class Job(SQLModel, table=True):
|
|
||||||
"""A transcription job tied to a single document."""
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
document_id: UUID = Field(foreign_key="document.id")
|
|
||||||
status: JobStatus = Field(default=JobStatus.QUEUED)
|
|
||||||
created_at: datetime = Field(
|
|
||||||
default_factory=lambda: datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
updated_at: datetime = Field(
|
|
||||||
default_factory=lambda: datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- relationships ---
|
|
||||||
document: Document = Relationship(back_populates="jobs")
|
|
||||||
transcript: "Transcript | None" = Relationship(back_populates="job")
|
|
||||||
|
|
||||||
|
|
||||||
class Transcript(SQLModel, table=True):
|
|
||||||
"""The output of a transcription job."""
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
job_id: UUID = Field(foreign_key="job.id", unique=True)
|
|
||||||
text: str | None = None
|
|
||||||
error_detail: str | None = None
|
|
||||||
created_at: datetime = Field(
|
|
||||||
default_factory=lambda: datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- relationships ---
|
|
||||||
job: Job = Relationship(back_populates="transcript")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Entity-Relationship Summary
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────┐ ┌──────────┐ ┌─────────────┐
|
|
||||||
│ Document │ 1───* │ Job │ 1───1 │ Transcript │
|
|
||||||
├──────────┤ ├──────────┤ ├─────────────┤
|
|
||||||
│ id (PK) │ │ id (PK) │ │ id (PK) │
|
|
||||||
│ filename │ │ doc_id │──FK──▶│ job_id (FK) │
|
|
||||||
│ file_path│ │ status │ │ text │
|
|
||||||
│ uploaded │ │ created │ │ error_detail│
|
|
||||||
│ │ │ updated │ │ created │
|
|
||||||
└──────────┘ └──────────┘ └─────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why Only Four Status Values
|
|
||||||
|
|
||||||
REQ-3 lists six states: `upload`, `queued`, `processing`, `transcribed`, `failed`, `completed`. The MVP simplifies this:
|
|
||||||
|
|
||||||
| REQ-3 State | MVP Treatment |
|
|
||||||
|-------------|---------------|
|
|
||||||
| `upload` | Implicit — the Document record exists before a Job is created. No separate job state needed. |
|
|
||||||
| `queued` | ✅ Included — job created, waiting for worker pickup |
|
|
||||||
| `processing` | ✅ Included — worker is actively transcribing |
|
|
||||||
| `transcribed` | ✅ Included — AI output received and stored |
|
|
||||||
| `failed` | ✅ Included — error captured |
|
|
||||||
| `completed` | Deferred — implies human review/acceptance. In MVP, `transcribed` is the terminal success state. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. `db.py` — Database Engine and Session Management
|
|
||||||
|
|
||||||
**Satisfies:** MVP Feature 5 (SQLite auto-created on first startup)
|
|
||||||
|
|
||||||
### Design Decisions
|
|
||||||
|
|
||||||
| Decision | Rationale |
|
|
||||||
|----------|-----------|
|
|
||||||
| Module-level `create_engine` + `Session` factory | REQ-7 (lifespan-owned resources) is deferred. A module-level engine is adequate for MVP's single-process, single-user operation |
|
|
||||||
| `create_all()` as an explicit function | Called at app startup. MVP auto-creates tables (REQ-10 deferred), but the function is isolated so it's easy to gate behind a flag later |
|
|
||||||
| `get_session()` as a generator | Standard FastAPI/SQLModel pattern — yields a session, ensures cleanup. Compatible with `Depends()` when the API layer arrives in Step 5 |
|
|
||||||
| `echo=False` default | Keeps logs clean. Can be toggled for debugging |
|
|
||||||
|
|
||||||
### Proposed Implementation
|
|
||||||
|
|
||||||
```python src/transcription/db.py
|
|
||||||
"""Database engine, session factory, and schema bootstrap.
|
|
||||||
|
|
||||||
MVP uses SQLite with auto-create-tables at startup.
|
|
||||||
PostgreSQL migration is a post-MVP configuration change.
|
|
||||||
"""
|
|
||||||
import contextlib
|
|
||||||
from collections.abc import Generator
|
|
||||||
|
|
||||||
from sqlmodel import Session, SQLModel, create_engine
|
|
||||||
|
|
||||||
from transcription.config import get_settings
|
|
||||||
|
|
||||||
|
|
||||||
def _build_engine():
|
|
||||||
settings = get_settings()
|
|
||||||
connect_args = {}
|
|
||||||
if settings.database_url.startswith("sqlite"):
|
|
||||||
connect_args["check_same_thread"] = False
|
|
||||||
return create_engine(
|
|
||||||
settings.database_url,
|
|
||||||
echo=False,
|
|
||||||
connect_args=connect_args,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
engine = _build_engine()
|
|
||||||
|
|
||||||
|
|
||||||
def create_all() -> None:
|
|
||||||
"""Create all tables. Called once at application startup."""
|
|
||||||
SQLModel.metadata.create_all(engine)
|
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def get_session() -> Generator[Session]:
|
|
||||||
"""Yield a database session and ensure cleanup."""
|
|
||||||
with Session(engine) as session:
|
|
||||||
yield session
|
|
||||||
```
|
|
||||||
|
|
||||||
### SQLite-Specific Note
|
|
||||||
|
|
||||||
`check_same_thread=False` is required for SQLite when the session may be accessed from different threads (e.g., a background worker on a different thread than the request handler). This setting is harmless and ignored for PostgreSQL connection strings.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Test Plan
|
|
||||||
|
|
||||||
Refer to these resources for rules and guidelines about structure:
|
|
||||||
|
|
||||||
- `resource://skills/pytesting/document`
|
|
||||||
- `resource://catalog/prompts/pytest-scaffold`
|
|
||||||
- `resource://catalog/prompts/pytest-fill-scaffold`
|
|
||||||
|
|
||||||
Hierarchy pattern used in this step:
|
|
||||||
|
|
||||||
```text
|
|
||||||
tests/
|
|
||||||
conftest.py
|
|
||||||
test_config.py
|
|
||||||
TestSettingsLoading
|
|
||||||
test_loads_from_env
|
|
||||||
test_requires_api_key
|
|
||||||
TestProviderSettings
|
|
||||||
test_defaults_to_openrouter
|
|
||||||
test_rejects_invalid_value
|
|
||||||
test_optional_fields_default_to_none
|
|
||||||
TestPathSettings
|
|
||||||
test_path_fields_are_path_objects
|
|
||||||
test_models.py
|
|
||||||
TestDocumentModel
|
|
||||||
test_can_be_persisted
|
|
||||||
test_defaults_are_populated
|
|
||||||
TestJobModel
|
|
||||||
test_can_be_created_for_document
|
|
||||||
test_defaults_are_populated
|
|
||||||
test_transitions_to_transcribed
|
|
||||||
test_transitions_to_failed
|
|
||||||
TestTranscriptModel
|
|
||||||
test_success_record_persists
|
|
||||||
test_failure_record_persists
|
|
||||||
test_job_id_is_unique
|
|
||||||
TestRelationships
|
|
||||||
test_document_exposes_jobs
|
|
||||||
test_job_exposes_transcript
|
|
||||||
test_db.py
|
|
||||||
TestSchemaBootstrap
|
|
||||||
test_create_all_creates_expected_tables
|
|
||||||
TestSessionFactory
|
|
||||||
test_get_session_yields_session
|
|
||||||
test_session_is_closed_after_generator_exit
|
|
||||||
```
|
|
||||||
|
|
||||||
### `tests/conftest.py` — Shared Fixtures
|
|
||||||
|
|
||||||
```python tests/conftest.py
|
|
||||||
"""Shared test fixtures.
|
|
||||||
|
|
||||||
Every test gets a fresh in-memory SQLite database so tests are
|
|
||||||
isolated, fast, and leave no artifacts on disk.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlmodel import Session, SQLModel, create_engine
|
|
||||||
from sqlmodel.pool import StaticPool
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def session():
|
|
||||||
"""Provide a clean database session for each test."""
|
|
||||||
engine = create_engine(
|
|
||||||
"sqlite://",
|
|
||||||
connect_args={"check_same_thread": False},
|
|
||||||
poolclass=StaticPool,
|
|
||||||
)
|
|
||||||
SQLModel.metadata.create_all(engine)
|
|
||||||
with Session(engine) as session:
|
|
||||||
yield session
|
|
||||||
```
|
|
||||||
|
|
||||||
`StaticPool` ensures a single in-memory SQLite connection is shared across threads, which is required when `TestClient` (Step 5) spawns threads that would otherwise get separate in-memory databases. Establishing it now keeps the fixture stable across all future steps.
|
|
||||||
|
|
||||||
### `tests/test_config.py` — Configuration Hierarchy
|
|
||||||
|
|
||||||
| Class | Method | What It Verifies |
|
|
||||||
|------|--------|------------------|
|
|
||||||
| `TestSettingsLoading` | `test_loads_from_env` | `Settings` constructs successfully when `OPENROUTER_API_KEY` is set via env var |
|
|
||||||
| `TestSettingsLoading` | `test_requires_api_key` | `Settings()` raises `ValidationError` when `OPENROUTER_API_KEY` is missing |
|
|
||||||
| `TestProviderSettings` | `test_defaults_to_openrouter` | Default provider is `openrouter` when not explicitly set |
|
|
||||||
| `TestProviderSettings` | `test_rejects_invalid_value` | Setting `PROVIDER=invalid` raises `ValidationError` |
|
|
||||||
| `TestProviderSettings` | `test_optional_fields_default_to_none` | `provider_model`, `openrouter_http_referer`, and `openrouter_app_title` are `None` when unset |
|
|
||||||
| `TestPathSettings` | `test_path_fields_are_path_objects` | `upload_dir` and `prompt_dir` are `Path` instances |
|
|
||||||
|
|
||||||
### `tests/test_models.py` — Model & Relationship Hierarchy
|
|
||||||
|
|
||||||
| Class | Method | What It Verifies |
|
|
||||||
|------|--------|------------------|
|
|
||||||
| `TestDocumentModel` | `test_can_be_persisted` | A `Document` can be persisted and read back with correct fields |
|
|
||||||
| `TestDocumentModel` | `test_defaults_are_populated` | `id` is auto-generated UUID, `uploaded_at` is populated |
|
|
||||||
| `TestJobModel` | `test_can_be_created_for_document` | A `Job` linked to a `Document` via FK persists correctly |
|
|
||||||
| `TestJobModel` | `test_defaults_are_populated` | Default status is `queued`, `created_at` and `updated_at` are populated |
|
|
||||||
| `TestJobModel` | `test_transitions_to_transcribed` | Status can be updated from `queued` → `processing` → `transcribed` |
|
|
||||||
| `TestJobModel` | `test_transitions_to_failed` | Status can be updated from `processing` → `failed` |
|
|
||||||
| `TestTranscriptModel` | `test_success_record_persists` | A `Transcript` with `text` set and `error_detail=None` persists correctly |
|
|
||||||
| `TestTranscriptModel` | `test_failure_record_persists` | A `Transcript` with `text=None` and `error_detail` set persists correctly |
|
|
||||||
| `TestRelationships` | `test_document_exposes_jobs` | `document.jobs` returns the linked `Job` list |
|
|
||||||
| `TestRelationships` | `test_job_exposes_transcript` | `job.transcript` returns the linked `Transcript` |
|
|
||||||
| `TestTranscriptModel` | `test_job_id_is_unique` | Inserting two transcripts with the same `job_id` raises an integrity error |
|
|
||||||
|
|
||||||
### `tests/test_db.py` — Database Bootstrap Hierarchy
|
|
||||||
|
|
||||||
| Class | Method | What It Verifies |
|
|
||||||
|------|--------|------------------|
|
|
||||||
| `TestSchemaBootstrap` | `test_create_all_creates_expected_tables` | After `create_all()`, the expected tables (`document`, `job`, `transcript`) exist in the database |
|
|
||||||
| `TestSessionFactory` | `test_get_session_yields_session` | `get_session()` yields a usable `Session` object |
|
|
||||||
| `TestSessionFactory` | `test_session_is_closed_after_generator_exit` | After the generator is exhausted, the session is closed |
|
|
||||||
|
|
||||||
### Marker Strategy (Step 1)
|
|
||||||
|
|
||||||
- Markers (`unit`, `integration`, `external`) are registered upfront in `pyproject.toml` with `--strict-markers` enabled, per pytesting skill conventions.
|
|
||||||
- All Step 1 tests are unmarked — they run in the default lane since they are fast, deterministic, and have no external dependencies.
|
|
||||||
- When slower integration or external tests are introduced in later steps, apply explicit markers and keep test names unchanged.
|
|
||||||
|
|
||||||
### Test Workflow
|
|
||||||
|
|
||||||
Follow the two-phase approach from `resource://catalog/prompts/pytest-scaffold` and `resource://catalog/prompts/pytest-fill-scaffold`:
|
|
||||||
|
|
||||||
1. **Scaffold phase**: Create test files with class hierarchy, method names, and one-line docstrings only. Validate collection:
|
|
||||||
- `uv run pytest --collect-only -q`
|
|
||||||
2. **Fill phase**: Implement assertions, fixtures, and minimal test data. Treat scaffolded names and docstrings as locked. Validate execution:
|
|
||||||
- `uv run pytest -q`
|
|
||||||
|
|
||||||
Scaffolded structure is treated as a stable baseline — do not rename, move, merge, split, or re-nest tests once the scaffold is reviewed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Step 1 Completion Checklist
|
|
||||||
|
|
||||||
When all of the following are true, Step 1 is done and Step 2 can begin:
|
|
||||||
|
|
||||||
| # | Criterion | How to Verify |
|
|
||||||
|---|-----------|---------------|
|
|
||||||
| 1 | `src/transcription/` package exists with `config.py`, `models.py`, `db.py` | `ls` / file inspection |
|
|
||||||
| 2 | Empty `__init__.py` stubs exist for `providers/`, `services/`, `ui/` | `ls` / file inspection |
|
|
||||||
| 3 | `Settings` loads from environment and validates `OPENROUTER_API_KEY` is present | `test_config.py` passes |
|
|
||||||
| 4 | `Document`, `Job`, `Transcript` models create tables in SQLite | `test_models.py` passes |
|
|
||||||
| 5 | `JobStatus` enum has exactly four values: `queued`, `processing`, `transcribed`, `failed` | `test_models.py` passes |
|
|
||||||
| 6 | Foreign key relationships work: Document→Job→Transcript | `test_models.py` passes |
|
|
||||||
| 7 | `create_all()` bootstraps the schema; `get_session()` yields a working session | `test_db.py` passes |
|
|
||||||
| 8 | All tests pass: `uv run pytest -q` | CI / local run |
|
|
||||||
| 9 | `hello.py` is deleted | File inspection |
|
|
||||||
| 10 | `pyproject.toml` includes `openrouter`, `sqlmodel`, `pydantic-settings`, `pytest`, `pytest-asyncio` | File inspection |
|
|
||||||
| 10a | `pyproject.toml` has `[tool.pytest.ini_options]` with `--strict-markers` and registered markers | File inspection |
|
|
||||||
| 11 | `.env.example` documents all config vars; `.env` is in `.gitignore` | File inspection |
|
|
||||||
| 12 | `setup_logging()` uses `logging.config.dictConfig` with centralized formatter/handler/root config | File inspection |
|
|
||||||
| 13 | `uv run pytest --collect-only -q` shows expected test hierarchy | Local run |
|
|
||||||
| 14 | `uv run pytest -q` passes all tests | Local run |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. What This Step Does NOT Include
|
|
||||||
|
|
||||||
Explicitly out of scope to prevent scope creep:
|
|
||||||
|
|
||||||
| Excluded | Reason |
|
|
||||||
|----------|--------|
|
|
||||||
| FastAPI / NiceGUI app entrypoint | Step 5 |
|
|
||||||
| Additional provider adapters beyond OpenRouter | Post-MVP |
|
|
||||||
| Upload service logic | Step 4 |
|
|
||||||
| Worker / background processing | Step 4 |
|
|
||||||
| Transcription prompt files | Step 2 |
|
|
||||||
| Alembic or migration tooling | Post-MVP (REQ-10 deferred) |
|
|
||||||
| Async session factory | Post-MVP (REQ-7 deferred) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This plan produces a fully tested, importable data foundation. Every subsequent step imports from `transcription.config`, `transcription.models`, and `transcription.db` without modification.
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
## Step 2: prompts/transcribe_document.md
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Implement the MVP prompt artifact system by creating a curated transcription prompt file:
|
|
||||||
|
|
||||||
- `prompts/transcribe_document.md`
|
|
||||||
|
|
||||||
This step primarily satisfies:
|
|
||||||
|
|
||||||
- **REQ-12**: prompts stored as individual Markdown artifacts
|
|
||||||
- MVP Feature 3: prompt-driven verbatim transcription behavior grounded in `docs/intent.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope for Step 2
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
1. Create prompt artifact directory and first prompt file.
|
|
||||||
2. Encode transcription rules from `docs/intent.md` into a model-facing prompt.
|
|
||||||
3. Define stable prompt structure so future revisions are easy to diff/review.
|
|
||||||
4. Add lightweight tests that validate artifact presence and baseline quality constraints.
|
|
||||||
5. Update docs/README references so Step 3 can consume prompt file directly.
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- Provider integration logic (Step 3)
|
|
||||||
- Worker/job orchestration (Step 4)
|
|
||||||
- UI behavior (Step 5)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proposed Deliverables
|
|
||||||
|
|
||||||
1. **`prompts/transcribe_document.md`**
|
|
||||||
- production prompt text for historical document transcription
|
|
||||||
|
|
||||||
2. **`prompts/README.md`** (recommended)
|
|
||||||
- conventions for prompt files, revision policy, naming
|
|
||||||
|
|
||||||
3. **`tests/test_prompts.py`** (recommended)
|
|
||||||
- artifact existence + structure checks
|
|
||||||
|
|
||||||
4. **Small docs update** (README or docs reference)
|
|
||||||
- indicate that prompts are file-based and loaded from `PROMPT_DIR`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Detailed Work Breakdown
|
|
||||||
|
|
||||||
### 1) Create prompt artifact folder and canonical file
|
|
||||||
- Add `prompts/` at repo root.
|
|
||||||
- Add `transcribe_document.md` as the first curated artifact.
|
|
||||||
- Keep filename stable; this becomes the default in Step 3 unless overridden.
|
|
||||||
|
|
||||||
### 2) Author prompt content using a strict, sectioned format
|
|
||||||
Use section headers so future diffs are clean and policy changes are isolated.
|
|
||||||
|
|
||||||
Suggested sections:
|
|
||||||
|
|
||||||
1. **Purpose**
|
|
||||||
- verbatim scholarly transcription of historical documents
|
|
||||||
|
|
||||||
2. **Output requirements**
|
|
||||||
- plain text only
|
|
||||||
- no summaries, no paraphrasing
|
|
||||||
- preserve reading order and meaningful structure
|
|
||||||
|
|
||||||
3. **Core fidelity rules**
|
|
||||||
- preserve original wording and punctuation
|
|
||||||
- don’t silently normalize grammar/spelling
|
|
||||||
- no invented content
|
|
||||||
|
|
||||||
4. **Issue-handling rules (mapped from Intent table)**
|
|
||||||
- misspellings with `[sic]`
|
|
||||||
- missing words with `[word]`
|
|
||||||
- uncertainty with `[guess?]`
|
|
||||||
- illegible with `[illegible]` / reason tags
|
|
||||||
- crossed-out text as `[deleted: ...]`
|
|
||||||
- inserted text as `[inserted: ...]`
|
|
||||||
- superscripts handling guidance
|
|
||||||
- non-text elements as `[description]`
|
|
||||||
- marginalia format `[written in left margin: ...]`
|
|
||||||
- line-break hyphen rejoin behavior
|
|
||||||
- capitalization policy
|
|
||||||
- hierarchical outline preservation (including unusual numbering)
|
|
||||||
|
|
||||||
5. **Confidence/ambiguity policy**
|
|
||||||
- prefer explicit uncertainty markers over hallucination
|
|
||||||
|
|
||||||
6. **Final self-checklist for model**
|
|
||||||
- did I preserve structure?
|
|
||||||
- did I mark uncertain text?
|
|
||||||
- did I avoid silent corrections?
|
|
||||||
|
|
||||||
### 3) Add prompt-library conventions (`prompts/README.md`)
|
|
||||||
Recommended conventions:
|
|
||||||
- one prompt per file
|
|
||||||
- snake_case names
|
|
||||||
- each file starts with purpose + behavior contract
|
|
||||||
- iterative edits, one prompt per PR where possible
|
|
||||||
- no secrets in prompt files
|
|
||||||
|
|
||||||
### 4) Add tests for prompt assets (`tests/test_prompts.py`)
|
|
||||||
Keep tests robust but not brittle.
|
|
||||||
|
|
||||||
Recommended tests:
|
|
||||||
1. `test_prompt_file_exists`
|
|
||||||
2. `test_prompt_file_is_not_empty`
|
|
||||||
3. `test_prompt_mentions_verbatim_behavior`
|
|
||||||
4. `test_prompt_includes_uncertainty_and_illegible_markers`
|
|
||||||
5. `test_prompt_includes_deleted_and_inserted_conventions`
|
|
||||||
|
|
||||||
Avoid exact full-text matching; verify key semantic anchors only.
|
|
||||||
|
|
||||||
### 5) Optional config alignment check
|
|
||||||
Current config already has:
|
|
||||||
- `prompt_dir: Path = Path("./prompts")`
|
|
||||||
|
|
||||||
In Step 2, ensure docs reflect this and that Step 3 will resolve:
|
|
||||||
- `PROMPT_DIR / "transcribe_document.md"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task-by-Task Execution Checklist
|
|
||||||
|
|
||||||
## Phase A — Scaffold files
|
|
||||||
|
|
||||||
- [ ] **A1. Create prompt directory**
|
|
||||||
- Path: `prompts/`
|
|
||||||
- Verify: directory exists at repo root
|
|
||||||
|
|
||||||
- [ ] **A2. Create canonical prompt file**
|
|
||||||
- Path: `prompts/transcribe_document.md`
|
|
||||||
- Verify: file exists and is non-empty
|
|
||||||
|
|
||||||
- [ ] **A3. (Recommended) Create prompt library README**
|
|
||||||
- Path: `prompts/README.md`
|
|
||||||
- Verify: includes naming + revision conventions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase B — Author prompt content (core work)
|
|
||||||
|
|
||||||
- [ ] **B1. Add Purpose section**
|
|
||||||
- States verbatim historical transcription objective
|
|
||||||
- Explicitly disallows summarization/paraphrase
|
|
||||||
|
|
||||||
- [ ] **B2. Add Output Contract section**
|
|
||||||
- Plain text output expectation
|
|
||||||
- Preserve meaningful structure and reading order
|
|
||||||
- No fabricated text
|
|
||||||
|
|
||||||
- [ ] **B3. Add Rule Set from `docs/intent.md`**
|
|
||||||
- Misspellings/errors: `[sic]`
|
|
||||||
- Missing words: `[word]`
|
|
||||||
- Uncertain readings: `[guess?]`
|
|
||||||
- Illegible regions: `[illegible]` / reason labels
|
|
||||||
- Crossed-out text: `[deleted: ...]`
|
|
||||||
- Squeezed-in text: `[inserted: ...]`
|
|
||||||
- Superscripts/abbrev handling guidance
|
|
||||||
- Non-text visuals: bracketed descriptive labels
|
|
||||||
- Marginalia formatting cue
|
|
||||||
- Rejoin line-break hyphenated words silently
|
|
||||||
- Ambiguous capitalization policy
|
|
||||||
- Hierarchical outline numbering preservation
|
|
||||||
|
|
||||||
- [ ] **B4. Add Ambiguity and Confidence policy**
|
|
||||||
- “Mark uncertainty instead of guessing”
|
|
||||||
- “Never silently normalize uncertain passages”
|
|
||||||
|
|
||||||
- [ ] **B5. Add Final Self-Check section**
|
|
||||||
- Checklist for fidelity, uncertainty labeling, and format compliance
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase C — Add validations (tests)
|
|
||||||
|
|
||||||
- [ ] **C1. Create prompt tests file**
|
|
||||||
- Path: `tests/test_prompts.py`
|
|
||||||
|
|
||||||
- [ ] **C2. Add existence/health checks**
|
|
||||||
- Prompt file exists
|
|
||||||
- Prompt file has content (non-whitespace)
|
|
||||||
|
|
||||||
- [ ] **C3. Add semantic anchor checks**
|
|
||||||
- Mentions verbatim behavior
|
|
||||||
- Mentions uncertainty marker pattern (`?` in brackets conceptually)
|
|
||||||
- Mentions illegible handling
|
|
||||||
- Mentions deleted/inserted conventions
|
|
||||||
|
|
||||||
- [ ] **C4. Keep tests resilient**
|
|
||||||
- Avoid exact full-file snapshot assertions
|
|
||||||
- Assert required concepts, not precise phrasing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase D — Documentation alignment
|
|
||||||
|
|
||||||
- [ ] **D1. Update top-level docs/README reference**
|
|
||||||
- Mention that prompts live in `prompts/`
|
|
||||||
- Mention Step 3 loads from `PROMPT_DIR`
|
|
||||||
|
|
||||||
- [ ] **D2. Confirm config compatibility**
|
|
||||||
- `src/transcription/config.py` already uses `prompt_dir = Path("./prompts")`
|
|
||||||
- No code change needed unless naming/path mismatch appears
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase E — Verification
|
|
||||||
|
|
||||||
- [ ] **E1. Run targeted test file**
|
|
||||||
- `uv run pytest tests/test_prompts.py -q`
|
|
||||||
|
|
||||||
- [ ] **E2. Run full suite**
|
|
||||||
- `uv run pytest -q`
|
|
||||||
|
|
||||||
- [ ] **E3. Confirm no regressions**
|
|
||||||
- All existing tests still green (expected: previous 20 + new prompt tests)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase F — Commit plan (recommended granularity)
|
|
||||||
|
|
||||||
- [ ] **F1. Commit 1: scaffold**
|
|
||||||
- `prompts/transcribe_document.md` (initial structure)
|
|
||||||
- `prompts/README.md` (if included)
|
|
||||||
|
|
||||||
- [ ] **F2. Commit 2: finalized prompt content**
|
|
||||||
- full rule-complete prompt text
|
|
||||||
|
|
||||||
- [ ] **F3. Commit 3: tests + docs alignment**
|
|
||||||
- `tests/test_prompts.py`
|
|
||||||
- README/docs mention of prompt artifact pattern
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Done Criteria (quick gate)
|
|
||||||
|
|
||||||
- [ ] Canonical prompt exists and is curated for verbatim transcription.
|
|
||||||
- [ ] Prompt encodes all high-value handling rules from `docs/intent.md`.
|
|
||||||
- [ ] Prompt tests pass.
|
|
||||||
- [ ] Full project tests pass with `uv`.
|
|
||||||
- [ ] Ready for Step 3 provider integration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acceptance Criteria (Definition of Done)
|
|
||||||
|
|
||||||
Step 2 is complete when all are true:
|
|
||||||
|
|
||||||
1. `prompts/transcribe_document.md` exists and is committed.
|
|
||||||
2. Prompt includes all critical handling rules from `docs/intent.md`.
|
|
||||||
3. Prompt is structured with stable section headings for future curation.
|
|
||||||
4. Prompt tests pass under `uv run pytest -q`.
|
|
||||||
5. Existing tests remain green (total suite still passes).
|
|
||||||
6. Docs indicate prompt artifact location and curation policy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Risks and Mitigations
|
|
||||||
|
|
||||||
1. **Risk: prompt too vague → hallucinated reconstructions**
|
|
||||||
- Mitigation: explicit uncertainty/illegible conventions and “no invention” rule.
|
|
||||||
|
|
||||||
2. **Risk: prompt too rigid for mixed document types**
|
|
||||||
- Mitigation: include neutral defaults + clear annotation formats.
|
|
||||||
|
|
||||||
3. **Risk: brittle tests block iterative prompt tuning**
|
|
||||||
- Mitigation: test semantic anchors, not exact wording.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Handoff to Step 3
|
|
||||||
|
|
||||||
After Step 2, Step 3 can immediately:
|
|
||||||
1. Load `transcribe_document.md` from `PROMPT_DIR`
|
|
||||||
2. Inject prompt into OpenRouter request
|
|
||||||
3. Start validating real transcription behavior with minimal glue code
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
## Step 3: services/transcription.py + providers/
|
|
||||||
|
|
||||||
### Objective
|
|
||||||
|
|
||||||
Implement the **AI transcription integration layer** so the app can:
|
|
||||||
|
|
||||||
1. Read the curated prompt from `PROMPT_DIR`
|
|
||||||
2. Send prompt + image to the configured provider (OpenRouter)
|
|
||||||
3. Return normalized transcription output (or structured failure)
|
|
||||||
|
|
||||||
This corresponds to MVP Step 3 from `docs/mvp.md`:
|
|
||||||
- `services/transcription.py`
|
|
||||||
- `providers/` adapter(s)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope for Step 3
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
- Provider abstraction and OpenRouter adapter
|
|
||||||
- Prompt file loading utility in service layer
|
|
||||||
- Image payload preparation
|
|
||||||
- One high-level transcription service function usable by Step 4 worker
|
|
||||||
- Unit tests (mocked provider SDK, no external calls)
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- Job polling/background loop (Step 4)
|
|
||||||
- DB status transition orchestration in worker loop (Step 4)
|
|
||||||
- UI invocation/wiring (Step 5)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Planned Deliverables
|
|
||||||
|
|
||||||
### Source files
|
|
||||||
- `src/transcription/providers/base.py`
|
|
||||||
- `src/transcription/providers/openrouter.py`
|
|
||||||
- `src/transcription/providers/__init__.py` (exports + factory)
|
|
||||||
- `src/transcription/services/transcription.py`
|
|
||||||
- `src/transcription/services/__init__.py` (optional export)
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- `tests/providers/test_openrouter.py`
|
|
||||||
- `tests/services/test_transcription.py`
|
|
||||||
|
|
||||||
### Test directory convention
|
|
||||||
- Mirror source domains under `tests/`.
|
|
||||||
- Provider adapter tests live under `tests/providers/`.
|
|
||||||
- Service-layer tests live under `tests/services/`.
|
|
||||||
- Prefer one focused test module per production module (for Step 3: `test_openrouter.py`, `test_transcription.py`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design Decisions (before coding)
|
|
||||||
|
|
||||||
1. **Provider interface first**
|
|
||||||
- Define a stable contract independent of SDK specifics.
|
|
||||||
- Prevent Step 4 from depending on raw SDK response shapes.
|
|
||||||
|
|
||||||
2. **Service returns normalized result object**
|
|
||||||
- Include: `text`, `provider`, `model`, `raw_error`/exception metadata.
|
|
||||||
- Worker can map this cleanly to `Transcript` and `JobStatus`.
|
|
||||||
|
|
||||||
3. **Prompt loaded from file at call time**
|
|
||||||
- Uses `get_settings().prompt_dir / "transcribe_document.md"`.
|
|
||||||
- Keeps prompt edits hot-swappable without code changes.
|
|
||||||
|
|
||||||
4. **Clear exception boundary**
|
|
||||||
- SDK/network/model failures become predictable domain exceptions:
|
|
||||||
- `ProviderError`
|
|
||||||
- `PromptLoadError`
|
|
||||||
- `TranscriptionError` (optional top-level wrapper)
|
|
||||||
|
|
||||||
5. **Model resolution policy**
|
|
||||||
- Use `settings.provider_model` if set
|
|
||||||
- Otherwise use adapter default constant (e.g., vision-capable model slug)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task-by-Task Execution Checklist
|
|
||||||
|
|
||||||
## Phase A — Provider contract
|
|
||||||
|
|
||||||
- [ ] Create `src/transcription/providers/base.py`
|
|
||||||
- [ ] Define protocol/ABC for transcription providers:
|
|
||||||
- [ ] method signature accepts prompt text + image bytes (or data URL) + mime type
|
|
||||||
- [ ] returns normalized text result (and optional metadata)
|
|
||||||
- [ ] Define shared provider exceptions:
|
|
||||||
- [ ] `ProviderError`
|
|
||||||
- [ ] optional subclasses (`ProviderAuthError`, `ProviderResponseError`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase B — OpenRouter adapter
|
|
||||||
|
|
||||||
- [ ] Create `src/transcription/providers/openrouter.py`
|
|
||||||
- [ ] Implement `OpenRouterTranscriptionProvider` with:
|
|
||||||
- [ ] config-driven API key usage
|
|
||||||
- [ ] optional referer/title attribution headers
|
|
||||||
- [ ] model resolution fallback when `provider_model` is unset
|
|
||||||
- [ ] Implement request building:
|
|
||||||
- [ ] prompt included as instruction content
|
|
||||||
- [ ] image included in supported format for vision call
|
|
||||||
- [ ] Implement response parsing:
|
|
||||||
- [ ] extract final transcript text from SDK response
|
|
||||||
- [ ] validate non-empty text
|
|
||||||
- [ ] Wrap SDK failures into `ProviderError` with clean message
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase C — Provider factory
|
|
||||||
|
|
||||||
- [ ] Update `src/transcription/providers/__init__.py`
|
|
||||||
- [ ] Add `get_transcription_provider()` factory:
|
|
||||||
- [ ] reads `settings.provider`
|
|
||||||
- [ ] returns OpenRouter adapter for `openrouter`
|
|
||||||
- [ ] raises explicit error for unsupported provider values
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase D — Transcription service (Step 3 core)
|
|
||||||
|
|
||||||
- [ ] Create `src/transcription/services/transcription.py`
|
|
||||||
- [ ] Add prompt loader function:
|
|
||||||
- [ ] default file: `transcribe_document.md`
|
|
||||||
- [ ] raises `PromptLoadError` on missing/empty file
|
|
||||||
- [ ] Add image loader/validator:
|
|
||||||
- [ ] path existence check
|
|
||||||
- [ ] allowed mime detection (`.jpg/.jpeg/.png/.tiff/.pdf` policy aligned to MVP)
|
|
||||||
- [ ] Add high-level function (name example):
|
|
||||||
- [ ] `transcribe_document_image(image_path, prompt_name="transcribe_document.md")`
|
|
||||||
- [ ] loads prompt + image
|
|
||||||
- [ ] calls provider from factory
|
|
||||||
- [ ] returns normalized transcription result object
|
|
||||||
- [ ] Add structured logging at key boundaries:
|
|
||||||
- [ ] prompt loaded
|
|
||||||
- [ ] provider invoked
|
|
||||||
- [ ] success/failure outcome (no sensitive data in logs)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase E — Tests (two-phase scaffold -> fill)
|
|
||||||
|
|
||||||
### Required execution resources
|
|
||||||
|
|
||||||
Load and reference these directly during test planning/implementation so the two-phase flow is enforced:
|
|
||||||
|
|
||||||
- [ ] `resource://catalog/prompts/pytest-scaffold`
|
|
||||||
- [ ] `resource://prompts/pytest-scaffold/document`
|
|
||||||
- [ ] `resource://catalog/prompts/pytest-fill-scaffold`
|
|
||||||
- [ ] `resource://prompts/pytest-fill-scaffold/document`
|
|
||||||
|
|
||||||
### Phase E1 — Scaffold test structure first
|
|
||||||
|
|
||||||
Prompt: `resource://catalog/prompts/pytest-scaffold`
|
|
||||||
|
|
||||||
Suggested arguments:
|
|
||||||
- [ ] `target_modules` = `src/transcription/providers/openrouter.py`, `src/transcription/services/transcription.py`
|
|
||||||
- [ ] `mode` = `scaffold`
|
|
||||||
- [ ] `path_strategy` = `src-to-tests-mirror`
|
|
||||||
- [ ] `naming_style` = `concise-behavior`
|
|
||||||
|
|
||||||
Expected scaffold outcomes:
|
|
||||||
- [ ] `tests/providers/test_openrouter.py` exists with class/method skeletons and one-line docstrings
|
|
||||||
- [ ] `tests/services/test_transcription.py` exists with class/method skeletons and one-line docstrings
|
|
||||||
- [ ] collection succeeds on scaffold-only tests
|
|
||||||
|
|
||||||
Scaffold coverage targets:
|
|
||||||
- [ ] adapter initializes from settings
|
|
||||||
- [ ] model fallback when `provider_model is None`
|
|
||||||
- [ ] referer/title options included when set
|
|
||||||
- [ ] successful SDK response parses transcript text
|
|
||||||
- [ ] SDK exception maps to `ProviderError`
|
|
||||||
- [ ] empty/invalid response maps to `ProviderError`
|
|
||||||
- [ ] prompt loader reads canonical prompt file
|
|
||||||
- [ ] missing prompt raises `PromptLoadError`
|
|
||||||
- [ ] transcription function loads file and calls provider once
|
|
||||||
- [ ] image path missing raises clear error
|
|
||||||
- [ ] provider error is propagated/wrapped predictably
|
|
||||||
- [ ] returned result includes transcript text and metadata
|
|
||||||
|
|
||||||
### Phase E2 — Fill scaffolded tests with assertions
|
|
||||||
|
|
||||||
Prompt: `resource://catalog/prompts/pytest-fill-scaffold`
|
|
||||||
|
|
||||||
Suggested arguments:
|
|
||||||
- [ ] `target_files` = `tests/providers/test_openrouter.py`, `tests/services/test_transcription.py`
|
|
||||||
- [ ] `stack` = `pure-python`
|
|
||||||
- [ ] `strategy` = `minimal`
|
|
||||||
- [ ] `marker_lane` = `unit`
|
|
||||||
|
|
||||||
Fill constraints:
|
|
||||||
- [ ] preserve scaffold class/method names and one-line docstrings
|
|
||||||
- [ ] keep mocks to an absolute minimum; mock only network boundaries and non-deterministic failures
|
|
||||||
- [ ] keep one behavior target per test method
|
|
||||||
|
|
||||||
> Default suite should remain deterministic and fast, but mocking should be minimal and intentional.
|
|
||||||
|
|
||||||
### Optional real-endpoint validation lane
|
|
||||||
|
|
||||||
- [ ] Add an opt-in integration lane for real provider calls (for example `@pytest.mark.integration` and `@pytest.mark.live_api`).
|
|
||||||
- [ ] Gate live tests behind explicit env vars (for example `OPENROUTER_API_KEY`, optional `RUN_LIVE_API_TESTS=1`).
|
|
||||||
- [ ] Exclude live tests from default CI/local runs unless explicitly requested.
|
|
||||||
- [ ] Keep at least one thin smoke path that can validate request/response compatibility against the real endpoint.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase F — Verification commands
|
|
||||||
|
|
||||||
- [ ] E1 scaffold validation: `uv run pytest --collect-only -q`
|
|
||||||
- [ ] E2 fill validation (unit lane): `uv run pytest -m unit -q`
|
|
||||||
- [ ] E2 targeted provider file: `uv run pytest tests/providers/test_openrouter.py -q`
|
|
||||||
- [ ] E2 targeted service file: `uv run pytest tests/services/test_transcription.py -q`
|
|
||||||
- [ ] E2 final full-suite check: `uv run pytest -q`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Notes / Guardrails
|
|
||||||
|
|
||||||
- Avoid coupling Step 3 service to DB models directly (that belongs in Step 4 orchestration).
|
|
||||||
- Do not silently swallow provider errors.
|
|
||||||
- Keep prompt filename stable (`transcribe_document.md`) unless explicitly parameterized.
|
|
||||||
- Keep request/response normalization inside provider adapter, not worker/UI layers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Definition of Done (Step 3)
|
|
||||||
|
|
||||||
Step 3 is done when:
|
|
||||||
|
|
||||||
1. Provider abstraction exists and OpenRouter adapter is implemented.
|
|
||||||
2. Service can transcribe a local image using prompt file content.
|
|
||||||
3. Failures are returned as structured exceptions, not raw SDK traceback noise.
|
|
||||||
4. Unit tests for provider and service pass.
|
|
||||||
5. Full suite remains green under `uv run pytest -q`.
|
|
||||||
6. Step 4 can call a single service function to process queued jobs.
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
## Step 4: `services/upload.py` + `worker.py`
|
|
||||||
|
|
||||||
### Objective
|
|
||||||
|
|
||||||
Implement the MVP upload and background-processing pipeline so the system can:
|
|
||||||
|
|
||||||
1. Save uploaded files into `UPLOAD_DIR`
|
|
||||||
2. Create `Document` + `Job(status="queued")`
|
|
||||||
3. Process queued jobs in a worker loop:
|
|
||||||
- `queued -> processing`
|
|
||||||
- call Step 3 transcription service
|
|
||||||
- persist `Transcript`
|
|
||||||
- finalize as `transcribed` or `failed`
|
|
||||||
|
|
||||||
This step advances MVP Feature 1 + Feature 2 and supports REQ-1, REQ-2, REQ-3, REQ-4, REQ-6.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
- `src/transcription/services/upload.py`
|
|
||||||
- `src/transcription/worker.py`
|
|
||||||
- Upload persistence logic and initial job creation
|
|
||||||
- Worker polling and single-job lifecycle execution
|
|
||||||
- Deterministic test coverage for upload + worker (default suite)
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- UI integration and pages (Step 5)
|
|
||||||
- Queue infrastructure beyond in-process loop
|
|
||||||
- Async DB/session architecture refactor
|
|
||||||
- Broad production hardening beyond MVP needs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Planned Deliverables
|
|
||||||
|
|
||||||
### Source files
|
|
||||||
- `src/transcription/services/upload.py`
|
|
||||||
- `src/transcription/worker.py`
|
|
||||||
- `src/transcription/services/__init__.py` (export updates as needed)
|
|
||||||
|
|
||||||
### Test files
|
|
||||||
- `tests/services/test_upload.py`
|
|
||||||
- `tests/services/test_worker.py`
|
|
||||||
|
|
||||||
### Optional external lane (already present pattern)
|
|
||||||
- reuse `external` marker for live-provider checks where appropriate
|
|
||||||
- keep external out of default lane
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Required MCP Prompt References (for test workflow)
|
|
||||||
|
|
||||||
Apply these resources directly during Step 4 test creation:
|
|
||||||
|
|
||||||
1. `resource://catalog/prompts/pytest-scaffold`
|
|
||||||
2. `resource://prompts/pytest-scaffold/document`
|
|
||||||
3. `resource://catalog/prompts/pytest-fill-scaffold`
|
|
||||||
4. `resource://prompts/pytest-fill-scaffold/document`
|
|
||||||
|
|
||||||
And (as referenced by those prompts) apply relevant pytest skill references for:
|
|
||||||
- naming/hierarchy
|
|
||||||
- marker defaults
|
|
||||||
- SQLAlchemy sync testing behavior where applicable
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design Decisions
|
|
||||||
|
|
||||||
1. **Upload service owns initial file + record creation**
|
|
||||||
- Writes file, creates `Document`, creates queued `Job`, returns IDs/path.
|
|
||||||
|
|
||||||
2. **Worker owns lifecycle transitions**
|
|
||||||
- Worker is the single owner of `queued -> processing -> terminal` job state changes.
|
|
||||||
|
|
||||||
3. **Worker uses Step 3 service boundary**
|
|
||||||
- Worker calls `transcribe_document_image(...)`; no provider-specific SDK logic in worker.
|
|
||||||
|
|
||||||
4. **Failure information is always persisted**
|
|
||||||
- On failure: store `Transcript(text=None, error_detail=...)` and set `Job.status=failed`.
|
|
||||||
|
|
||||||
5. **Loop remains simple and stoppable**
|
|
||||||
- In-process polling loop with stop event and poll interval for MVP simplicity and testability.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task-by-Task Execution Checklist
|
|
||||||
|
|
||||||
## Phase A — Implement upload service (`src/transcription/services/upload.py`)
|
|
||||||
|
|
||||||
- [ ] Create `UploadError` exception
|
|
||||||
- [ ] Create `UploadJobResult` dataclass with:
|
|
||||||
- [ ] `document_id`
|
|
||||||
- [ ] `job_id`
|
|
||||||
- [ ] `stored_path`
|
|
||||||
- [ ] `original_filename`
|
|
||||||
- [ ] Add filename safety handling:
|
|
||||||
- [ ] normalize to basename
|
|
||||||
- [ ] avoid path traversal
|
|
||||||
- [ ] collision-safe stored name (e.g., UUID prefix/suffix)
|
|
||||||
- [ ] Validate upload payload:
|
|
||||||
- [ ] non-empty bytes required
|
|
||||||
- [ ] extension in supported set (`.jpg/.jpeg/.png/.tif/.tiff/.pdf`)
|
|
||||||
- [ ] Ensure upload directory exists (`mkdir(parents=True, exist_ok=True)`)
|
|
||||||
- [ ] Write file bytes to `UPLOAD_DIR`
|
|
||||||
- [ ] Persist DB records in one transaction:
|
|
||||||
- [ ] `Document(filename, file_path)`
|
|
||||||
- [ ] `Job(document_id=..., status=queued)`
|
|
||||||
- [ ] Return `UploadJobResult`
|
|
||||||
- [ ] Add logging for success/failure boundaries
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase B — Implement worker core (`src/transcription/worker.py`)
|
|
||||||
|
|
||||||
- [ ] Add `process_next_queued_job(...) -> bool`
|
|
||||||
- [ ] Fetch oldest queued job
|
|
||||||
- [ ] Return `False` when no queued jobs exist
|
|
||||||
- [ ] Transition picked job to `processing` and update timestamp
|
|
||||||
- [ ] Resolve associated `Document.file_path`
|
|
||||||
- [ ] Call `transcribe_document_image(image_path=...)`
|
|
||||||
- [ ] On success:
|
|
||||||
- [ ] insert/update transcript text
|
|
||||||
- [ ] clear error detail
|
|
||||||
- [ ] mark job `transcribed`
|
|
||||||
- [ ] update timestamp
|
|
||||||
- [ ] On failure:
|
|
||||||
- [ ] insert/update transcript with `text=None`, `error_detail=...`
|
|
||||||
- [ ] mark job `failed`
|
|
||||||
- [ ] update timestamp
|
|
||||||
- [ ] Commit terminal state and return `True`
|
|
||||||
- [ ] Add logs around job pickup, transition, and terminal outcome
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase C — Implement worker loop (`src/transcription/worker.py`)
|
|
||||||
|
|
||||||
- [ ] Add `run_worker_loop(...)`
|
|
||||||
- [ ] Accept configurable stop event/signal
|
|
||||||
- [ ] Accept configurable poll interval
|
|
||||||
- [ ] Repeatedly call `process_next_queued_job`
|
|
||||||
- [ ] Sleep only when queue is empty
|
|
||||||
- [ ] Exit cleanly when stop event is set
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase D — Exports
|
|
||||||
|
|
||||||
- [ ] Update `src/transcription/services/__init__.py` to expose upload APIs
|
|
||||||
- [ ] Keep existing transcription exports intact
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase E — Tests via MCP scaffold -> fill flow
|
|
||||||
|
|
||||||
## E1 Scaffold (structure only)
|
|
||||||
|
|
||||||
Use scaffold prompt workflow first for:
|
|
||||||
- `src/transcription/services/upload.py`
|
|
||||||
- `src/transcription/worker.py`
|
|
||||||
|
|
||||||
Expected scaffold targets:
|
|
||||||
- `tests/services/test_upload.py`
|
|
||||||
- `tests/services/test_worker.py`
|
|
||||||
|
|
||||||
Scaffold rules:
|
|
||||||
- [ ] Class hierarchy + method names + one-line docstrings only
|
|
||||||
- [ ] No assertions or implementation details in scaffold phase
|
|
||||||
- [ ] Keep method names concise and behavior-focused
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
- [ ] `uv run pytest --collect-only -q`
|
|
||||||
|
|
||||||
## E2 Fill scaffold (implementation)
|
|
||||||
|
|
||||||
Use fill prompt workflow for:
|
|
||||||
- `tests/services/test_upload.py`
|
|
||||||
- `tests/services/test_worker.py`
|
|
||||||
- stack: `sqlalchemy-sync` (or `mixed` if combining pure + DB behaviors)
|
|
||||||
- marker lane preference: `unit` and `integration` as appropriate
|
|
||||||
- strategy: minimal deterministic implementation
|
|
||||||
|
|
||||||
Fill rules (invariants):
|
|
||||||
- [ ] Preserve scaffold class names, method names, and one-line docstrings
|
|
||||||
- [ ] Do not rename/re-nest scaffolded tests unless explicitly approved
|
|
||||||
- [ ] One behavior target per test
|
|
||||||
- [ ] Minimal mocking; mock only network/nondeterministic boundaries
|
|
||||||
|
|
||||||
Suggested test coverage:
|
|
||||||
|
|
||||||
### `tests/services/test_upload.py`
|
|
||||||
- [ ] creates file + document + queued job (`integration`)
|
|
||||||
- [ ] rejects empty bytes (`unit`)
|
|
||||||
- [ ] rejects unsupported extension (`unit`)
|
|
||||||
- [ ] writes collision-safe unique filename (`integration`)
|
|
||||||
- [ ] persisted job status is `queued` (`integration`)
|
|
||||||
|
|
||||||
### `tests/services/test_worker.py`
|
|
||||||
- [ ] returns `False` when queue empty (`integration`)
|
|
||||||
- [ ] transitions `queued -> processing -> transcribed` on success (`integration`)
|
|
||||||
- [ ] stores transcript text on success (`integration`)
|
|
||||||
- [ ] transitions to `failed` and stores `error_detail` on failure (`integration`)
|
|
||||||
- [ ] updates existing transcript instead of duplicate create (`integration`)
|
|
||||||
- [ ] worker loop exits when stop event set (`unit`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Marker Strategy
|
|
||||||
|
|
||||||
- `unit`: pure logic tests (filename handling, loop stop behavior, validation logic)
|
|
||||||
- `integration`: DB + service orchestration tests (SQLite/session/contracts)
|
|
||||||
- `external`: opt-in live provider tests only (not part of default Step 4 lane)
|
|
||||||
|
|
||||||
No new marker needed; reuse existing marker registration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Validation Sequence (strict order)
|
|
||||||
|
|
||||||
- [ ] `uv run pytest --collect-only -q`
|
|
||||||
- [ ] `uv run pytest -m unit -q` *(if unit tests touched)*
|
|
||||||
- [ ] `uv run pytest tests/services/test_upload.py -q`
|
|
||||||
- [ ] `uv run pytest tests/services/test_worker.py -q`
|
|
||||||
- [ ] `uv run pytest -q`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reporting Requirements (after implementation)
|
|
||||||
|
|
||||||
Implementation report must include:
|
|
||||||
|
|
||||||
1. Files created/updated
|
|
||||||
2. Fixture and marker decisions
|
|
||||||
3. MCP references used and why
|
|
||||||
4. Validation command results
|
|
||||||
5. Remaining risks/open questions (only blockers)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Keep Step 4 independent from UI concerns.
|
|
||||||
- Do not call provider SDK directly from worker.
|
|
||||||
- Do not silently swallow exceptions.
|
|
||||||
- Always persist terminal job outcome.
|
|
||||||
- Keep default suite deterministic and fast.
|
|
||||||
- Preserve scaffold invariants during fill phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Definition of Done (Step 4)
|
|
||||||
|
|
||||||
Step 4 is complete when:
|
|
||||||
|
|
||||||
- [ ] Upload service writes file and creates `Document` + queued `Job`
|
|
||||||
- [ ] Worker processes queued jobs end-to-end using Step 3 transcription service
|
|
||||||
- [ ] Success path persists transcript text and sets `transcribed`
|
|
||||||
- [ ] Failure path persists error detail and sets `failed`
|
|
||||||
- [ ] Queue-empty path returns cleanly
|
|
||||||
- [ ] New tests pass and full suite is green (`uv run pytest -q`)
|
|
||||||
- [ ] Output report includes MCP reference usage + validation evidence
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
Step 5 is implemented and passing ✅
|
|
||||||
|
|
||||||
### What I implemented
|
|
||||||
|
|
||||||
#### App/bootstrap
|
|
||||||
- Added `src/transcription/app.py`:
|
|
||||||
- `create_app()` factory
|
|
||||||
- FastAPI lifespan startup/shutdown:
|
|
||||||
- `setup_logging()`
|
|
||||||
- `create_all()`
|
|
||||||
- ensure `upload_dir` and `prompt_dir`
|
|
||||||
- start/stop worker thread (`run_worker_loop`)
|
|
||||||
- includes health router
|
|
||||||
- registers NiceGUI pages
|
|
||||||
|
|
||||||
#### API
|
|
||||||
- Added `src/transcription/api/__init__.py`
|
|
||||||
- Added `src/transcription/api/health.py`:
|
|
||||||
- `GET /healthz` returns `{"status": "ok"}`
|
|
||||||
|
|
||||||
#### UI
|
|
||||||
- Added `src/transcription/ui/upload_page.py`:
|
|
||||||
- upload page route
|
|
||||||
- accepted extensions helper
|
|
||||||
- upload submit helper to Step 4 service (`create_upload_job`)
|
|
||||||
- success/error state messaging
|
|
||||||
- Added `src/transcription/ui/jobs_page.py`:
|
|
||||||
- jobs list page
|
|
||||||
- job detail page
|
|
||||||
- `fetch_jobs()` and `fetch_job_detail()` helpers
|
|
||||||
- Updated `src/transcription/ui/__init__.py`:
|
|
||||||
- explicit page registration
|
|
||||||
- NiceGUI mounted via `ui.run_with(app, mount_path="/ui", ...)`
|
|
||||||
|
|
||||||
#### Dependencies
|
|
||||||
- Added runtime dependencies:
|
|
||||||
- `fastapi`
|
|
||||||
- `nicegui`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Tests created and filled (scaffold → fill)
|
|
||||||
|
|
||||||
- `tests/test_app.py`
|
|
||||||
- `tests/api/test_health.py`
|
|
||||||
- `tests/ui/test_pages_registration.py`
|
|
||||||
- `tests/ui/test_upload_page.py`
|
|
||||||
- `tests/ui/test_jobs_page.py`
|
|
||||||
|
|
||||||
All scaffolded method names/docstrings were preserved and filled with executable assertions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Validation results (strict sequence)
|
|
||||||
|
|
||||||
- `uv run pytest --collect-only -q` ✅
|
|
||||||
- `uv run pytest -m unit -q` ✅
|
|
||||||
- `uv run pytest tests/api/test_health.py -q` ✅
|
|
||||||
- `uv run pytest tests/ui/test_pages_registration.py -q` ✅
|
|
||||||
- `uv run pytest tests/test_app.py -q` ✅
|
|
||||||
- `uv run pytest tests/ui/test_upload_page.py -q` ✅
|
|
||||||
- `uv run pytest tests/ui/test_jobs_page.py -q` ✅
|
|
||||||
- `uv run pytest -q` ✅
|
|
||||||
|
|
||||||
Full suite is green.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Note
|
|
||||||
You’ll see one warning from FastAPI/Starlette test client about `httpx` deprecation; it does not affect correctness and all tests pass.
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
## Step 5: `app.py` + UI Pages (NiceGUI + FastAPI composition)
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement the MVP user-facing application layer so users can:
|
|
||||||
|
|
||||||
1. Upload a document from the UI
|
|
||||||
2. Trigger Step 4 upload/job creation flow
|
|
||||||
3. See live job lifecycle status (`queued`, `processing`, `transcribed`, `failed`)
|
|
||||||
4. Open a job detail view to read transcript text or failure details
|
|
||||||
|
|
||||||
This step composes Steps 1–4 into a usable UI.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Summary (NiceGUI-aligned)
|
|
||||||
|
|
||||||
Step 5 uses a **FastAPI app factory + lifespan orchestration** and mounts/registers NiceGUI pages via explicit page modules.
|
|
||||||
|
|
||||||
Reference baseline: `resource://skills/nicegui/document`
|
|
||||||
|
|
||||||
### Core architecture decisions
|
|
||||||
|
|
||||||
- **App factory:** `create_app()`
|
|
||||||
- **Lifespan-managed resources:** worker start/stop managed in startup/shutdown
|
|
||||||
- **Modular pages:** upload and jobs pages in separate modules (no monolithic UI file)
|
|
||||||
- **Health endpoint:** FastAPI-side `/healthz`
|
|
||||||
- **UI composition:** route pages stay modular and reusable shared shell/components live under `ui/components` as needed
|
|
||||||
- **Styling architecture:** shared CSS loaded once at startup; avoid ad-hoc per-page styling drift
|
|
||||||
- **Dependency direction (one-way):**
|
|
||||||
- `app` -> `config/logging/db/worker/ui/api`
|
|
||||||
- `ui/pages` -> `ui/components` + `services`
|
|
||||||
- `services` -> `db/models/providers`
|
|
||||||
- no reverse imports from services into UI/API
|
|
||||||
|
|
||||||
### DB and AI stance (explicit)
|
|
||||||
|
|
||||||
- **DB:** already enabled (SQLModel + SQLite), session lifecycle remains request/service-scoped as built in prior steps.
|
|
||||||
- **AI workflow:** already in place via Step 3 transcription service + Step 4 worker; UI does not call provider SDK directly.
|
|
||||||
- **Mounted docs:** not in Step 5 scope; docs mounting remains disabled for MVP.
|
|
||||||
|
|
||||||
### Async and responsiveness stance
|
|
||||||
|
|
||||||
- Prefer `async def` for page handlers and service boundaries when I/O is involved.
|
|
||||||
- Keep UI handlers non-blocking (no blocking sleeps or synchronous long I/O calls).
|
|
||||||
- For long-running user actions, always provide explicit loading/progress/error states.
|
|
||||||
- Keep cancellation/timeout behavior explicit for refresh/poll operations where applicable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
- `src/transcription/app.py`
|
|
||||||
- `src/transcription/ui/upload_page.py`
|
|
||||||
- `src/transcription/ui/jobs_page.py`
|
|
||||||
- `src/transcription/ui/__init__.py`
|
|
||||||
- `src/transcription/api/health.py` (or equivalent FastAPI health route module)
|
|
||||||
- UI/app tests with MCP scaffold->fill flow
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- Auth
|
|
||||||
- advanced filtering/search UX
|
|
||||||
- batch upload UX beyond MVP
|
|
||||||
- deployment/container hardening
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Planned Deliverables
|
|
||||||
|
|
||||||
### Source files
|
|
||||||
- `src/transcription/app.py` (app factory + lifespan wiring)
|
|
||||||
- `src/transcription/api/health.py` (GET `/healthz`)
|
|
||||||
- `src/transcription/ui/upload_page.py` (upload flow)
|
|
||||||
- `src/transcription/ui/jobs_page.py` (status list + detail)
|
|
||||||
- `src/transcription/ui/__init__.py` (explicit `register_pages(...)` export)
|
|
||||||
- `src/transcription/ui/components/*` (shared shell/navigation/status components if introduced)
|
|
||||||
- `src/transcription/ui/static/*.css` (optional shared CSS loaded once at startup)
|
|
||||||
|
|
||||||
### Test files
|
|
||||||
- `tests/test_app.py`
|
|
||||||
- `tests/api/test_health.py`
|
|
||||||
- `tests/ui/test_pages_registration.py`
|
|
||||||
- `tests/ui/test_upload_page.py`
|
|
||||||
- `tests/ui/test_jobs_page.py`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Plan + Checklist
|
|
||||||
|
|
||||||
Plan baseline and guardrails source: `resource://skills/nicegui/document`
|
|
||||||
|
|
||||||
## Phase A — App factory and lifespan orchestration
|
|
||||||
|
|
||||||
- [ ] Create `create_app()` in `src/transcription/app.py`
|
|
||||||
- [ ] Add FastAPI lifespan startup/shutdown handlers
|
|
||||||
- [ ] Startup responsibilities:
|
|
||||||
- [ ] `setup_logging()`
|
|
||||||
- [ ] `create_all()`
|
|
||||||
- [ ] ensure directories exist (`upload_dir`, `prompt_dir`)
|
|
||||||
- [ ] create worker stop event
|
|
||||||
- [ ] start worker background thread/task
|
|
||||||
- [ ] Shutdown responsibilities:
|
|
||||||
- [ ] signal stop event
|
|
||||||
- [ ] join/cleanup worker thread/task cleanly
|
|
||||||
- [ ] Register API router(s), including health route
|
|
||||||
- [ ] Register NiceGUI pages via explicit page registration function
|
|
||||||
- [ ] Load shared CSS once at startup (if present)
|
|
||||||
|
|
||||||
## Phase B — FastAPI health endpoint
|
|
||||||
|
|
||||||
- [ ] Create `src/transcription/api/health.py`
|
|
||||||
- [ ] Add `GET /healthz` returning simple healthy payload
|
|
||||||
- [ ] Wire route into app factory
|
|
||||||
|
|
||||||
## Phase C — Upload page (`ui/upload_page.py`)
|
|
||||||
|
|
||||||
- [ ] Add upload route/page registration function
|
|
||||||
- [ ] Render file input accepting supported extensions
|
|
||||||
- [ ] On submit:
|
|
||||||
- [ ] show loading/progress state
|
|
||||||
- [ ] call `create_upload_job(filename, file_bytes, ...)`
|
|
||||||
- [ ] show success state with job reference/link
|
|
||||||
- [ ] On error:
|
|
||||||
- [ ] show user-safe error message
|
|
||||||
- [ ] restore ready UI state
|
|
||||||
- [ ] Ensure non-blocking I/O in UI event handlers; offload CPU-heavy work to worker path
|
|
||||||
- [ ] Make timeout/cancellation behavior explicit for any long-running action
|
|
||||||
|
|
||||||
## Phase D — Jobs page (`ui/jobs_page.py`)
|
|
||||||
|
|
||||||
- [ ] Add jobs list route/page registration function
|
|
||||||
- [ ] Display jobs with status + timestamps
|
|
||||||
- [ ] Add job detail route/view
|
|
||||||
- [ ] Show transcript on success, error detail on failure
|
|
||||||
- [ ] Include explicit refresh action and loading state
|
|
||||||
- [ ] Ensure error states are surfaced to user and logged
|
|
||||||
- [ ] Keep refresh path async and bounded to avoid UI freeze
|
|
||||||
|
|
||||||
## Phase E — UI registration module
|
|
||||||
|
|
||||||
- [ ] Update `src/transcription/ui/__init__.py`
|
|
||||||
- [ ] Export `register_pages(...)`
|
|
||||||
- [ ] Ensure each page module exports `register_page(...)`
|
|
||||||
- [ ] Keep page registration explicit and modular
|
|
||||||
|
|
||||||
## Phase F — Shared components and style consistency
|
|
||||||
|
|
||||||
- [ ] Add `ui/components` module only for reusable shell elements (header/nav/status chips), not page-local logic
|
|
||||||
- [ ] Keep structural layout in Python; keep visual polish in shared CSS
|
|
||||||
- [ ] Avoid one-off styling duplication across upload/jobs pages
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MCP Testing Workflow (Required)
|
|
||||||
|
|
||||||
Use these resources directly:
|
|
||||||
|
|
||||||
- `resource://catalog/prompts/pytest-scaffold`
|
|
||||||
- `resource://prompts/pytest-scaffold/document`
|
|
||||||
- `resource://catalog/prompts/pytest-fill-scaffold`
|
|
||||||
- `resource://prompts/pytest-fill-scaffold/document`
|
|
||||||
|
|
||||||
## E1 — Scaffold tests first (structure only)
|
|
||||||
|
|
||||||
Target modules:
|
|
||||||
- `src/transcription/app.py`
|
|
||||||
- `src/transcription/api/health.py`
|
|
||||||
- `src/transcription/ui/upload_page.py`
|
|
||||||
- `src/transcription/ui/jobs_page.py`
|
|
||||||
|
|
||||||
Scaffold test files:
|
|
||||||
- `tests/test_app.py`
|
|
||||||
- `tests/api/test_health.py`
|
|
||||||
- `tests/ui/test_pages_registration.py`
|
|
||||||
- `tests/ui/test_upload_page.py`
|
|
||||||
- `tests/ui/test_jobs_page.py`
|
|
||||||
|
|
||||||
Scaffold constraints:
|
|
||||||
- [ ] class/method skeletons only
|
|
||||||
- [ ] one-line docstrings
|
|
||||||
- [ ] concise behavior-focused names
|
|
||||||
- [ ] no implementation assertions yet
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
- [ ] `uv run pytest --collect-only -q`
|
|
||||||
|
|
||||||
## E2 — Fill scaffold tests
|
|
||||||
|
|
||||||
Fill constraints from MCP guidance:
|
|
||||||
- [ ] preserve scaffold class/method names and docstrings (locked baseline)
|
|
||||||
- [ ] one behavior target per method
|
|
||||||
- [ ] deterministic tests preferred
|
|
||||||
- [ ] minimal mocking; only nondeterministic boundaries
|
|
||||||
|
|
||||||
Stack:
|
|
||||||
- [ ] `fastapi` (or `mixed` if needed for UI+DB fixture combination)
|
|
||||||
|
|
||||||
Suggested coverage:
|
|
||||||
|
|
||||||
### `tests/api/test_health.py`
|
|
||||||
- [ ] `/healthz` returns success status and expected payload shape
|
|
||||||
|
|
||||||
### `tests/ui/test_pages_registration.py`
|
|
||||||
- [ ] page registration wiring succeeds
|
|
||||||
- [ ] expected routes are present
|
|
||||||
|
|
||||||
### `tests/test_app.py`
|
|
||||||
- [ ] startup path initializes runtime dependencies
|
|
||||||
- [ ] worker start is invoked on startup
|
|
||||||
- [ ] worker shutdown signal/cleanup is invoked on shutdown
|
|
||||||
|
|
||||||
### `tests/ui/test_upload_page.py`
|
|
||||||
- [ ] upload action calls upload service
|
|
||||||
- [ ] success feedback displayed
|
|
||||||
- [ ] error feedback displayed for `UploadError`
|
|
||||||
- [ ] loading/progress state behavior covered
|
|
||||||
- [ ] timeout/cancellation behavior covered (if implemented)
|
|
||||||
|
|
||||||
### `tests/ui/test_jobs_page.py`
|
|
||||||
- [ ] list renders job statuses
|
|
||||||
- [ ] detail shows transcript text for successful job
|
|
||||||
- [ ] detail shows error detail for failed job
|
|
||||||
- [ ] refresh/loading state behavior covered
|
|
||||||
|
|
||||||
Marker strategy:
|
|
||||||
- [ ] `unit` for pure helpers/state formatting
|
|
||||||
- [ ] `integration` for app/page/service+DB contracts
|
|
||||||
- [ ] `external` not required for default Step 5 lane
|
|
||||||
|
|
||||||
Async behavior assertions:
|
|
||||||
- [ ] long-running actions keep button/inputs in expected disabled state
|
|
||||||
- [ ] completion/failure returns controls to ready state
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Validation Sequence (strict)
|
|
||||||
|
|
||||||
- [ ] `uv run pytest --collect-only -q`
|
|
||||||
- [ ] `uv run pytest -m unit -q` *(if unit tests touched)*
|
|
||||||
- [ ] `uv run pytest tests/api/test_health.py -q`
|
|
||||||
- [ ] `uv run pytest tests/ui/test_pages_registration.py -q`
|
|
||||||
- [ ] `uv run pytest tests/test_app.py -q`
|
|
||||||
- [ ] `uv run pytest tests/ui/test_upload_page.py -q`
|
|
||||||
- [ ] `uv run pytest tests/ui/test_jobs_page.py -q`
|
|
||||||
- [ ] `uv run pytest -q`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails (NiceGUI + MVP)
|
|
||||||
|
|
||||||
- [ ] Do not collapse pages into one file.
|
|
||||||
- [ ] Do not use implicit global side effects for runtime wiring.
|
|
||||||
- [ ] Keep UI responsive with explicit loading/progress/error states.
|
|
||||||
- [ ] Do not block UI handlers with synchronous long I/O.
|
|
||||||
- [ ] Do not place provider SDK calls in UI handlers.
|
|
||||||
- [ ] Keep dependency direction one-way and maintainable.
|
|
||||||
- [ ] Keep shared UI in `ui/components`; keep service logic out of page modules.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
|
|
||||||
- [ ] App factory + lifespan are in place
|
|
||||||
- [ ] Health endpoint exists and is tested
|
|
||||||
- [ ] Upload page creates queued jobs through service boundary
|
|
||||||
- [ ] Jobs list/detail pages render status/transcript/failure data
|
|
||||||
- [ ] Worker lifecycle is started/stopped by app lifespan
|
|
||||||
- [ ] Async UI states (loading/success/error) are deterministic and tested
|
|
||||||
- [ ] Scaffold->fill testing flow completed and validated
|
|
||||||
- [ ] Full suite passes: `uv run pytest -q`
|
|
||||||
|
|
||||||
## Completion Checks (NiceGUI skill aligned)
|
|
||||||
|
|
||||||
- [ ] Uses app factory and FastAPI lifespan
|
|
||||||
- [ ] Pages are modularized (not single-file UI)
|
|
||||||
- [ ] Health endpoint exists on FastAPI side
|
|
||||||
- [ ] Dependency direction is clean and one-way
|
|
||||||
- [ ] Async-first guidance is applied where I/O exists, with explicit non-blocking UX states
|
|
||||||
- [ ] DB/AI/docs decisions are explicit and reflected in structure
|
|
||||||
- [ ] Plan references baseline URI: `resource://skills/nicegui/document`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PR Checklist (Integrated)
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
- [ ] `app.py` app factory + lifespan implemented
|
|
||||||
- [ ] FastAPI health route (`/healthz`) implemented
|
|
||||||
- [ ] `ui/upload_page.py` implemented
|
|
||||||
- [ ] `ui/jobs_page.py` implemented
|
|
||||||
- [ ] `ui/__init__.py` explicit page registration implemented
|
|
||||||
- [ ] Worker startup/shutdown managed by lifespan
|
|
||||||
|
|
||||||
### Testing (MCP-compliant)
|
|
||||||
- [ ] Scaffold phase completed first for all Step 5 tests
|
|
||||||
- [ ] `--collect-only` passed on scaffolds
|
|
||||||
- [ ] Fill phase completed without renaming/re-nesting scaffolded tests
|
|
||||||
- [ ] Marker decisions documented (`unit` vs `integration`)
|
|
||||||
- [ ] Targeted tests passed
|
|
||||||
- [ ] Full suite passed
|
|
||||||
|
|
||||||
### Evidence
|
|
||||||
- [ ] Validation command outputs captured
|
|
||||||
- [ ] Files created/updated listed
|
|
||||||
- [ ] MCP prompt resources referenced in implementation notes
|
|
||||||
- [ ] Any residual risks/questions documented
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
Step 6 implemented ✅
|
|
||||||
|
|
||||||
### Files added
|
|
||||||
- `tests/integration/test_pipeline_flow.py`
|
|
||||||
- `tests/test_traceability.py`
|
|
||||||
|
|
||||||
### What was implemented
|
|
||||||
|
|
||||||
#### 1) End-to-end pipeline integration coverage
|
|
||||||
`tests/integration/test_pipeline_flow.py` now verifies:
|
|
||||||
|
|
||||||
- **Success path**
|
|
||||||
- upload creates queued job
|
|
||||||
- worker processes job
|
|
||||||
- job becomes `transcribed`
|
|
||||||
- transcript text is persisted
|
|
||||||
- error detail remains `None`
|
|
||||||
|
|
||||||
- **Failure path**
|
|
||||||
- upload creates queued job
|
|
||||||
- worker processing failure is handled
|
|
||||||
- job becomes `failed`
|
|
||||||
- transcript has `text=None`
|
|
||||||
- `error_detail` contains provider failure reason
|
|
||||||
|
|
||||||
#### 2) MVP requirement traceability check
|
|
||||||
`tests/test_traceability.py` adds a concrete map:
|
|
||||||
|
|
||||||
- REQ-0, REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-6, REQ-8, REQ-12
|
|
||||||
- each REQ maps to one or more existing test files
|
|
||||||
- test asserts each mapped path exists (guards against drift)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Validation run results
|
|
||||||
|
|
||||||
Executed and passed:
|
|
||||||
|
|
||||||
1. `uv run pytest --collect-only -q` ✅
|
|
||||||
2. `uv run pytest -m unit -q` ✅
|
|
||||||
3. `uv run pytest -m integration -q` ✅
|
|
||||||
4. `uv run pytest -m "not external" -q` ✅
|
|
||||||
5. `uv run pytest tests/integration/test_pipeline_flow.py -q` ✅
|
|
||||||
6. `uv run pytest tests/ui/test_upload_page.py -q` ✅
|
|
||||||
7. `uv run pytest tests/ui/test_jobs_page.py -q` ✅
|
|
||||||
8. `uv run pytest -q` ✅
|
|
||||||
|
|
||||||
All green.
|
|
||||||
(Only existing non-blocking FastAPI TestClient deprecation warning remains.)
|
|
||||||
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
## Step 6: Test and Verification Hardening (MVP closeout)
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Complete MVP verification by building a **requirements-traceable, deterministic test strategy** across unit/integration/external lanes, then enforcing stable validation commands and reporting.
|
|
||||||
|
|
||||||
This step finalizes the MVP implementation sequence from `docs/mvp.md` (Step 6 in the build order: tests and automated verification).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MCP Resource Integration (what was applied)
|
|
||||||
|
|
||||||
I reviewed all top-level skills/prompts from `john-stream-mcp` and integrated the relevant guidance into this plan:
|
|
||||||
|
|
||||||
### Directly applied
|
|
||||||
- `resource://skills/pytesting/document`
|
|
||||||
- `resource://catalog/prompts/pytest-scaffold`
|
|
||||||
- `resource://prompts/pytest-scaffold/document`
|
|
||||||
- `resource://catalog/prompts/pytest-fill-scaffold`
|
|
||||||
- `resource://prompts/pytest-fill-scaffold/document`
|
|
||||||
- `resource://skills/nicegui/document`
|
|
||||||
- `resource://skills/nicegui-ui-customization/document`
|
|
||||||
- `resource://skills/fastapi-uv-docker/document`
|
|
||||||
- `resource://skills/python-logging-dictconfig/document`
|
|
||||||
- `resource://skills/python-typing/document`
|
|
||||||
- `resource://skills/ruff-linting-formating/document`
|
|
||||||
|
|
||||||
### Reviewed but informational/non-blocking for Step 6
|
|
||||||
- `copilot-customization`, `mcp-details`, `vscode-configuration`, `zensical-docs`, and authoring/shim prompts.
|
|
||||||
- These are primarily customization/documentation tooling resources, not core MVP test-lane blockers.
|
|
||||||
- Step 6 includes optional workflow follow-ups where relevant (e.g., VS Code task conveniences).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
- Strengthen and complete test coverage for the shipped MVP slice (Steps 1–5)
|
|
||||||
- Add requirement-to-test traceability for REQ-0..REQ-12 (MVP subset emphasized)
|
|
||||||
- Enforce deterministic default lanes (`unit`, `integration`)
|
|
||||||
- Keep `external` lane opt-in and isolated
|
|
||||||
- Validate app/UI/service/worker contracts end-to-end at test level
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- Major architecture rewrites (async SQLAlchemy migration, queue system, etc.)
|
|
||||||
- Full production deployment rollout
|
|
||||||
- Post-MVP feature expansion (revision history, search, export)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Planned Deliverables
|
|
||||||
|
|
||||||
### Test files (new/updated)
|
|
||||||
- `tests/test_traceability.py` *(or docs-based traceability matrix if preferred)*
|
|
||||||
- `tests/integration/test_pipeline_flow.py` *(upload -> queued -> worker -> transcript/failed)*
|
|
||||||
- `tests/ui/test_upload_page.py` (augment loading/error/ready-state checks as practical)
|
|
||||||
- `tests/ui/test_jobs_page.py` (augment refresh/error behavior checks as practical)
|
|
||||||
- Existing tests touched only when needed; preserve naming/hierarchy unless explicitly approved.
|
|
||||||
|
|
||||||
### Optional docs output
|
|
||||||
- `docs/tests.md` or `docs/verification.md` with lane definitions and command matrix
|
|
||||||
- REQ-to-test mapping table
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design and Policy Decisions (MCP-aligned)
|
|
||||||
|
|
||||||
1. **Scaffold-first, fill-second workflow is mandatory**
|
|
||||||
- First create/adjust skeletons and collect.
|
|
||||||
- Then fill test bodies.
|
|
||||||
- Preserve scaffold names/docstrings during fill.
|
|
||||||
|
|
||||||
2. **Deterministic-first default lanes**
|
|
||||||
- `unit` and `integration` run by default.
|
|
||||||
- `external` remains explicit opt-in.
|
|
||||||
|
|
||||||
3. **One behavior target per test**
|
|
||||||
- Short, behavior-focused names.
|
|
||||||
- Precise assertions on observable outcomes.
|
|
||||||
|
|
||||||
4. **Test double discipline (from pytesting skill)**
|
|
||||||
- Prefer real-input/real-object paths first.
|
|
||||||
- If monkeypatch/mocks/fakes are needed for a boundary, keep narrowly scoped.
|
|
||||||
- Avoid call-only assertions.
|
|
||||||
|
|
||||||
5. **NiceGUI responsiveness expectations**
|
|
||||||
- Verify loading/success/error state transitions where testable.
|
|
||||||
- Ensure user-facing feedback behavior is covered.
|
|
||||||
|
|
||||||
6. **FastAPI/ops baseline checks**
|
|
||||||
- Keep `/healthz` route validation in default lanes.
|
|
||||||
- Keep startup/shutdown lifecycle assertions present.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Plan + Checklist
|
|
||||||
|
|
||||||
## Phase A — Coverage and traceability audit
|
|
||||||
|
|
||||||
- [ ] Build a REQ-to-test matrix for MVP requirements:
|
|
||||||
- [ ] REQ-0, REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-6, REQ-8, REQ-12
|
|
||||||
- [ ] Identify weak spots:
|
|
||||||
- [ ] full pipeline integration (service + worker + persistence)
|
|
||||||
- [ ] UI state transition assertions (loading/error/ready)
|
|
||||||
- [ ] failure-path persistence verification robustness
|
|
||||||
- [ ] Record current baseline command results before edits
|
|
||||||
|
|
||||||
## Phase B — Scaffold phase (pytest-scaffold resources)
|
|
||||||
|
|
||||||
Target modules/areas:
|
|
||||||
- pipeline integration flow
|
|
||||||
- UI behavior augmentations
|
|
||||||
- traceability checks/document validators (if test-backed)
|
|
||||||
|
|
||||||
- [ ] Scaffold new/adjusted test files/classes/methods only
|
|
||||||
- [ ] Keep one-line intent docstrings
|
|
||||||
- [ ] Keep behavior-focused names
|
|
||||||
- [ ] Run: `uv run pytest --collect-only -q`
|
|
||||||
|
|
||||||
## Phase C — Fill phase (pytest-fill-scaffold resources)
|
|
||||||
|
|
||||||
- [ ] Fill scaffolded methods with deterministic setup/assertions
|
|
||||||
- [ ] Preserve scaffold names/hierarchy/docstrings
|
|
||||||
- [ ] Add/adjust fixtures at nearest useful scope
|
|
||||||
- [ ] Keep DB tests in `integration`; pure helper tests in `unit`
|
|
||||||
|
|
||||||
### Required coverage additions
|
|
||||||
|
|
||||||
#### Pipeline integration
|
|
||||||
- [ ] Upload service creates document/job and file path persists
|
|
||||||
- [ ] Worker success path creates transcript and terminal status
|
|
||||||
- [ ] Worker failure path persists error detail and terminal failed status
|
|
||||||
- [ ] Queue-empty behavior remains stable (`False` return / no side effects)
|
|
||||||
|
|
||||||
#### UI behavior (practical, testable boundaries)
|
|
||||||
- [ ] Upload helper flow success and UploadError surfacing
|
|
||||||
- [ ] Jobs data helpers return stable normalized view models
|
|
||||||
- [ ] Refresh/detail fallback behavior for missing/invalid job IDs
|
|
||||||
|
|
||||||
#### Traceability
|
|
||||||
- [ ] Every in-scope MVP REQ has at least one mapped test/assertion point
|
|
||||||
- [ ] Document and/or enforce mapping consistency
|
|
||||||
|
|
||||||
## Phase D — External lane stability
|
|
||||||
|
|
||||||
- [ ] Keep real-image external tests isolated under `@pytest.mark.external`
|
|
||||||
- [ ] Ensure no external test leaks into default runs
|
|
||||||
- [ ] Confirm artifact capture behavior remains stable
|
|
||||||
|
|
||||||
## Phase E — Quality gates and workflow
|
|
||||||
|
|
||||||
- [ ] Confirm logging/lifecycle startup tests still pass after changes
|
|
||||||
- [ ] (If enabled) add/update lint/type check commands in docs:
|
|
||||||
- [ ] Ruff lane (if configured)
|
|
||||||
- [ ] typing lane (if configured)
|
|
||||||
- [ ] Optionally add VS Code task aliases for test lanes (non-blocking)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Marker and Fixture Strategy
|
|
||||||
|
|
||||||
- `unit`: pure logic, helper behavior, formatting/normalization
|
|
||||||
- `integration`: DB + service + app lifecycle contracts
|
|
||||||
- `external`: live provider/real image checks only
|
|
||||||
|
|
||||||
Fixture policy:
|
|
||||||
- Prefer reusable fixtures in `tests/conftest.py` only when broadly shared
|
|
||||||
- Use subtree/local fixtures for domain-specific setup
|
|
||||||
- Keep setup explicit and readable
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Validation Sequence (strict)
|
|
||||||
|
|
||||||
- [ ] `uv run pytest --collect-only -q`
|
|
||||||
- [ ] `uv run pytest -m unit -q`
|
|
||||||
- [ ] `uv run pytest -m integration -q`
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
- [ ] `uv run pytest tests/integration/test_pipeline_flow.py -q` *(if added)*
|
|
||||||
- [ ] `uv run pytest tests/ui/test_upload_page.py -q`
|
|
||||||
- [ ] `uv run pytest tests/ui/test_jobs_page.py -q`
|
|
||||||
- [ ] `uv run pytest -q`
|
|
||||||
|
|
||||||
Optional external verification:
|
|
||||||
- [ ] `uv run pytest -m external -q`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Do not rename/re-nest scaffolded tests during fill unless explicitly requested.
|
|
||||||
- Do not broaden external dependencies in default lane.
|
|
||||||
- Do not add flaky timing-based assertions; keep deterministic boundaries.
|
|
||||||
- Keep business logic out of UI tests; test through service/helper boundaries.
|
|
||||||
- Preserve one-way dependency direction in test setup patterns.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Definition of Done (Step 6)
|
|
||||||
|
|
||||||
- [ ] MVP requirement coverage is explicitly traceable
|
|
||||||
- [ ] Deterministic lanes (`unit` + `integration`) are stable and green
|
|
||||||
- [ ] External lane remains opt-in and green when enabled
|
|
||||||
- [ ] Pipeline success/failure lifecycle paths are verified end-to-end
|
|
||||||
- [ ] UI helper/state behavior has explicit success/error assertions
|
|
||||||
- [ ] Full suite passes with `uv run pytest -q`
|
|
||||||
- [ ] Verification evidence is captured in implementation report
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PR Checklist (Step 6)
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
- [ ] Added/updated test files per scoped gaps
|
|
||||||
- [ ] Added REQ traceability mapping
|
|
||||||
- [ ] Kept default lanes deterministic
|
|
||||||
- [ ] Preserved scaffold invariants during fill
|
|
||||||
|
|
||||||
### Testing (MCP-compliant)
|
|
||||||
- [ ] Used scaffold prompt flow first
|
|
||||||
- [ ] Used fill prompt flow second
|
|
||||||
- [ ] Preserved naming/docstrings/hierarchy
|
|
||||||
- [ ] Marker usage documented (`unit`, `integration`, `external`)
|
|
||||||
|
|
||||||
### Evidence
|
|
||||||
- [ ] Collected command outputs in strict order
|
|
||||||
- [ ] Listed files changed
|
|
||||||
- [ ] Listed MCP resources used and why
|
|
||||||
- [ ] Noted residual risks/open questions (if any)
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
## Step 7 Results: Error Handling Standardization and Operational Visibility
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Step 7 was implemented across the MVP runtime boundaries with a shared error taxonomy, actionable UI error surfacing, worker failure normalization, and API error envelope handling.
|
|
||||||
|
|
||||||
All required validation gates in `docs/step7.md` were executed and passed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope Delivered
|
|
||||||
|
|
||||||
### Implemented
|
|
||||||
- Shared application error contract and taxonomy
|
|
||||||
- Service-layer error normalization (upload + transcription)
|
|
||||||
- UI error presentation helpers with suggested actions and error references
|
|
||||||
- Worker failure persistence format with category/suggestion/error_id markers
|
|
||||||
- API exception handlers for structured error responses
|
|
||||||
- Targeted tests for new error contract behavior
|
|
||||||
|
|
||||||
### Not implemented in this step
|
|
||||||
- External lane execution (`-m external`) was not required for Step 7 completion and was not run in this pass.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Added
|
|
||||||
|
|
||||||
- `src/transcription/errors.py`
|
|
||||||
- `src/transcription/api/errors.py`
|
|
||||||
- `src/transcription/ui/error_presenter.py`
|
|
||||||
- `tests/test_errors.py`
|
|
||||||
- `tests/api/test_error_responses.py`
|
|
||||||
- `docs/step7.md`
|
|
||||||
|
|
||||||
## Files Updated
|
|
||||||
|
|
||||||
- `src/transcription/app.py`
|
|
||||||
- `src/transcription/services/upload.py`
|
|
||||||
- `src/transcription/services/transcription.py`
|
|
||||||
- `src/transcription/ui/upload_page.py`
|
|
||||||
- `src/transcription/ui/jobs_page.py`
|
|
||||||
- `src/transcription/worker.py`
|
|
||||||
- `tests/services/test_upload.py`
|
|
||||||
- `tests/services/test_transcription.py`
|
|
||||||
- `tests/services/test_worker.py`
|
|
||||||
- `tests/integration/test_pipeline_flow.py`
|
|
||||||
- `uv.lock`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Notes by Phase
|
|
||||||
|
|
||||||
### Phase A/B (Foundation)
|
|
||||||
- Added `ErrorCategory` enum and `AppError` base type in `src/transcription/errors.py`.
|
|
||||||
- Added helper utilities:
|
|
||||||
- `new_error_id()`
|
|
||||||
- `build_error_envelope(...)`
|
|
||||||
- `classify_unexpected_error(...)`
|
|
||||||
- `format_error_detail(...)`
|
|
||||||
|
|
||||||
### Phase C (Service/Provider normalization)
|
|
||||||
- `UploadError` now extends `AppError` and includes category/suggestion/retriable metadata.
|
|
||||||
- `PromptLoadError` and `TranscriptionError` now extend `AppError`.
|
|
||||||
- Provider failures are mapped with deterministic category semantics (auth/payload/provider-failure cases).
|
|
||||||
|
|
||||||
### Phase D (UI visibility)
|
|
||||||
- Added `src/transcription/ui/error_presenter.py`.
|
|
||||||
- Upload and jobs pages now use centralized UI error rendering and summary helpers.
|
|
||||||
- UI error paths now include more visible/actionable guidance and reference IDs.
|
|
||||||
|
|
||||||
### Phase E (Worker failure handling)
|
|
||||||
- Worker now normalizes exception handling into structured persisted `error_detail` strings with:
|
|
||||||
- category marker
|
|
||||||
- suggestion marker
|
|
||||||
- error_id marker
|
|
||||||
- Logging now includes category/error_id context in failure paths.
|
|
||||||
|
|
||||||
### Phase F (API envelope)
|
|
||||||
- Added `src/transcription/api/errors.py` and registered handlers in app factory.
|
|
||||||
- AppError and unexpected exceptions now serialize to stable API envelopes with mapped status codes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Validation Commands and Outcomes
|
|
||||||
|
|
||||||
All commands were executed with `uv run python -m pytest ...` and completed successfully.
|
|
||||||
|
|
||||||
1. `uv run python -m pytest tests/test_errors.py -q` ✅
|
|
||||||
2. `uv run python -m pytest tests/services/test_upload.py -q` ✅
|
|
||||||
3. `uv run python -m pytest tests/services/test_transcription.py -q` ✅
|
|
||||||
4. `uv run python -m pytest tests/providers/test_openrouter.py -q` ✅
|
|
||||||
5. `uv run python -m pytest tests/services/test_worker.py -q` ✅
|
|
||||||
6. `uv run python -m pytest tests/integration/test_pipeline_flow.py -q` ✅
|
|
||||||
7. `uv run python -m pytest tests/api/test_error_responses.py -q` ✅
|
|
||||||
8. `uv run python -m pytest tests/ui/test_upload_page.py -q` ✅
|
|
||||||
9. `uv run python -m pytest tests/ui/test_jobs_page.py -q` ✅
|
|
||||||
10. `uv run python -m pytest -m "not external" -q` ✅
|
|
||||||
11. `uv run python -m pytest --collect-only -q` ✅
|
|
||||||
12. `uv run python -m pytest -m unit -q` ✅
|
|
||||||
13. `uv run python -m pytest -m integration -q` ✅
|
|
||||||
14. `uv run python -m pytest tests/integration/test_pipeline_flow.py -q` ✅
|
|
||||||
15. `uv run python -m pytest tests/ui/test_upload_page.py -q` ✅
|
|
||||||
16. `uv run python -m pytest tests/ui/test_jobs_page.py -q` ✅
|
|
||||||
17. `uv run python -m pytest -q` ✅
|
|
||||||
|
|
||||||
Observed warning (non-blocking): Starlette/FastAPI TestClient deprecation warning related to `httpx` package naming.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Policy Alignment Check (`docs/error_handling.md`)
|
|
||||||
|
|
||||||
Aligned items:
|
|
||||||
- Stable taxonomy categories are implemented.
|
|
||||||
- Unexpected errors are normalized.
|
|
||||||
- User-facing UI paths include actionable guidance and references.
|
|
||||||
- Worker persistence includes trace-friendly failure detail.
|
|
||||||
- API error responses are structured and category-aware.
|
|
||||||
|
|
||||||
Follow-up candidates:
|
|
||||||
- Add richer UI tests that validate rendered suggested-action content end-to-end (current tests focus helper/service contracts).
|
|
||||||
- Consider typed storage fields for error metadata instead of packed `error_detail` strings in a future schema revision.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 7 Definition of Done Status
|
|
||||||
|
|
||||||
- [x] Shared error taxonomy implemented across MVP layers
|
|
||||||
- [x] GUI error paths upgraded for visibility/actionability
|
|
||||||
- [x] Worker failure persistence and log context standardized
|
|
||||||
- [x] API error envelope handling added and tested
|
|
||||||
- [x] Phase-level and full-suite validation gates passed
|
|
||||||
- [x] Results documented in this report
|
|
||||||
|
|
||||||
Step 7 is complete.
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
## Step 7: Error Handling Standardization and Operational Visibility
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Apply the canonical error policy from `docs/error_handling.md` to the MVP implementation so failures are:
|
|
||||||
|
|
||||||
- consistently classified
|
|
||||||
- visibly surfaced in the GUI
|
|
||||||
- paired with suggested corrective actions
|
|
||||||
- traceable through logs via error reference IDs
|
|
||||||
- validated through deterministic tests after each phase
|
|
||||||
|
|
||||||
This step extends MVP hardening by converting current ad hoc exception behavior into a stable cross-layer contract.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
- Introduce a shared application error contract and taxonomy implementation
|
|
||||||
- Normalize service/provider exceptions into taxonomy categories
|
|
||||||
- Improve GUI error visibility and suggested-action UX
|
|
||||||
- Standardize worker failure persistence and logging context
|
|
||||||
- Add API error-envelope policy hooks for current/future endpoints
|
|
||||||
- Add targeted tests and phase-level/full-suite validation gates
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- Major architecture rewrites (distributed queue, multi-service decomposition)
|
|
||||||
- Post-MVP feature expansion unrelated to error handling
|
|
||||||
- Full observability platform rollout (tracing backends, APM)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Policy Source of Truth
|
|
||||||
|
|
||||||
- Canonical policy document: `docs/error_handling.md`
|
|
||||||
- If implementation and policy diverge, policy is authoritative and code/tests must be updated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Planned Deliverables
|
|
||||||
|
|
||||||
### Runtime code
|
|
||||||
- `src/transcription/errors.py` *(new shared contract module)*
|
|
||||||
- `src/transcription/ui/error_presenter.py` *(new UI error rendering helper)*
|
|
||||||
- Updates to:
|
|
||||||
- `src/transcription/services/upload.py`
|
|
||||||
- `src/transcription/services/transcription.py`
|
|
||||||
- `src/transcription/providers/openrouter.py`
|
|
||||||
- `src/transcription/worker.py`
|
|
||||||
- `src/transcription/ui/upload_page.py`
|
|
||||||
- `src/transcription/ui/jobs_page.py`
|
|
||||||
- `src/transcription/api/*` *(as needed for envelope/handlers)*
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- `tests/test_errors.py` *(new shared error contract tests)*
|
|
||||||
- updates/additions in:
|
|
||||||
- `tests/services/test_upload.py`
|
|
||||||
- `tests/services/test_transcription.py` *(add if missing)*
|
|
||||||
- `tests/providers/test_openrouter.py`
|
|
||||||
- `tests/services/test_worker.py`
|
|
||||||
- `tests/ui/test_upload_page.py`
|
|
||||||
- `tests/ui/test_jobs_page.py`
|
|
||||||
- `tests/api/test_error_responses.py` *(new, if API handlers added)*
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- Update `docs/error_handling.md` only if implementation reveals policy gaps
|
|
||||||
- Capture validation evidence in a Step 7 results artifact (`docs/step7-results.md`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design and Policy Decisions
|
|
||||||
|
|
||||||
1. **Stable taxonomy contract**
|
|
||||||
- Use policy categories as stable identifiers (`validation_error`, `user_input_error`, etc.).
|
|
||||||
|
|
||||||
2. **Actionable UX is mandatory**
|
|
||||||
- User-visible errors must include a suggested course of action.
|
|
||||||
|
|
||||||
3. **Traceability by default**
|
|
||||||
- Non-trivial errors include an `error_id` in both logs and user-facing output.
|
|
||||||
|
|
||||||
4. **Safe surface / rich logs**
|
|
||||||
- UI/API show safe summaries; logs retain diagnostic detail and traceback.
|
|
||||||
|
|
||||||
5. **Deterministic verification cadence**
|
|
||||||
- Targeted tests after each change batch, then phase-level regression gates.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Plan + Checklist
|
|
||||||
|
|
||||||
## Phase A — Baseline Validation and Gap Confirmation
|
|
||||||
|
|
||||||
- [ ] Run baseline tests before changes
|
|
||||||
- [ ] Record baseline outputs and any known flaky behavior
|
|
||||||
- [ ] Confirm current behavior against `docs/error_handling.md` requirements
|
|
||||||
|
|
||||||
### Validation gate
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
- [ ] `uv run pytest -q`
|
|
||||||
|
|
||||||
## Phase B — Shared Error Contract Foundation
|
|
||||||
|
|
||||||
- [ ] Add `src/transcription/errors.py` with:
|
|
||||||
- [ ] stable category enum
|
|
||||||
- [ ] base `AppError` (category/message/suggestion/error_id/retriable)
|
|
||||||
- [ ] helpers for error-id generation and fallback classification
|
|
||||||
- [ ] Keep category names aligned with `docs/error_handling.md`
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- [ ] Add `tests/test_errors.py`
|
|
||||||
- [ ] category stability assertions
|
|
||||||
- [ ] error_id creation behavior
|
|
||||||
- [ ] fallback classification for unexpected exceptions
|
|
||||||
|
|
||||||
### Validation gate
|
|
||||||
- [ ] `uv run pytest tests/test_errors.py -q`
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
|
|
||||||
## Phase C — Service and Provider Normalization
|
|
||||||
|
|
||||||
- [ ] Refactor upload service exceptions to shared taxonomy
|
|
||||||
- [ ] Refactor transcription service exceptions to shared taxonomy
|
|
||||||
- [ ] Normalize provider adapter failures into deterministic categories
|
|
||||||
- [ ] Preserve causal chaining (`raise ... from exc`)
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- [ ] Extend `tests/services/test_upload.py`:
|
|
||||||
- [ ] empty payload category/suggestion
|
|
||||||
- [ ] unsupported extension category/suggestion
|
|
||||||
- [ ] persistence failure category mapping
|
|
||||||
- [ ] Add/extend `tests/services/test_transcription.py`:
|
|
||||||
- [ ] missing/empty prompt behavior
|
|
||||||
- [ ] unsupported file type behavior
|
|
||||||
- [ ] provider failure mapping behavior
|
|
||||||
- [ ] Extend `tests/providers/test_openrouter.py`:
|
|
||||||
- [ ] auth error mapping
|
|
||||||
- [ ] malformed response mapping
|
|
||||||
|
|
||||||
### Validation gate
|
|
||||||
- [ ] `uv run pytest tests/services/test_upload.py -q`
|
|
||||||
- [ ] `uv run pytest tests/services/test_transcription.py -q`
|
|
||||||
- [ ] `uv run pytest tests/providers/test_openrouter.py -q`
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
|
|
||||||
## Phase D — GUI Visibility and Suggested Actions
|
|
||||||
|
|
||||||
- [ ] Add `src/transcription/ui/error_presenter.py`
|
|
||||||
- [ ] Update upload/jobs pages to use centralized error presentation
|
|
||||||
- [ ] Ensure GUI surfaces:
|
|
||||||
- [ ] user-safe message
|
|
||||||
- [ ] suggested action
|
|
||||||
- [ ] error reference ID
|
|
||||||
- [ ] optional technical details panel
|
|
||||||
- [ ] Replace raw `str(exc)` UX where policy requires safer messaging
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- [ ] Extend `tests/ui/test_upload_page.py` for actionable error UX paths
|
|
||||||
- [ ] Extend `tests/ui/test_jobs_page.py` for refresh/detail error guidance
|
|
||||||
- [ ] Add `tests/ui/test_error_presenter.py` *(optional but recommended)*
|
|
||||||
|
|
||||||
### Validation gate
|
|
||||||
- [ ] `uv run pytest tests/ui/test_upload_page.py -q`
|
|
||||||
- [ ] `uv run pytest tests/ui/test_jobs_page.py -q`
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
|
|
||||||
## Phase E — Worker Failure Persistence and Logging Context
|
|
||||||
|
|
||||||
- [ ] Update worker failure handling to classify errors before persistence
|
|
||||||
- [ ] Ensure failed jobs persist actionable, structured error detail
|
|
||||||
- [ ] Add log context fields where available (`error_id`, `category`, `operation`, `job_id`)
|
|
||||||
- [ ] Ensure retry semantics are explicit and bounded (or clearly documented as deferred)
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- [ ] Extend `tests/services/test_worker.py`:
|
|
||||||
- [ ] missing document failure contract
|
|
||||||
- [ ] provider/transcription failure contract
|
|
||||||
- [ ] persisted error detail includes category/suggestion/error_id markers
|
|
||||||
- [ ] Validate integration failure flow in `tests/integration/test_pipeline_flow.py`
|
|
||||||
|
|
||||||
### Validation gate
|
|
||||||
- [ ] `uv run pytest tests/services/test_worker.py -q`
|
|
||||||
- [ ] `uv run pytest tests/integration/test_pipeline_flow.py -q`
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
|
|
||||||
## Phase F — API Error Envelope Alignment (Current + Future Routes)
|
|
||||||
|
|
||||||
- [ ] Add shared API error serialization utilities/handlers (as needed)
|
|
||||||
- [ ] Ensure API responses can include:
|
|
||||||
- [ ] `error_id`
|
|
||||||
- [ ] `category`
|
|
||||||
- [ ] `message`
|
|
||||||
- [ ] `suggestion`
|
|
||||||
- [ ] `timestamp`
|
|
||||||
- [ ] Map categories to HTTP status guidance from `docs/error_handling.md`
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- [ ] Add `tests/api/test_error_responses.py` *(if handlers added)*
|
|
||||||
- [ ] Keep `tests/api/test_health.py` passing
|
|
||||||
|
|
||||||
### Validation gate
|
|
||||||
- [ ] `uv run pytest tests/api/test_error_responses.py -q` *(if added)*
|
|
||||||
- [ ] `uv run pytest tests/api/test_health.py -q`
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
|
|
||||||
## Phase G — Final Regression and Documentation Closure
|
|
||||||
|
|
||||||
- [ ] Reconcile implementation details with `docs/error_handling.md`
|
|
||||||
- [ ] Update policy doc only where required by confirmed implementation learning
|
|
||||||
- [ ] Capture execution evidence in `docs/step7-results.md`
|
|
||||||
|
|
||||||
### Final validation sequence (strict)
|
|
||||||
- [ ] `uv run pytest --collect-only -q`
|
|
||||||
- [ ] `uv run pytest -m unit -q`
|
|
||||||
- [ ] `uv run pytest -m integration -q`
|
|
||||||
- [ ] `uv run pytest -m "not external" -q`
|
|
||||||
- [ ] `uv run pytest tests/integration/test_pipeline_flow.py -q`
|
|
||||||
- [ ] `uv run pytest tests/ui/test_upload_page.py -q`
|
|
||||||
- [ ] `uv run pytest tests/ui/test_jobs_page.py -q`
|
|
||||||
- [ ] `uv run pytest -q`
|
|
||||||
|
|
||||||
Optional:
|
|
||||||
- [ ] `uv run pytest -m external -q`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Do not weaken user-facing clarity to expose raw internals.
|
|
||||||
- Do not introduce silent exception swallowing.
|
|
||||||
- Do not break category-name stability without policy update.
|
|
||||||
- Do not merge phase changes without passing that phase validation gate.
|
|
||||||
- Keep targeted tests fast and deterministic; isolate external-provider tests under `external`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Definition of Done (Step 7)
|
|
||||||
|
|
||||||
- [ ] Shared error taxonomy is implemented and used across MVP layers
|
|
||||||
- [ ] GUI error experiences are visible, actionable, and traceable
|
|
||||||
- [ ] Worker persists and logs failure context consistently
|
|
||||||
- [ ] API error contract path is aligned for current/future endpoints
|
|
||||||
- [ ] Phase-by-phase test gates pass
|
|
||||||
- [ ] Full suite remains green (`uv run pytest -q`)
|
|
||||||
- [ ] Step 7 results are documented with evidence
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PR Checklist (Step 7)
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
- [ ] Added shared error contract module
|
|
||||||
- [ ] Updated service/provider/worker/UI error handling paths
|
|
||||||
- [ ] Added actionable GUI guidance for user-visible failures
|
|
||||||
- [ ] Added error reference IDs for traceability
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- [ ] Added/updated tests per phase scope
|
|
||||||
- [ ] Ran targeted phase tests after each change batch
|
|
||||||
- [ ] Ran `not external` regression at each phase boundary
|
|
||||||
- [ ] Ran full suite before closeout
|
|
||||||
|
|
||||||
### Documentation and Evidence
|
|
||||||
- [ ] `docs/error_handling.md` reviewed for alignment
|
|
||||||
- [ ] `docs/step7-results.md` includes executed command outputs
|
|
||||||
- [ ] Residual risks and deferred items explicitly recorded
|
|
||||||
-209
@@ -1,209 +0,0 @@
|
|||||||
## MVP Definition: Historical Document Transcription System
|
|
||||||
|
|
||||||
### 1. MVP Objective
|
|
||||||
Deliver the thinnest possible end-to-end vertical slice — a user uploads an image of a document, the system transcribes it via the OpenRouter Python SDK, and the user reads the resulting transcript — with just enough persistence and structure to validate the core value proposition: *can AI-driven transcription, guided by curated prompts, produce useful verbatim transcripts of historical family documents?*
|
|
||||||
|
|
||||||
The MVP deliberately defers full-text search, export, revision history, MongoDB, and timeline assembly. These are additive features that don't need validation before the core transcription loop is proven.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Core User Story
|
|
||||||
*As a family historian, I can upload a photo of a historical document, wait for it to be transcribed, and read the verbatim transcript — so I can evaluate whether this system will work for my thousands of documents.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. In-Scope Requirements (from ```requirements.md```)
|
|
||||||
|
|
||||||
| Requirement | ID | MVP Rationale |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| End-to-end transcription with lifecycle state | REQ-0 | This is the MVP. |
|
|
||||||
| Upload one or more images from the web UI | REQ-1 | Core entry point. MVP supports single-image upload (multi-image is a stretch goal). |
|
|
||||||
| Asynchronous processing → transcription or failure | REQ-2 | Validates the AI transcription pipeline. |
|
|
||||||
| Persist and expose job states (queued → processing → transcribed/failed) | REQ-3 | Minimum feedback loop for the user. |
|
|
||||||
| Persist transcription output and failure details | REQ-4 | User must be able to read the result. |
|
|
||||||
| UI views for status and transcript reading | REQ-5 | The user needs to see what happened. |
|
|
||||||
| Background processing to keep UI responsive | REQ-6 | Essential for usability during long AI calls. |
|
|
||||||
| Centralized config and logging at startup | REQ-8 | Small effort, high payoff for debugging. |
|
|
||||||
| Store transcription prompts as Markdown files | REQ-12 | Core to the Prompt Curation Policy in intent.md. Start with a single prompt file. |
|
|
||||||
|
|
||||||
|
|
||||||
### Deferred to Post-MVP
|
|
||||||
| Requirement | ID | Why Deferred |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Lifespan-owned runtime resources (engine, session factory, etc.) | REQ-7 | Important for production robustness, but a simple global or module-level setup is adequate for MVP validation. |
|
|
||||||
| Docker Compose (app + PostgreSQL + optional MongoDB) | REQ-9 | MVP runs locally with SQLite to eliminate container overhead during rapid iteration. PostgreSQL migration is Stage 1 hardening. |
|
|
||||||
| Explicit, opt-in schema bootstrap | REQ-10 | MVP uses auto-create-tables at startup (SQLModel create_all). Production schema discipline comes after the model stabilizes. |
|
|
||||||
| Service-backed persistence for core data | REQ-11 | MVP uses a thin repository layer over SQLite. Full service abstraction follows once the domain model is proven. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. MVP Feature Set
|
|
||||||
#### Feature 1: Document Upload (UI)
|
|
||||||
* A single NiceGUI page with a file-upload widget (accepts .jpg, .png, .tiff, .pdf).
|
|
||||||
* On upload: save the file to a local uploads/ directory, create a Document record, create a Job record with status queued.
|
|
||||||
* Minimal metadata capture: original filename, upload timestamp.
|
|
||||||
|
|
||||||
#### Feature 2: Asynchronous Transcription Worker
|
|
||||||
* An in-process background worker (Python asyncio task or BackgroundTasks) that:
|
|
||||||
1. Picks up queued jobs.
|
|
||||||
2. Transitions status to processing.
|
|
||||||
3. Sends the image + the curated Markdown prompt to an AI vision model via OpenRouter.
|
|
||||||
4. On success: saves the transcript text, transitions to transcribed.
|
|
||||||
5. On failure: saves the error detail, transitions to failed.
|
|
||||||
|
|
||||||
#### Feature 3: Transcription Prompt (Markdown Asset)
|
|
||||||
* A single Markdown file (prompts/transcribe_document.md) encoding the verbatim transcription rules from intent.md (the Document Issues table, scholarly guidelines, etc.).
|
|
||||||
* The worker reads this file at invocation time and injects it as the system/user prompt.
|
|
||||||
|
|
||||||
#### Feature 4: Job Status & Transcript Viewer (UI)
|
|
||||||
* A job list page showing all jobs with their current status (queued / processing / transcribed / failed).
|
|
||||||
* A transcript detail page showing:
|
|
||||||
* The original uploaded image (rendered inline).
|
|
||||||
* The transcription text (or the failure reason).
|
|
||||||
* Timestamp metadata.
|
|
||||||
|
|
||||||
#### Feature 5: Minimal Persistence (SQLite + SQLModel)
|
|
||||||
* Three tables/models:
|
|
||||||
* Document: id, filename, file_path, uploaded_at.
|
|
||||||
* Job: id, document_id (FK), status, created_at, updated_at.
|
|
||||||
* Transcript: id, job_id (FK), text, error_detail, created_at.
|
|
||||||
* SQLite database file stored locally. Auto-created on first startup.
|
|
||||||
|
|
||||||
#### Feature 6: Centralized Configuration
|
|
||||||
* A single config.py (or Pydantic BaseSettings) loading:
|
|
||||||
* PROVIDER (fixed to openrouter for MVP)
|
|
||||||
* OPENROUTER_API_KEY (required)
|
|
||||||
* PROVIDER_MODEL (default: OpenRouter model slug for vision transcription)
|
|
||||||
* OPENROUTER_HTTP_REFERER (optional; app attribution)
|
|
||||||
* OPENROUTER_APP_TITLE (optional; app attribution)
|
|
||||||
* DATABASE_URL (default: sqlite:///./transcription.db)
|
|
||||||
* UPLOAD_DIR (default: ./uploads)
|
|
||||||
* PROMPT_DIR (default: ./prompts)
|
|
||||||
|
|
||||||
#### Feature 7: MVP Dependency Baseline (OpenRouter-Centric)
|
|
||||||
* Runtime dependencies:
|
|
||||||
* openrouter (official OpenRouter Python SDK)
|
|
||||||
* pydantic
|
|
||||||
* pydantic-settings
|
|
||||||
* sqlmodel
|
|
||||||
* Explicitly out of MVP runtime dependencies:
|
|
||||||
* google-genai (deferred until/if Gemini is introduced post-MVP)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. MVP Architecture (Simplified)
|
|
||||||
|
|
||||||
```Apply
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ NiceGUI Web UI │
|
|
||||||
│ ┌──────────────┐ ┌───────────────────┐ │
|
|
||||||
│ │ Upload Page │ │ Jobs / Transcript │ │
|
|
||||||
│ └──────┬───────┘ └───────┬───────────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ ▼ ▼ │
|
|
||||||
│ ┌───────────────────────────┐ │
|
|
||||||
│ │ Application Service │ │
|
|
||||||
│ │ (upload, job lifecycle) │ │
|
|
||||||
│ └─────┬─────────────┬───────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ ┌─────▼─────┐ ┌─────▼───────────────┐ │
|
|
||||||
│ │ SQLite DB │ │ Background Worker │ │
|
|
||||||
│ │ (SQLModel)│ │ → AI Vision Provider│ │
|
|
||||||
│ └───────────┘ └─────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌─────▼──────┐ │
|
|
||||||
│ │ prompts/ │ │
|
|
||||||
│ │ *.md files │ │
|
|
||||||
│ └────────────┘ │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 6. Proposed File Structure
|
|
||||||
|
|
||||||
```Apply
|
|
||||||
project-root/
|
|
||||||
├── docs/ # (existing)
|
|
||||||
├── prompts/
|
|
||||||
│ └── transcribe_document.md # curated transcription prompt
|
|
||||||
├── src/
|
|
||||||
│ └── transcription/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── app.py # FastAPI + NiceGUI app entrypoint
|
|
||||||
│ ├── config.py # Pydantic BaseSettings
|
|
||||||
│ ├── models.py # SQLModel: Document, Job, Transcript
|
|
||||||
│ ├── db.py # engine, session, create_all
|
|
||||||
│ ├── providers/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── base.py # provider interface (transcribe contract)
|
|
||||||
│ │ ├── openrouter.py # OpenRouter via official Python SDK
|
|
||||||
│ ├── services/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── upload.py # save file + create records
|
|
||||||
│ │ └── transcription.py # call provider, update job
|
|
||||||
│ ├── worker.py # background job loop
|
|
||||||
│ └── ui/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── upload_page.py # NiceGUI upload page
|
|
||||||
│ └── jobs_page.py # NiceGUI job list + detail
|
|
||||||
├── tests/
|
|
||||||
│ ├── test_models.py
|
|
||||||
│ ├── test_upload.py
|
|
||||||
│ └── test_transcription.py
|
|
||||||
├── pyproject.toml
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 7. MVP Validation Criteria
|
|
||||||
The MVP is considered validated when:
|
|
||||||
|
|
||||||
1. ✅ A user can upload an image of a document through the browser.
|
|
||||||
2. ✅ The system asynchronously sends the image to the configured AI vision model with the curated prompt.
|
|
||||||
3. ✅ The transcript (or failure reason) is persisted and visible in the UI.
|
|
||||||
4. ✅ The transcription follows verbatim scholarly rules defined in intent.md (spot-checked by the user on real family documents).
|
|
||||||
5. ✅ The transcription prompt is stored as a standalone Markdown file and can be edited without code changes.
|
|
||||||
6. ✅ Job status transitions are visible: queued → processing → transcribed/failed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. Key Feedback Questions the MVP Should Answer
|
|
||||||
These are the real unknowns this MVP exists to resolve:
|
|
||||||
|
|
||||||
| # | Question | How We Learn |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 1 | Is AI transcription quality good enough for this document corpus? | User reviews 20–50 real transcriptions against originals. |
|
|
||||||
| 2 | Does the verbatim prompt produce scholarly-quality output, or does it need major rework? | Compare output to the Document Issues table rules in intent.md. |
|
|
||||||
| 3 | What document types are hardest (old cursive, faded ink, pencil, postcards)? | Track which uploads produce failed or low-quality results. |
|
|
||||||
| 4 | Is single-image upload sufficient, or is batch upload needed early? | User friction during real scanning sessions. |
|
|
||||||
| 5 | What metadata is missing that the user wishes they could capture at upload time? | User feedback after processing real batches. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 9. What Comes After MVP (Immediate Post-MVP)
|
|
||||||
Once the core transcription loop is validated, the next priorities (aligned to Architecture Stage 1) are:
|
|
||||||
|
|
||||||
1. **Multi-image upload** — process a batch from a scanning session.
|
|
||||||
2. **PostgreSQL migration** — swap SQLite for containerized PostgreSQL (REQ-9, REQ-10).
|
|
||||||
3. **Revision history** — allow the user to edit/correct transcripts with immutable version tracking.
|
|
||||||
4. **Full-text search** — search across all accepted transcripts.
|
|
||||||
5. **Repository/service layer formalization** — proper ports/adapters as the domain model stabilizes.
|
|
||||||
6. **Docker Compose deployment** — containerize the app for reproducible operation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 10. Implementation Approach
|
|
||||||
Recommended build order for the MVP (each step produces a testable increment):
|
|
||||||
|
|
||||||
| Step | Deliverable | Validates |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 1 | config.py + models.py + db.py — data layer with SQLite | Schema and config foundation |
|
|
||||||
| 2 | prompts/transcribe_document.md — curated prompt from intent.md | Prompt asset pattern |
|
|
||||||
| 3 | services/transcription.py + providers/ — call AI vision provider with prompt + image | Core AI integration |
|
|
||||||
| 4 | services/upload.py + worker.py — upload handling + background job loop | End-to-end pipeline (CLI-testable) |
|
|
||||||
| 5 | ui/upload_page.py + ui/jobs_page.py — NiceGUI pages | User-facing interface |
|
|
||||||
| 6 | tests/ — unit + integration tests Automated verification |
|
|
||||||
|
|
||||||
This MVP is deliberately narrow: **one prompt, one provider (OpenRouter), one user, one image at a time, SQLite, no containers**. Every omission is intentional — the goal is to get real family documents through the transcription pipeline as fast as possible and let the quality of the output guide every subsequent decision.
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Production Runbook
|
||||||
|
|
||||||
|
This runbook is the operational checklist for releasing and monitoring the transcription system.
|
||||||
|
|
||||||
|
## 1. Pre-release gate checklist
|
||||||
|
|
||||||
|
1. Run the full suite: `uv run pytest`
|
||||||
|
2. Confirm contract guardrails are green:
|
||||||
|
- `uv run pytest tests/test_meta_contract_guards.py`
|
||||||
|
3. Confirm health endpoint includes worker liveness payload (`/healthz` returns `worker.state`).
|
||||||
|
4. Confirm required runtime settings are present in deployment environment:
|
||||||
|
- `OPENROUTER_API_KEY`
|
||||||
|
- `DATABASE__*`
|
||||||
|
- filesystem paths for data/logs/backups.
|
||||||
|
5. Confirm schema contract alignment is current:
|
||||||
|
- `src/transcription/db/models.py`
|
||||||
|
- `docs/schema.md`
|
||||||
|
|
||||||
|
## 2. Release execution steps
|
||||||
|
|
||||||
|
1. Deploy artifact/config to target environment.
|
||||||
|
2. Validate service startup:
|
||||||
|
- `/healthz` responds `200`
|
||||||
|
- `worker.state` is `running`
|
||||||
|
3. Execute one smoke workflow:
|
||||||
|
- create a document/job with at least one source
|
||||||
|
- verify terminal job outcome updates
|
||||||
|
- verify execution evidence row appended
|
||||||
|
4. Verify log flow:
|
||||||
|
- stdout aggregation receives events
|
||||||
|
- file logs are written under `./data/logs`
|
||||||
|
|
||||||
|
## 3. Rollback triggers and actions
|
||||||
|
|
||||||
|
### Trigger conditions
|
||||||
|
|
||||||
|
1. `/healthz` reports `worker.state=failed`
|
||||||
|
2. Repeated provider timeout/error spikes beyond normal baseline
|
||||||
|
3. Evidence write failures or DB persistence failures
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
1. Roll back app artifact and config to previous release.
|
||||||
|
2. Restart service and re-check `/healthz`.
|
||||||
|
3. Re-run smoke workflow and confirm worker returns to `running`.
|
||||||
|
4. Preserve incident evidence:
|
||||||
|
- `./data/logs`
|
||||||
|
- relevant DB rows (`job`, `job_source`, `execution_attempt`)
|
||||||
|
|
||||||
|
## 4. Post-release monitoring checklist
|
||||||
|
|
||||||
|
## First 24 hours
|
||||||
|
|
||||||
|
1. Monitor `/healthz` periodically for `worker.state`.
|
||||||
|
2. Track job terminal distribution (`transcribed`, `partial_success`, `failed`).
|
||||||
|
3. Sample timeout/error categories for abnormal increase.
|
||||||
|
4. Spot-check new `execution_attempt` records for append-only growth and timing metadata.
|
||||||
|
|
||||||
|
## First 72 hours
|
||||||
|
|
||||||
|
1. Re-check error/timeout trend versus 24h baseline.
|
||||||
|
2. Verify no recurring worker-failed states.
|
||||||
|
3. Verify storage growth and rotation behavior under `./data/logs`.
|
||||||
|
4. Confirm incident response notes are captured for any production anomalies.
|
||||||
|
|
||||||
|
## 5. Operator playbook for common incidents
|
||||||
|
|
||||||
|
### Worker failed
|
||||||
|
|
||||||
|
1. Check `/healthz` payload (`error_id`, `error_category`).
|
||||||
|
2. Locate matching error in logs.
|
||||||
|
3. If non-transient defect persists, roll back.
|
||||||
|
|
||||||
|
### Provider timeout spike
|
||||||
|
|
||||||
|
1. Confirm provider reachability and rate limits.
|
||||||
|
2. Review timeout frequency and impacted job volume.
|
||||||
|
3. If sustained, execute rollback criteria and notify stakeholders.
|
||||||
|
|
||||||
|
### Partial-success increase
|
||||||
|
|
||||||
|
1. Inspect affected `job_source` and `execution_attempt` records.
|
||||||
|
2. Confirm failures are category-aligned (`external`/`timeout`/`internal`).
|
||||||
|
3. Triage whether issue is source quality, provider, or runtime regression.
|
||||||
+65
-64
@@ -1,84 +1,85 @@
|
|||||||
## Document Transcription System Requirements
|
# System Requirements (Version 4)
|
||||||
|
|
||||||
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.
|
These requirements define the active Version 4 contract and align to current implementation.
|
||||||
|
|
||||||
## Scope
|
## Functional Requirements
|
||||||
|
|
||||||
- System of interest: the single Python application service (NiceGUI + FastAPI) with PostgreSQL as the relational system of record and optional MongoDB for document-oriented persistence.
|
### Domain and Record Management
|
||||||
- Operational context: local-first execution with Docker Compose and an intentionally lightweight production trajectory.
|
|
||||||
- Primary concern: end-to-end transcription job lifecycle from upload through completion or failure.
|
|
||||||
|
|
||||||
## Requirements Model (Concise Text Form)
|
- **REQ-4-001 Document Registry:** The system must create and update `Document` records with title, type, language, comments, date metadata, and optional location.
|
||||||
|
- **REQ-4-002 Source Registry:** The system must create and update `Source` records linked to exactly one `Document`.
|
||||||
|
- **REQ-4-003 People Registry:** The system must create and update `Person` records and support many-to-many links to `Document` with role and confidence.
|
||||||
|
- **REQ-4-004 Registry Semantics:** Document types and person roles must support optional immutable semantic keys and hard-delete only when unreferenced.
|
||||||
|
|
||||||
### Requirements
|
### Job and Workflow Behavior
|
||||||
|
|
||||||
| ID | Category | Requirement | Risk | Verify Method |
|
- **REQ-4-010 Job Creation:** The system must create `Job` records from uploaded sources and from retranscription of existing sources.
|
||||||
| --- | --- | --- | --- | --- |
|
- **REQ-4-011 Prompt Snapshotting:** Job creation must persist effective prompt and runtime settings as immutable per-job snapshots.
|
||||||
| REQ-0 | System | Provide end-to-end document transcription with persistent, inspectable lifecycle state. | medium | demonstration |
|
- **REQ-4-012 Queue Membership:** Each `(job, source)` pair must be represented by one `JobSource` row.
|
||||||
| REQ-1 | Functional | Allow users to upload one or more document images from the web UI. | low | test |
|
- **REQ-4-013 Job Status Lifecycle:** `Job.status` must use one of `queued`, `processing`, `transcribed`, `partial_success`, `failed`.
|
||||||
| REQ-2 | Functional | Run each upload through asynchronous processing that returns a transcription or explicit failure. | high | test |
|
- **REQ-4-014 JobSource Status Lifecycle:** `JobSource.status` must use one of `pending`, `transcribed`, `failed`, `cancelled`.
|
||||||
| REQ-3 | Functional | Persist and expose job states: upload, queued, processing, transcribed, failed, completed. | high | inspection |
|
- **REQ-4-015 Terminal Job Resolution:** Job terminal status must derive from page outcomes as `transcribed`, `partial_success`, or `failed`.
|
||||||
| REQ-4 | Functional | Persist transcription output, processing history, and failure details. | medium | test |
|
- **REQ-4-016 Cancellation Semantics:** Job cancellation must set remaining `pending` page entries to `cancelled`.
|
||||||
| REQ-5 | Interface | Expose API and UI views for status inspection and completed transcription reading. | medium | demonstration |
|
|
||||||
| REQ-6 | Performance | Trigger background processing on upload to preserve UI responsiveness. | medium | analysis |
|
|
||||||
| REQ-7 | Design Constraint | Keep lifespan-owned runtime resources: SQLAlchemy engine, async session factory, worker resources, provider clients. | medium | inspection |
|
|
||||||
| REQ-8 | Design Constraint | Initialize configuration and logging once at startup through centralized mechanisms. | low | inspection |
|
|
||||||
| REQ-9 | Design Constraint | Use Docker Compose baseline of app plus PostgreSQL; allow optional MongoDB container when enabled. | medium | demonstration |
|
|
||||||
| REQ-10 | Design Constraint | Keep schema bootstrap explicit and opt-in; normal startup does not mutate production schema. | high | inspection |
|
|
||||||
| REQ-11 | Design Constraint | Use service-backed persistence for core document and job data. | medium | inspection |
|
|
||||||
| REQ-12 | Design Constraint | Store transcription prompts as individual Markdown artifacts for iterative refinement. | medium | inspection |
|
|
||||||
|
|
||||||
### Requirement Relationships
|
### Transcription and Evidence
|
||||||
|
|
||||||
- Contains: REQ-0 contains REQ-1 through REQ-12.
|
- **REQ-4-020 Attempt Evidence:** Each provider call must emit one append-only `ExecutionAttempt` record.
|
||||||
- Derives: REQ-2 -> REQ-3, REQ-3 -> REQ-4.
|
- **REQ-4-021 Attempt Payload:** `ExecutionAttempt` must retain request manifest/hash, outcome, timing, model/provider fields, and error details when present.
|
||||||
- Traces: REQ-5 -> REQ-3.
|
- **REQ-4-022 Transport Evidence:** Provider response evidence must be attached to the attempt when a response is available.
|
||||||
- Refines: REQ-6 -> REQ-2.
|
- **REQ-4-023 Source Projection Rule:** `Source.raw_transcription` is a projection chosen from attempt outcomes and can be repointed by explicit promotion.
|
||||||
|
- **REQ-4-024 Candidate Visibility:** UI must expose candidate attempts with metadata needed for comparative review and selection.
|
||||||
|
|
||||||
### Architecture Elements
|
### Media and Access
|
||||||
|
|
||||||
| Element | Type | Doc Reference |
|
- **REQ-4-030 Ingest Canonicalization:** Stored source bytes may be normalized at ingest (for example orientation correction); stored bytes are the canonical processing source.
|
||||||
| --- | --- | --- |
|
- **REQ-4-031 Path Safety:** Client-facing media URLs must be generated from controlled application paths only.
|
||||||
| UI | NiceGUI pages | src/transcription/ui/pages |
|
- **REQ-4-032 Print Media Validation:** Print/export source media must be served through record-validated API routes.
|
||||||
| API | FastAPI routes | src/transcription/api/routes.py |
|
|
||||||
| GRAPH | Async processing workflow | src/transcription/services, src/transcription/ai |
|
|
||||||
| DBREL | PostgreSQL + SQLModel relational persistence | src/transcription/db |
|
|
||||||
| DBDOC | MongoDB document persistence | src/transcription/db, src/transcription/services |
|
|
||||||
| OPS | Docker Compose runtime | docker-compose.yml |
|
|
||||||
| PROMPTS | Transcription prompt artifact library (Markdown files) | .github/prompts, docs |
|
|
||||||
| TESTS | Pytest verification suite | tests |
|
|
||||||
|
|
||||||
### Satisfaction Mapping
|
### Error and UX Contracts
|
||||||
|
|
||||||
- UI satisfies REQ-1, REQ-5.
|
- **REQ-4-040 Error Envelope:** Service/API errors must map to structured, user-safe error categories and messages.
|
||||||
- API satisfies REQ-5.
|
- **REQ-4-041 Partial Failure Visibility:** Mixed page outcomes must be visible at job and page level.
|
||||||
- GRAPH satisfies REQ-2, REQ-6.
|
- **REQ-4-042 Retry Support:** Failed and cancelled pages must support targeted retranscription without requiring full document recreation.
|
||||||
- DBREL satisfies REQ-3, REQ-10.
|
|
||||||
- DBDOC satisfies REQ-4, REQ-11.
|
|
||||||
- OPS satisfies REQ-9.
|
|
||||||
- PROMPTS satisfies REQ-12.
|
|
||||||
|
|
||||||
### Verification Mapping
|
## Non-Functional Requirements
|
||||||
|
|
||||||
- TESTS verifies REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-10, REQ-11, REQ-12.
|
- **REQ-4-100 Boundary Integrity:** UI pages/components must not access persistence directly and must call service APIs.
|
||||||
|
- **REQ-4-101 Service Ownership:** Aggregate writes must occur in owning service/workflow modules, not in UI handlers.
|
||||||
|
- **REQ-4-102 Deterministic Loading:** ORM relationship reads in service/UI code must use explicit eager loading compatible with `lazy="raise"`.
|
||||||
|
- **REQ-4-103 Async Safety:** Long-running provider calls must not block UI event handlers directly.
|
||||||
|
- **REQ-4-104 Evidence Durability:** Attempt evidence must survive process restart once the transaction commits.
|
||||||
|
- **REQ-4-105 Test Guardrails:** Architecture boundary tests must remain in place for services and UI boundaries.
|
||||||
|
|
||||||
## Requirement Notes
|
## Requirement Interpretation Notes
|
||||||
|
|
||||||
- Requirement IDs (`REQ-*`) are stable references for planning, implementation, and test traceability.
|
### Status and lifecycle semantics
|
||||||
- The model uses compact tables and traceability lists for renderer compatibility while preserving SysML-style requirement IDs and relationship semantics.
|
|
||||||
- Requirement categories (functional, interface, performance, and design constraints) are preserved as explicit REQ entries and relationship labels to keep change impact visible.
|
|
||||||
- PostgreSQL containerization and optional MongoDB containerization are both treated as extremely lightweight and simple operational choices in this architecture.
|
|
||||||
|
|
||||||
## Verification Intent
|
- `REQ-4-013` and `REQ-4-015` intentionally bind success to `transcribed`, not a generic `completed`, so docs, tests, and runtime transitions stay consistent.
|
||||||
|
- `REQ-4-016` and `REQ-4-042` distinguish cancellation from failure at page level (`cancelled` vs `failed`) while still allowing targeted retranscription.
|
||||||
|
|
||||||
- Demonstration: validate end-to-end behavior via running system flows and operator-visible outcomes.
|
### Evidence semantics
|
||||||
- Inspection: verify architecture and startup/runtime policies in code and configuration.
|
|
||||||
- Analysis: evaluate asynchronous execution behavior and design sufficiency.
|
|
||||||
- Test: automate behavioral checks through pytest suites and service-level tests.
|
|
||||||
|
|
||||||
## Glossary
|
- `REQ-4-020` through `REQ-4-024` separate authoritative history (`ExecutionAttempt`) from operational projection (`Source.raw_transcription`).
|
||||||
|
- This supports immutable provenance while allowing explicit candidate promotion for operator workflows.
|
||||||
|
|
||||||
- Document-oriented persistence: A storage approach that uses flexible document structures for variable data shapes.
|
### Boundary and loading semantics
|
||||||
- Prompt artifact: A single Markdown file that defines one transcription prompt and is revised independently.
|
|
||||||
- SysML: Systems Modeling Language used to express structured requirements and traceability.
|
- `REQ-4-100` and `REQ-4-101` codify aggregate/service ownership and keep UI out of persistence concerns.
|
||||||
- System of record: The authoritative persistent store for canonical business data.
|
- `REQ-4-102` exists to enforce deterministic query shape under `lazy="raise"` and avoid hidden data access in rendering callbacks.
|
||||||
|
|
||||||
|
## Verification Anchors
|
||||||
|
|
||||||
|
- Service boundary enforcement: `tests/test_service_boundaries.py`
|
||||||
|
- UI boundary enforcement: `tests/test_ui_boundaries.py`
|
||||||
|
- Job lifecycle reliability and terminal status behavior: `tests/services/test_workflows_reliability.py`
|
||||||
|
- Evidence append-only and projection behavior: `tests/services/test_store.py`, `tests/services/test_transcription_service.py`
|
||||||
|
|
||||||
|
## Traceability Notes
|
||||||
|
|
||||||
|
- Source of truth for status enums:
|
||||||
|
- `src/transcription/db/models.py`
|
||||||
|
- Source of truth for workflow transitions:
|
||||||
|
- `src/transcription/services/workflows.py`
|
||||||
|
- `src/transcription/services/jobs.py`
|
||||||
|
- Source of truth for attempt evidence writes:
|
||||||
|
- `src/transcription/services/sources.py`
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# Roadmap Review: Recommendations & Version Plan
|
||||||
|
|
||||||
|
This is a review/consulting deliverable. It organizes your near-term changes,
|
||||||
|
nice-to-haves, and long-range goals into version buckets, building on
|
||||||
|
`./docs/ver4.8/feature_backlog_v4_8.md` (already-scoped image work) and
|
||||||
|
folding in the additional items from your original list.
|
||||||
|
|
||||||
|
Versioning convention (per your direction): **4.8–4.11** are incremental
|
||||||
|
feature/fix releases; **5.x and 6.x are reserved for major changes** to the
|
||||||
|
app (a shared data-model overhaul, and the hosting migration, respectively).
|
||||||
|
|
||||||
|
Final deliverable will be saved to `./docs` in the repo per your instruction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V4.8 — Bug fixes + Image Experience
|
||||||
|
|
||||||
|
**Bug fixes (do first within this release — data correctness, not features):**
|
||||||
|
1. **Stale Error Detail after resubmit.** Root cause found in `db/models.py`:
|
||||||
|
`Source.latest_error_detail` sorts all `execution_attempts` on the latest
|
||||||
|
job by `attempt_number` descending and returns the **first attempt with
|
||||||
|
any `error_detail`**, even if that's an older attempt and the latest
|
||||||
|
attempt succeeded. Fix: only report error detail from the latest attempt,
|
||||||
|
don't fall through to earlier ones.
|
||||||
|
2. **"View Jobs" ≠ "View Sources" behavior.** `/documents/{id}/sources`
|
||||||
|
already redirects to the filtered Transcription Pipeline Jobs page;
|
||||||
|
`/documents/{id}/jobs` renders its own bare two-field custom page instead.
|
||||||
|
Fix: make `/documents/{id}/jobs` redirect the same way, filtered by document.
|
||||||
|
|
||||||
|
**Image Experience (Track A from the V4.8 backlog doc — already scoped, high
|
||||||
|
value/low risk, storage layer already exists):**
|
||||||
|
3. **Homepage Image Gallery** (recommended first feature in that doc):
|
||||||
|
`list_homepage_images()` already returns every stored image; only the
|
||||||
|
multi-image carousel UI and optional slideshow rotation are missing.
|
||||||
|
4. **Pan and Zoom on Source Detail**: recover `document_panzoom.py` from git
|
||||||
|
history (deleted in V4.6 Phase 5, `6a3ee26`), but vendor the Panzoom
|
||||||
|
library locally instead of the unpkg CDN, and reuse `resolve_media_url`
|
||||||
|
from `media_urls.py` instead of recreating its old helper. Scope to Source
|
||||||
|
Detail only — do not add it to the shared `dark_room_viewer`.
|
||||||
|
|
||||||
|
**Note:** the backlog doc's model-performance/telemetry item still applies as
|
||||||
|
stated there — it must wait for V4.7 Phase 4 to land first, since `duration_ms`
|
||||||
|
today mixes provider latency with preprocessing/DB commit time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V4.9 — Detail Page Parity Pass
|
||||||
|
|
||||||
|
Small, low-risk structural-parity fixes across Person/Job/Document detail pages:
|
||||||
|
- Hide "Maiden Name" on Person detail when empty.
|
||||||
|
- Job Record detail "Document Links" box → mirror the Document detail page's
|
||||||
|
"Sources & Pipeline Jobs" box (clickable name, Sources count, single
|
||||||
|
"View Sources" button, no "+Add Job").
|
||||||
|
- Transcription Pipeline Jobs page: add a Document Name column; resolve
|
||||||
|
Created vs. Updated by keeping **Updated** as the primary/visible field
|
||||||
|
(more actionable) and de-emphasizing Created rather than deleting it outright.
|
||||||
|
- Person detail "Linked Documents" → 3-column table (Document Name, Role,
|
||||||
|
Number of Pages), with clickable document names, dropping the "Open" button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V4.10 — Settings Consolidation & Small Enhancements
|
||||||
|
|
||||||
|
- **Settings page → tabs**, not sub-pages (per your confirmation). Convert the
|
||||||
|
existing stacked cards (Document Types, Person Roles, Prompts) into
|
||||||
|
`ui.tabs`/`ui.tab_panels`, and add a new **Home Page Text** tab. One route,
|
||||||
|
no navigation overhead, scales cleanly as more settings are added.
|
||||||
|
- **Google Maps links** for Person birth/death locations — cheap, no
|
||||||
|
scraping, a formatted place string becomes a Maps search link.
|
||||||
|
- **FamilySearch ID lookup** (scope confirmed as narrow): on the Person
|
||||||
|
create/edit form, add a FamilySearch ID field and an "Auto-fill from
|
||||||
|
FamilySearch" button that fetches only birth date/place, death date/place,
|
||||||
|
and marriage date/spouse for that specific ID — a single-record lookup, not
|
||||||
|
a crawler. This keeps FamilySearch.org as a companion reference rather than
|
||||||
|
a data source the app tries to replace. Because it's ID-driven and
|
||||||
|
single-record, it's a much smaller, safer feature than open-ended scraping —
|
||||||
|
worth doing at this scope, revisit if FamilySearch's page structure changes
|
||||||
|
and breaks the parser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V4.11 — Approved Scope
|
||||||
|
|
||||||
|
- **Tags** (supersedes "collections"): many-to-many tagging for Documents with
|
||||||
|
Settings-style management (same pattern as Document Types and Person Roles),
|
||||||
|
autocomplete-capable assignment, and a dedicated **Tags** entry point for
|
||||||
|
browse/filter-by-tag workflows.
|
||||||
|
- **Source Detail simplification**: remove the separate **Transcription Text**
|
||||||
|
card; show Source image + Editable Revision + Source/SourceJob metadata in a
|
||||||
|
3-column top layout, then keep Candidate Machine Transcriptions below the
|
||||||
|
image/revision area.
|
||||||
|
- **Integrity reconciliation checks in tests**:
|
||||||
|
- document folder count under `UPLOAD_DIR/documents` must equal `document`
|
||||||
|
row count.
|
||||||
|
- source file count under each `UPLOAD_DIR/documents/{document_id}` folder
|
||||||
|
must equal `source` row count for that Document.
|
||||||
|
- failures should include actionable mismatch details (missing row/folder or
|
||||||
|
file/source mapping).
|
||||||
|
- **UI table updates**:
|
||||||
|
- Archival Documents: remove **Archive Ref**, add **# Sources**.
|
||||||
|
- Archival Entities: People: remove **Display Name** and **Maiden Name**
|
||||||
|
columns, add **FamilySearch ID**.
|
||||||
|
- Transcription Pipeline Jobs: add **# Sources**.
|
||||||
|
- **Create Processing Job page**: Provider and Model must be selectable for new
|
||||||
|
job creation.
|
||||||
|
|
||||||
|
Deferred out of this release:
|
||||||
|
|
||||||
|
- UI theme selection.
|
||||||
|
- Settings-based `.env` editing and runtime controls.
|
||||||
|
- Person table structural redesign (removing/splitting name fields).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V5.0 — Unified Photos Table (major data-model change)
|
||||||
|
|
||||||
|
V5.0 standardizes homepage images and Person portraits into one shared `photo`
|
||||||
|
table and one storage layout.
|
||||||
|
|
||||||
|
Finalized shape:
|
||||||
|
- `photo`: `id`, nullable `person_id`, `path`, `description`, `is_primary`,
|
||||||
|
timestamps.
|
||||||
|
- `person_id IS NULL` = homepage photos; non-null = Person photos.
|
||||||
|
- `is_primary` is the featured/first photo for that owner (homepage or Person).
|
||||||
|
- No separate context enum; ownership is derived from `person_id`.
|
||||||
|
- All image files are stored under `UPLOAD_DIR/photos/{photo_id}{suffix}`.
|
||||||
|
- `Person.portrait_path` is removed.
|
||||||
|
- `HOMEPAGE_DIR` is retired; homepage markdown remains file-backed at
|
||||||
|
`UPLOAD_DIR/homepage.md`.
|
||||||
|
|
||||||
|
Migration policy for legacy installs:
|
||||||
|
- Export/import rebuild remains the migration mechanism.
|
||||||
|
- Legacy `person.portrait_path` values are backfilled into `photo` rows.
|
||||||
|
- Legacy homepage images under `UPLOAD_DIR/homepage` are backfilled into
|
||||||
|
homepage `photo` rows.
|
||||||
|
- Legacy homepage markdown is relocated to `UPLOAD_DIR/homepage.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V6.0 — Server Hosting Migration
|
||||||
|
|
||||||
|
Your stated approach (Postgres in Docker, app in Docker, Cloudflare Tunnel) is
|
||||||
|
the standard, low-maintenance way to get secure remote access without exposing
|
||||||
|
ports or running your own VPN/reverse-proxy TLS setup. Sequential stages
|
||||||
|
(each de-risks the next):
|
||||||
|
|
||||||
|
**Stage 0 — Prerequisites**
|
||||||
|
- Confirm data access already goes through the service layer (it does, per
|
||||||
|
`.github/instructions/services.instructions.md`) — this is what makes the
|
||||||
|
DB swap and later auth additive rather than a rewrite.
|
||||||
|
- Confirm upload/file storage path (`settings.upload_dir`) is Docker-volume-friendly.
|
||||||
|
- Confirm DB URL and upload dir are both env-overridable (`.env`/`.env.example`
|
||||||
|
already exist).
|
||||||
|
|
||||||
|
**Stage 1 — Containerize** against the *existing* SQLite file first (smallest
|
||||||
|
possible change) to validate the container boundary (networking, volumes,
|
||||||
|
permissions) before also switching databases. `Dockerfile`/`docker-compose.yml`
|
||||||
|
already exist in the repo — confirm current intent vs. extend for production.
|
||||||
|
|
||||||
|
**Stage 2 — Migrate to PostgreSQL.** Since the app uses SQLModel/SQLAlchemy,
|
||||||
|
this should mostly be a connection string/dialect change plus a data migration
|
||||||
|
script. Do this as its own isolated step so a regression is attributable to
|
||||||
|
the DB swap alone. Resolve the Postgres connection through a small
|
||||||
|
factory/indirection point rather than a single global engine constant — this
|
||||||
|
costs nothing now and keeps the door open for per-user databases later (see
|
||||||
|
Beyond, below).
|
||||||
|
|
||||||
|
**Stage 3 — Expose via Cloudflare Tunnel.** Add a `cloudflared` container
|
||||||
|
pointed at the app's internal port (no public port exposure needed). Put the
|
||||||
|
tunnel hostname behind Cloudflare Access (free tier, email OTP/SSO) as the
|
||||||
|
**first layer of remote-access auth**, even before the app has its own login
|
||||||
|
system — gets secure remote access working quickly and buys time to do
|
||||||
|
Stage 4 and later user-auth properly instead of rushed.
|
||||||
|
|
||||||
|
**Stage 4 — Backups & operational hygiene.** Automated Postgres backups
|
||||||
|
(scheduled `pg_dump`) and uploaded-file backups *before* exposing this to the
|
||||||
|
internet — this is the point where a data-loss incident would be most
|
||||||
|
damaging. Basic container health checks/restart policies in compose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V6.1 — Reporting Features
|
||||||
|
|
||||||
|
Independent of hosting — can be built any time, including in parallel with
|
||||||
|
V6.0:
|
||||||
|
- **Person timelines**: a query/aggregation feature (documents by person,
|
||||||
|
ordered by document date), no hosting dependency.
|
||||||
|
- **AI-written biographies/family histories**: likely reuses the existing
|
||||||
|
prompt/provider abstraction (`services/prompts.py`) already built for
|
||||||
|
transcription, rather than needing new infrastructure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beyond / Pie-in-the-sky (informational only — no version assigned)
|
||||||
|
|
||||||
|
Not being built now, but the plan above is checked against these so nothing
|
||||||
|
forecloses them later:
|
||||||
|
|
||||||
|
- **Individual user logins + roles** (admin/editor/contributor/view-only):
|
||||||
|
Cloudflare Access (V6.0 Stage 3) is a stopgap, not this. When you get here,
|
||||||
|
add a real `User`/`Role` model and auth middleware. Because the codebase
|
||||||
|
already funnels data access through the service layer, adding a
|
||||||
|
"current user + role check" later is additive — this is the reason to keep
|
||||||
|
service-layer discipline intact through all the versions above, so
|
||||||
|
authorization can be bolted on without touching every page.
|
||||||
|
- **Per-user databases**: protected for by the Stage 2 connection-factory
|
||||||
|
recommendation above (schema-per-tenant or database-per-tenant becomes
|
||||||
|
much easier if the DB connection is already resolved through an
|
||||||
|
indirection point rather than hardcoded at startup).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open items for you
|
||||||
|
- V5.0 unified `photos` table: needs the follow-up design discussion you
|
||||||
|
flagged (exact schema, how photos link to homepage vs. person context)
|
||||||
|
before implementation.
|
||||||
|
- Confirm this version numbering/grouping matches your intent before work starts.
|
||||||
+280
@@ -0,0 +1,280 @@
|
|||||||
|
# Data Model and Persistence Schema (Version 4)
|
||||||
|
|
||||||
|
This document is the field-accurate Version 4 schema contract aligned to `src/transcription/db/models.py`.
|
||||||
|
|
||||||
|
## Source of Truth Anchors
|
||||||
|
|
||||||
|
- `src/transcription/db/models.py:60-78` (status and purpose enums)
|
||||||
|
- `src/transcription/db/models.py:80-120` (`DocumentType`, `PersonRole`)
|
||||||
|
- `src/transcription/db/models.py:122-172` (`Tag`, `Document`)
|
||||||
|
- `src/transcription/db/models.py:175-281` (`Person`, `Photo`, `DocumentPerson`, `DocumentTag`)
|
||||||
|
- `src/transcription/db/models.py:285-347` (`Job`)
|
||||||
|
- `src/transcription/db/models.py:350-462` (`Source`, `JobSource`)
|
||||||
|
- `src/transcription/db/models.py:465-522` (`ExecutionAttempt`)
|
||||||
|
|
||||||
|
## Entity Relationship Overview
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
DocumentType ||--o{ Document : classifies
|
||||||
|
Document ||--o{ Job : has
|
||||||
|
Document ||--o{ Source : has
|
||||||
|
Document ||--o{ DocumentPerson : links
|
||||||
|
Document ||--o{ DocumentTag : tagged
|
||||||
|
Person ||--o{ DocumentPerson : links
|
||||||
|
Person ||--o{ Photo : owns
|
||||||
|
PersonRole ||--o{ DocumentPerson : labels
|
||||||
|
Tag ||--o{ DocumentTag : labels
|
||||||
|
Job ||--o{ JobSource : includes
|
||||||
|
Source ||--o{ JobSource : participates
|
||||||
|
JobSource ||--o{ ExecutionAttempt : attempts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authoritative Enumerations
|
||||||
|
|
||||||
|
### JobStatus
|
||||||
|
|
||||||
|
- `queued`
|
||||||
|
- `processing`
|
||||||
|
- `transcribed`
|
||||||
|
- `partial_success`
|
||||||
|
- `failed`
|
||||||
|
|
||||||
|
### JobSourceStatus
|
||||||
|
|
||||||
|
- `pending`
|
||||||
|
- `transcribed`
|
||||||
|
- `failed`
|
||||||
|
- `cancelled`
|
||||||
|
|
||||||
|
### JobPurpose
|
||||||
|
|
||||||
|
- `transcription`
|
||||||
|
- `retranscription`
|
||||||
|
|
||||||
|
## Field-Accurate Table Contracts
|
||||||
|
|
||||||
|
### `DocumentType`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `semantic_key` | `str \| None` | nullable unique, indexed |
|
||||||
|
| `label` | `str` | required |
|
||||||
|
| `normalized_label` | `str` | unique, indexed |
|
||||||
|
| `is_active` | `bool` | default `True` |
|
||||||
|
| `created_at` | `datetime` | default now |
|
||||||
|
| `updated_at` | `datetime` | default now, onupdate |
|
||||||
|
|
||||||
|
### `PersonRole`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `semantic_key` | `str \| None` | nullable unique, indexed |
|
||||||
|
| `label` | `str` | required |
|
||||||
|
| `normalized_label` | `str` | unique, indexed |
|
||||||
|
| `is_active` | `bool` | default `True` |
|
||||||
|
| `created_at` | `datetime` | default now |
|
||||||
|
| `updated_at` | `datetime` | default now, onupdate |
|
||||||
|
|
||||||
|
### `Tag`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `semantic_key` | `str \| None` | nullable unique, indexed |
|
||||||
|
| `label` | `str` | required |
|
||||||
|
| `normalized_label` | `str` | unique, indexed |
|
||||||
|
| `is_active` | `bool` | default `True` |
|
||||||
|
| `created_at` | `datetime` | default now |
|
||||||
|
| `updated_at` | `datetime` | default now, onupdate |
|
||||||
|
|
||||||
|
### `Document`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `name` | `str` | required |
|
||||||
|
| `document_type_id` | `UUID \| None` | FK -> `document_type.id`, indexed |
|
||||||
|
| `document_date` | `date \| None` | optional |
|
||||||
|
| `document_date_raw` | `str \| None` | optional |
|
||||||
|
| `location_created` | `str \| None` | optional |
|
||||||
|
| `notes` | `str \| None` | optional |
|
||||||
|
| `archive_identifier` | `str \| None` | optional |
|
||||||
|
| `created_at` | `datetime` | default now |
|
||||||
|
| `updated_at` | `datetime` | default now, onupdate |
|
||||||
|
|
||||||
|
### `Person`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `full_name` | `str` | required |
|
||||||
|
| `display_name` | `str \| None` | optional |
|
||||||
|
| `maiden_name` | `str \| None` | optional |
|
||||||
|
| `birth_date` | `date \| None` | optional |
|
||||||
|
| `birth_date_raw` | `str \| None` | optional |
|
||||||
|
| `birth_place` | `str \| None` | optional |
|
||||||
|
| `death_date` | `date \| None` | optional |
|
||||||
|
| `death_date_raw` | `str \| None` | optional |
|
||||||
|
| `death_place` | `str \| None` | optional |
|
||||||
|
| `biography` | `str \| None` | optional |
|
||||||
|
| `family_search_id` | `str \| None` | nullable unique |
|
||||||
|
| `metadata_` | `dict[str, JsonValue] \| None` | stored as DB column `metadata` (`JSONBCompat`) |
|
||||||
|
| `created_at` | `datetime` | default now |
|
||||||
|
| `updated_at` | `datetime` | default now, onupdate |
|
||||||
|
|
||||||
|
### `Photo`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `person_id` | `UUID \| None` | nullable FK -> `person.id`, indexed (`NULL` = homepage photo) |
|
||||||
|
| `path` | `str` | required upload-root-relative POSIX path (`photos/...`) |
|
||||||
|
| `description` | `str \| None` | optional |
|
||||||
|
| `is_primary` | `bool` | default `False`; owner-level "featured/primary" marker |
|
||||||
|
| `created_at` | `datetime` | default now |
|
||||||
|
| `updated_at` | `datetime` | default now, onupdate |
|
||||||
|
|
||||||
|
### `DocumentPerson`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `document_id` | `UUID` | FK -> `document.id`, indexed |
|
||||||
|
| `person_id` | `UUID` | FK -> `person.id`, indexed |
|
||||||
|
| `role_id` | `UUID` | FK -> `person_role.id`, indexed |
|
||||||
|
| `created_at` | `datetime` | default now |
|
||||||
|
| `updated_at` | `datetime` | default now, onupdate |
|
||||||
|
|
||||||
|
Constraint:
|
||||||
|
- `UniqueConstraint(document_id, person_id)` named `uq_document_person`
|
||||||
|
|
||||||
|
### `DocumentTag`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `document_id` | `UUID` | FK -> `document.id`, indexed |
|
||||||
|
| `tag_id` | `UUID` | FK -> `tag.id`, indexed |
|
||||||
|
| `created_at` | `datetime` | default now |
|
||||||
|
| `updated_at` | `datetime` | default now, onupdate |
|
||||||
|
|
||||||
|
Constraint:
|
||||||
|
- `UniqueConstraint(document_id, tag_id)` named `uq_document_tag`
|
||||||
|
|
||||||
|
### `Job`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `document_id` | `UUID` | FK -> `document.id`, indexed |
|
||||||
|
| `status` | `JobStatus` | non-null enum (stored as enum values) |
|
||||||
|
| `retry_count` | `int` | default `0`, `ge=0` |
|
||||||
|
| `purpose` | `JobPurpose` | non-null enum, default `transcription` |
|
||||||
|
| `date_created` | `datetime` | default now |
|
||||||
|
| `date_updated` | `datetime` | default now, onupdate |
|
||||||
|
| `provider` | `str \| None` | optional |
|
||||||
|
| `model` | `str \| None` | optional |
|
||||||
|
| `prompt_name` | `str \| None` | optional |
|
||||||
|
| `prompt_hash` | `str \| None` | optional |
|
||||||
|
| `system_prompt` | `str \| None` | optional |
|
||||||
|
| `user_prompt` | `str \| None` | optional |
|
||||||
|
| `temperature` | `float \| None` | optional |
|
||||||
|
| `top_p` | `float \| None` | optional |
|
||||||
|
|
||||||
|
Index:
|
||||||
|
- `Index("ix_job_status_date_created", "status", "date_created")`
|
||||||
|
|
||||||
|
### `Source`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `document_id` | `UUID` | FK -> `document.id`, indexed |
|
||||||
|
| `page_number` | `int` | default `1`, `ge=1` |
|
||||||
|
| `upload_name` | `str` | required |
|
||||||
|
| `filename` | `str` | required |
|
||||||
|
| `file_path` | `str` | required upload-root-relative POSIX path (`documents/...`) |
|
||||||
|
| `file_hash` | `str` | required |
|
||||||
|
| `file_size_bytes` | `int` | `BigInteger`, non-null |
|
||||||
|
| `raw_transcription` | `str \| None` | projection field |
|
||||||
|
| `preferred_execution_attempt_id` | `UUID \| None` | nullable FK -> `execution_attempt.id`, indexed (`use_alter`) |
|
||||||
|
| `revised_text` | `str \| None` | optional human revision |
|
||||||
|
| `date_uploaded` | `datetime` | default now |
|
||||||
|
| `date_revised` | `datetime \| None` | optional |
|
||||||
|
|
||||||
|
### `JobSource`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `job_id` | `UUID` | FK -> `job.id`, indexed |
|
||||||
|
| `source_id` | `UUID` | FK -> `source.id`, indexed |
|
||||||
|
| `status` | `JobSourceStatus` | non-null enum, default `pending` |
|
||||||
|
|
||||||
|
Constraint:
|
||||||
|
- `UniqueConstraint(job_id, source_id)` named `uq_job_source_job_source`
|
||||||
|
|
||||||
|
Runtime reconciliation:
|
||||||
|
- Startup database operations remove retired V4.6 `job_source` evidence columns (`raw_transcription`, `ai_metadata`, `raw_api_response`, `error_detail`, `executed_at`) when present so persisted schema matches this contract.
|
||||||
|
|
||||||
|
### `ExecutionAttempt`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `job_source_id` | `UUID` | FK -> `job_source.id`, indexed |
|
||||||
|
| `job_id` | `UUID` | FK -> `job.id`, indexed |
|
||||||
|
| `source_id` | `UUID` | FK -> `source.id`, indexed |
|
||||||
|
| `attempt_number` | `int` | `ge=1` |
|
||||||
|
| `status` | `JobSourceStatus` | non-null enum, value-stable with `JobSource.status` |
|
||||||
|
| `provider` | `str` | required |
|
||||||
|
| `model` | `str \| None` | optional |
|
||||||
|
| `request_manifest` | `dict[str, JsonValue] \| None` | JSONBCompat |
|
||||||
|
| `request_manifest_sha256` | `str \| None` | optional |
|
||||||
|
| `request_manifest_schema_version` | `str \| None` | optional |
|
||||||
|
| `response_received` | `bool` | default `False` |
|
||||||
|
| `transport_status_code` | `int \| None` | optional |
|
||||||
|
| `transport_body` | `bytes \| None` | LargeBinary |
|
||||||
|
| `transport_content_type` | `str \| None` | optional |
|
||||||
|
| `transport_content_encoding` | `str \| None` | optional |
|
||||||
|
| `transport_safe_headers` | `dict[str, JsonValue] \| None` | JSONBCompat |
|
||||||
|
| `router_request_id` | `str \| None` | optional |
|
||||||
|
| `router_generation_id` | `str \| None` | optional |
|
||||||
|
| `sdk_response_snapshot` | `dict[str, JsonValue] \| None` | JSONBCompat |
|
||||||
|
| `normalized_metadata` | `dict[str, JsonValue] \| None` | JSONBCompat; may include app-namespaced `processing_timing` (`provider_call_duration_ms`, `processing_duration_ms`) |
|
||||||
|
| `software_context` | `dict[str, JsonValue] \| None` | JSONBCompat |
|
||||||
|
| `raw_transcription` | `str \| None` | optional |
|
||||||
|
| `error_category` | `str \| None` | optional |
|
||||||
|
| `error_detail` | `str \| None` | optional |
|
||||||
|
| `failure_phase` | `str \| None` | optional |
|
||||||
|
| `started_at` | `datetime` | required |
|
||||||
|
| `finished_at` | `datetime` | required |
|
||||||
|
| `duration_ms` | `int` | `ge=0` |
|
||||||
|
| `created_at` | `datetime` | default now |
|
||||||
|
|
||||||
|
Constraint:
|
||||||
|
- `UniqueConstraint(job_id, source_id, attempt_number)` named `uq_execution_attempt_number`
|
||||||
|
|
||||||
|
## Relationship Loading Contract
|
||||||
|
|
||||||
|
- Most ORM relationships are configured with `lazy="raise"`.
|
||||||
|
- `JobSource.execution_attempts` is intentionally `lazy="noload"` with ordered attempts.
|
||||||
|
- Service/UI read paths must explicitly eager-load required relationships before access.
|
||||||
|
|
||||||
|
## Persistence Invariants (Ground Truth)
|
||||||
|
|
||||||
|
1. `ExecutionAttempt` is append-only runtime evidence.
|
||||||
|
2. `JobSource.status` represents queue/projection execution state and is not a full evidence container.
|
||||||
|
3. `Source.raw_transcription` is a mutable projection and not authoritative attempt history.
|
||||||
|
4. `Job` terminal status derives from page outcomes (`JobSource` state), not from a separate summary table.
|
||||||
|
5. `DocumentType.semantic_key` and `PersonRole.semantic_key` are nullable-unique semantic identifiers.
|
||||||
|
|
||||||
|
## Cross-Reference
|
||||||
|
|
||||||
|
- [System Architecture](architecture.md)
|
||||||
|
- [System Requirements](requirements.md)
|
||||||
|
- [Error Handling Policy](error_handling.md)
|
||||||
|
- [AI Evidence and Provenance Invariant](../invariant/ai_evidence_and_provenance.md)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# UI Behavioral Contracts
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This directory defines the current user-facing behavior of the NiceGUI application. It records what each page is for, which routes and actions it exposes, what information it presents, and how success, empty, validation, and failure states behave.
|
||||||
|
|
||||||
|
These documents are written for maintainers and AI contributors. They are behavioral contracts, not historical implementation notes and not substitutes for the database schema.
|
||||||
|
|
||||||
|
## Current Page Contracts
|
||||||
|
|
||||||
|
- [Home](pages/home.md)
|
||||||
|
- [Documents](pages/documents.md)
|
||||||
|
- [People](pages/people.md)
|
||||||
|
- [Jobs](pages/jobs.md)
|
||||||
|
- [Sources](pages/sources.md)
|
||||||
|
|
||||||
|
NiceGUI registers the routes shown in each contract without the `/ui` prefix. The application mounts NiceGUI under `/ui`, so `/documents` in page code is served to a browser as `/ui/documents`.
|
||||||
|
|
||||||
|
## Authority Hierarchy
|
||||||
|
|
||||||
|
When documents disagree, use this order:
|
||||||
|
|
||||||
|
1. User-facing page intent and accepted behavior: the page contracts in this directory.
|
||||||
|
2. Visual and interaction styling: [UI Style Guide](../invariant/ui_style_guide.md).
|
||||||
|
3. UI dependency and ownership boundaries: [UI contributor instructions](../../.github/instructions/ui.instructions.md).
|
||||||
|
4. Durable failure behavior: [Error Handling invariant](../invariant/error_handling.md).
|
||||||
|
5. Durable AI evidence behavior: [Digital Evidence and AI Processing Provenance](../invariant/ai_evidence_and_provenance.md).
|
||||||
|
6. Data definitions and relationships: current models plus the [schema contract](../schema.md).
|
||||||
|
7. Implementation truth: current code and tests.
|
||||||
|
|
||||||
|
If code intentionally changes accepted page behavior, update the corresponding page contract in the same change. If code accidentally differs, correct the implementation rather than rewriting intent to match a defect.
|
||||||
|
|
||||||
|
## Contract Contents
|
||||||
|
|
||||||
|
Each page contract contains:
|
||||||
|
|
||||||
|
1. Purpose and user goals.
|
||||||
|
2. Registered routes and navigation context.
|
||||||
|
3. List, detail, and form behavior.
|
||||||
|
4. Editable and system-managed information.
|
||||||
|
5. Validation, empty, loading, and failure states.
|
||||||
|
6. A concise acceptance checklist.
|
||||||
|
7. Current implementation and test anchors.
|
||||||
|
8. Known limitations and deferred work.
|
||||||
|
|
||||||
|
## Maintenance Rules
|
||||||
|
|
||||||
|
- Describe current accepted behavior in present tense.
|
||||||
|
- Do not mix an obsolete “first release” design with current behavior.
|
||||||
|
- Keep future changes in versioned scope documents and link to them from a Deferred Work section.
|
||||||
|
- Do not reproduce the complete database field inventory here; include only fields that affect page behavior.
|
||||||
|
- Keep service, file, and test anchors current.
|
||||||
|
- Do not create separate current-state, target-state, and traceability copies of the same contract.
|
||||||
|
- Keep cross-page visual rules in the UI Style Guide instead of repeating them on each page.
|
||||||
|
- Keep database joins such as `DocumentPerson` and `JobSource` in schema/architecture documentation unless they directly affect a page interaction.
|
||||||
|
|
||||||
|
## Current Baseline
|
||||||
|
|
||||||
|
These contracts describe the current flattened Version 4 baseline.
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Documents Page Contract
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Documents manages the archival record for each historical artifact independently of its source files and transcription jobs. A Document can be created first, linked to people in one or more roles, and used later as the parent for Sources and Jobs.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `/documents` | Searchable archival Document list. |
|
||||||
|
| `/documents/new` | Create a Document. |
|
||||||
|
| `/documents/{document_id}` | View one Document and its related records. |
|
||||||
|
| `/documents/{document_id}/edit` | Edit metadata and the complete Linked People set. |
|
||||||
|
| `/documents/{document_id}/delete` | Confirm or block deletion. |
|
||||||
|
| `/documents/{document_id}/jobs` | Show Jobs belonging to the Document. |
|
||||||
|
| `/documents/{document_id}/sources` | Redirect to the Document-filtered Sources list. |
|
||||||
|
| `/documents/{document_id}/print` | Preview and browser-print the persisted Document. |
|
||||||
|
|
||||||
|
## List Behavior
|
||||||
|
|
||||||
|
- The title is **Archival Documents**.
|
||||||
|
- **Create new document** opens the create route.
|
||||||
|
- The table defaults to Document Title order and supports search and column sorting.
|
||||||
|
- Columns are Document Title, Type, Author, Document Date, and # Sources.
|
||||||
|
- Document Title is left-aligned; the remaining columns are centered.
|
||||||
|
- Author lists all linked people in the `author` role.
|
||||||
|
- # Sources reflects the count of linked Source rows for each Document.
|
||||||
|
- Date display prefers exact date, then approximate date, then `Unknown`.
|
||||||
|
- Selecting a row opens Document Detail.
|
||||||
|
- No records displays `No documents found in repository.`
|
||||||
|
|
||||||
|
## Create and Edit Behavior
|
||||||
|
|
||||||
|
Required:
|
||||||
|
|
||||||
|
- Document name.
|
||||||
|
- Document type selected from the Document Type registry.
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- Exact date.
|
||||||
|
- Approximate date.
|
||||||
|
- Document location.
|
||||||
|
- Archive identifier.
|
||||||
|
- Notes.
|
||||||
|
- Tags.
|
||||||
|
- Linked People, with exactly one Person Role per linked Person.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Exact date must parse as `YYYY-MM-DD`; browser presentation may follow locale.
|
||||||
|
- Existing people appear with disambiguating labels.
|
||||||
|
- Tag assignment supports selecting existing tags and adding new labels inline.
|
||||||
|
- **Create new person** opens Person creation.
|
||||||
|
- `person_id` may preselect that Person in the author role on Document creation.
|
||||||
|
- An invalid requested Person produces a warning rather than a broken form.
|
||||||
|
- `return_to=jobs_new` returns a successful create to Job creation with the new Document selected.
|
||||||
|
- Edit includes active and inactive Document Types so historical values remain maintainable.
|
||||||
|
- One Linked People table contains Select, Person, and Role columns.
|
||||||
|
- Add and Edit use an inline Person/Role editor; Save, Cancel, and Delete change staged UI state only.
|
||||||
|
- A Person may appear once per Document regardless of role.
|
||||||
|
- Existing inactive-role links remain visible; only active roles may be newly assigned.
|
||||||
|
- Document fields and the complete staged link set commit atomically on the main save.
|
||||||
|
- Save success returns to Document Detail.
|
||||||
|
|
||||||
|
## Detail Behavior
|
||||||
|
|
||||||
|
- The heading shows name, type, and internal ID.
|
||||||
|
- The first Source, when present, appears in the dark-room viewer.
|
||||||
|
- Archival Metadata shows authors, Document Type, tags, compact Document date, location, and archive identifier. Notes appear in a separate archival-notes block within the same card.
|
||||||
|
- System Logistics shows created and updated timestamps.
|
||||||
|
- Related People are grouped by role and link to Person Detail.
|
||||||
|
- **Sources & Pipeline Jobs** shows counts and actions for filtered Sources, Document Jobs, and adding a Job.
|
||||||
|
- **Edit Document**, **Print**, and **Delete** are available from the header.
|
||||||
|
- Invalid IDs and missing Documents produce explicit states without rendering a partial page.
|
||||||
|
|
||||||
|
## Print Behavior
|
||||||
|
|
||||||
|
- Print opens a dedicated preview for persisted Document data.
|
||||||
|
- **Facsimile** places each Source image beside its current transcription and starts every Source on a new printed sheet.
|
||||||
|
- **Text only** omits images, joins single line breaks inside paragraphs, and preserves blank-line paragraph boundaries.
|
||||||
|
- Non-null revised text takes precedence over raw transcription, including an intentionally empty revision.
|
||||||
|
- Archival metadata resolves Author through the hidden built-in semantic identity, not its mutable label.
|
||||||
|
- Archival metadata includes the Document Type label.
|
||||||
|
- Metadata tables use a narrow non-wrapping label column and wider wrapping data columns rather than stretching across the page.
|
||||||
|
- Job metadata uses one oldest-to-newest column per Job and ends with Status.
|
||||||
|
- Stored text is escaped and Source media uses record-validated application URLs rather than local file paths.
|
||||||
|
- Printing uses the browser print dialog; server-generated PDFs are not provided.
|
||||||
|
|
||||||
|
## Document Jobs Behavior
|
||||||
|
|
||||||
|
- The page lists the Document's Jobs newest first with status and Job ID.
|
||||||
|
- **Open Job** navigates to Job Detail.
|
||||||
|
- **Create Job** opens Job creation with the Document selected.
|
||||||
|
- No jobs displays an explicit empty state.
|
||||||
|
|
||||||
|
## Delete Behavior
|
||||||
|
|
||||||
|
- Deletion is blocked while any Source or Job belongs to the Document.
|
||||||
|
- The blocked state names the dependency categories and provides navigation back and to Jobs.
|
||||||
|
- An unlinked Document requires an explicit permanent-delete action.
|
||||||
|
- Success returns to the Documents list.
|
||||||
|
|
||||||
|
## Acceptance Checklist
|
||||||
|
|
||||||
|
- List columns, alignment, search, sorting, date fallback, and row navigation match this contract.
|
||||||
|
- Create/edit enforce name, registered type, and valid exact-date input.
|
||||||
|
- Linked People staging enforces one role and one row per Person.
|
||||||
|
- Document and Linked People writes never partially commit.
|
||||||
|
- Person-first Document creation preselects the requested Person as author.
|
||||||
|
- Detail links people, Sources, and Jobs to the correct records.
|
||||||
|
- Delete never removes a Document with Source or Job dependencies.
|
||||||
|
- Both print formats preserve the frozen content, ordering, text-precedence, and safety contracts.
|
||||||
|
- Service failures use the shared error presenter and never report false success.
|
||||||
|
|
||||||
|
## Implementation Anchors
|
||||||
|
|
||||||
|
- `src/transcription/ui/pages/documents_page.py`
|
||||||
|
- `src/transcription/ui/components/table/documents.py`
|
||||||
|
- `src/transcription/services/documents.py`
|
||||||
|
- `src/transcription/services/people.py`
|
||||||
|
- `src/transcription/services/workflows.py`
|
||||||
|
- `src/transcription/ui/components/linked_people.py`
|
||||||
|
- `src/transcription/ui/pages/print_preview_page.py`
|
||||||
|
- `src/transcription/api/print_api.py`
|
||||||
|
- `tests/ui/test_documents_page.py`
|
||||||
|
- `tests/services/test_document_service.py`
|
||||||
|
|
||||||
|
## Known Limitations and Deferred Work
|
||||||
|
|
||||||
|
- Source page ordering remains read-only in V4.4.
|
||||||
|
- Printing other entities, batch printing, and server-side export formats are deferred.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Home Page Contract
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Home provides a user-maintained landing page for the local archive. It combines a database-backed image gallery with Markdown text and lets the operator edit both without changing application source or prompt assets.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Route | Browser path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/homepage` | `/ui/homepage` | View homepage gallery and Markdown. |
|
||||||
|
| `/homepage/edit` | `/ui/homepage/edit` | Upload images, manage image metadata, and edit Markdown. |
|
||||||
|
|
||||||
|
The application root and `/ui` redirect to `/ui/homepage`.
|
||||||
|
|
||||||
|
## View Behavior
|
||||||
|
|
||||||
|
- The visible page heading is **Home**; the browser tab title is **VibeScribe Home**.
|
||||||
|
- The featured homepage image (`photo.is_primary`) is shown first; remaining images are shown in random order.
|
||||||
|
- The current image appears in the shared dark-room viewer with its description.
|
||||||
|
- Saved Markdown is rendered in the **Home Text** card.
|
||||||
|
- Missing text displays `No homepage text saved yet.`
|
||||||
|
- Missing image displays the viewer's empty state.
|
||||||
|
- **Edit Home Page** opens the edit route.
|
||||||
|
- The same Home Text content is also editable from **Settings → Home Page Text**.
|
||||||
|
|
||||||
|
## Edit Behavior
|
||||||
|
|
||||||
|
- The image upload accepts JPEG, PNG, GIF, WebP, BMP, and TIFF files and supports multi-file uploads.
|
||||||
|
- A successful upload immediately stores files in the shared `photo` table/media layout and displays a positive notification.
|
||||||
|
- The editor supports per-image description edits, setting a featured image, and deleting the current image.
|
||||||
|
- The Markdown textarea is initialized from the currently stored homepage text.
|
||||||
|
- **Save** writes the textarea content, displays `Homepage saved`, and returns to Home.
|
||||||
|
- **Cancel** returns to Home without saving textarea changes. An image already uploaded during the edit session remains stored.
|
||||||
|
|
||||||
|
## Storage Contract
|
||||||
|
|
||||||
|
- Homepage markdown text is mutable application data at `UPLOAD_DIR/homepage.md`.
|
||||||
|
- Homepage images are stored as `photo` rows (`person_id = NULL`) with files under `UPLOAD_DIR/photos/`.
|
||||||
|
- Uploaded images are renamed to `{photo_id}{suffix}`.
|
||||||
|
- Homepage images are database records; markdown remains file-backed.
|
||||||
|
|
||||||
|
## Acceptance Checklist
|
||||||
|
|
||||||
|
- `/`, `/ui`, and the application brand reach Home.
|
||||||
|
- Home renders with or without stored Markdown and image content.
|
||||||
|
- Edit loads existing Markdown.
|
||||||
|
- Supported image upload stores one or more images and makes the first image featured when no featured image exists yet.
|
||||||
|
- Save persists Markdown and returns to Home.
|
||||||
|
- Cancel does not save changed Markdown.
|
||||||
|
|
||||||
|
## Implementation Anchors
|
||||||
|
|
||||||
|
- `src/transcription/ui/pages/home_page.py`
|
||||||
|
- `src/transcription/ui/homepage_store.py`
|
||||||
|
- `src/transcription/ui/components/app_shell.py`
|
||||||
|
- `tests/ui/test_upload_page.py`
|
||||||
|
- `tests/ui/test_navigation_and_mounts.py`
|
||||||
|
- `tests/ui/test_pages_registration.py`
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- Homepage markdown storage location is `UPLOAD_DIR/homepage.md` and must remain writable in the active runtime environment.
|
||||||
|
- Uploading an image is immediate and is not rolled back by Cancel.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Jobs Page Contract
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Jobs manages transcription processing runs. A Job belongs to one Document, links one or more Source pages, records processing provenance, and exposes lifecycle actions without making lifecycle fields directly editable.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `/jobs` | Searchable processing Job list. |
|
||||||
|
| `/jobs/new` | Create and queue a Job. |
|
||||||
|
| `/jobs/{job_id}` | View status, execution logistics, and related records. |
|
||||||
|
| `/jobs/{job_id}/cancel` | Confirm cancellation. |
|
||||||
|
| `/jobs/{job_id}/resubmit` | Confirm resubmission of failed Sources. |
|
||||||
|
| `/jobs/{job_id}/delete` | Confirm or block deletion. |
|
||||||
|
|
||||||
|
## List Behavior
|
||||||
|
|
||||||
|
- The title is **Transcription Pipeline Jobs**.
|
||||||
|
- **Create job** opens Job creation and **Refresh** reloads the table.
|
||||||
|
- Columns are Job ID, Status, Document Name, # Sources, Retries, and Updated.
|
||||||
|
- Updated is the primary date/sort field.
|
||||||
|
- Search covers Job ID, document name, and status.
|
||||||
|
- Status is displayed as a semantic status chip.
|
||||||
|
- Selecting a row opens Job Detail.
|
||||||
|
- No records displays `No job records found in repository.`
|
||||||
|
|
||||||
|
## Create Behavior
|
||||||
|
|
||||||
|
- A Target Document and at least one source file are required.
|
||||||
|
- `document_id` may preselect a Target Document.
|
||||||
|
- If no Documents exist, the page explains the prerequisite and links to Document creation with a return path.
|
||||||
|
- Provider and Model are selectable when creating a new Job.
|
||||||
|
- Upload accepts JPEG, PNG, TIFF, and PDF files and supports multiple/folder selection.
|
||||||
|
- The visible upload queue is sorted alphabetically by original filename.
|
||||||
|
- Files can be removed individually or cleared before submission.
|
||||||
|
- Helper text explains numeric filename prefixes for page ordering.
|
||||||
|
- Submission creates the Job, Source records, and JobSource links, notifies the worker, and opens Job Detail.
|
||||||
|
- When opened with `source_id`, creation becomes a retranscription flow: Source and Document are locked, Provider is
|
||||||
|
read-only, Model is restricted to `PROVIDER_MODELS`, no upload is accepted, and one existing Source is linked.
|
||||||
|
|
||||||
|
## Detail and Lifecycle Behavior
|
||||||
|
|
||||||
|
- The heading shows Job ID and a status badge.
|
||||||
|
- Execution Logistics shows provider, model, prompt, retry count, and last update.
|
||||||
|
- Document Links show a clickable Document Name, Sources count, and a single **View Sources** action using document filtering.
|
||||||
|
- Queued and processing Jobs show an auto-refresh notice and reload every four seconds.
|
||||||
|
- Polling stops when the Job becomes terminal or a refresh fails.
|
||||||
|
- Queued and processing Jobs expose **Cancel**.
|
||||||
|
- Jobs other than `transcribed` expose **Resubmit** under the current UI rule. The service blocks resubmission while processing is active or when no failed Sources exist.
|
||||||
|
- All Jobs expose **Delete Job**, subject to explicit evidence-deletion guardrails.
|
||||||
|
- Invalid and missing IDs produce explicit states.
|
||||||
|
|
||||||
|
## Cancel Behavior
|
||||||
|
|
||||||
|
- The confirmation explains that processing stops and remaining pending Sources become cancelled.
|
||||||
|
- The service decides whether the current state permits cancellation.
|
||||||
|
- Success updates the Job, notifies the worker, and returns to Job Detail.
|
||||||
|
|
||||||
|
## Resubmit Behavior
|
||||||
|
|
||||||
|
- The page shows current status and failed Source count.
|
||||||
|
- The page explains that resubmission queues failed linked Sources while preserving immutable prior attempt evidence.
|
||||||
|
- The service blocks submission while processing is active or when no failed Sources exist.
|
||||||
|
- `JobSource` remains the latest compatibility projection, while every provider call appends an `ExecutionAttempt`.
|
||||||
|
- The selected `Source.raw_transcription` projection remains available while a retry is pending or fails.
|
||||||
|
- Success reports the number of resubmitted Sources and returns to Job Detail.
|
||||||
|
|
||||||
|
## Delete Behavior
|
||||||
|
|
||||||
|
- Deletion is blocked while status is `processing`.
|
||||||
|
- Allowed deletion explicitly warns that related `JobSource` projections,
|
||||||
|
immutable execution attempts, captured transport responses, and attempt-owned
|
||||||
|
artifacts are permanently removed.
|
||||||
|
- Source records and source files remain available for separate deletion.
|
||||||
|
- Success returns to the Jobs list.
|
||||||
|
|
||||||
|
## Acceptance Checklist
|
||||||
|
|
||||||
|
- Job creation cannot proceed without a valid Document and at least one Source.
|
||||||
|
- Upload ordering and removal controls match the displayed queue.
|
||||||
|
- Detail shows current status and provenance summary with correct related links.
|
||||||
|
- Active Jobs refresh without overlapping permanent polling after terminal state.
|
||||||
|
- Cancel, resubmit, and delete honor service guardrails and show actionable failures.
|
||||||
|
- Lifecycle fields cannot be edited directly.
|
||||||
|
|
||||||
|
## Implementation Anchors
|
||||||
|
|
||||||
|
- `src/transcription/ui/pages/jobs_page.py`
|
||||||
|
- `src/transcription/ui/components/table/jobs.py`
|
||||||
|
- `src/transcription/services/jobs.py`
|
||||||
|
- `src/transcription/services/store.py`
|
||||||
|
- `src/transcription/services/workflows.py`
|
||||||
|
- `tests/ui/test_jobs_page.py`
|
||||||
|
- `tests/services/test_job_service.py`
|
||||||
|
- `tests/services/test_store.py`
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# People Page Contract
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
People manages reusable historical-person records. A Person may appear in many Documents under different relationship roles and may optionally carry one or more photos plus a FamilySearch identifier.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `/people` | Searchable People list. |
|
||||||
|
| `/people/new` | Create a Person. |
|
||||||
|
| `/people/{person_id}` | View one Person and linked Documents. |
|
||||||
|
| `/people/{person_id}/edit` | Edit the Person. |
|
||||||
|
| `/people/{person_id}/delete` | Confirm permanent deletion. |
|
||||||
|
|
||||||
|
## List Behavior
|
||||||
|
|
||||||
|
- The title is **Archival Entities: People**.
|
||||||
|
- **Create new person** opens the create route.
|
||||||
|
- The table defaults to Full Name order and supports search and column sorting.
|
||||||
|
- Columns are Full Name, FamilySearch ID, Birth Date, Death Date, and # Documents.
|
||||||
|
- Full Name is left-aligned; FamilySearch ID, date columns, and # Documents are centered.
|
||||||
|
- # Documents reflects how many linked Documents each Person is connected to.
|
||||||
|
- Birth and death values independently prefer exact date, then approximate date, then `Unknown`.
|
||||||
|
- Selecting a row opens Person Detail.
|
||||||
|
- No records displays `No person records found in repository.`
|
||||||
|
|
||||||
|
## Create and Edit Behavior
|
||||||
|
|
||||||
|
Required:
|
||||||
|
|
||||||
|
- Full name.
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- Display name and maiden name.
|
||||||
|
- Exact and approximate birth/death dates.
|
||||||
|
- Birth/death places.
|
||||||
|
- Biography.
|
||||||
|
- FamilySearch ID.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Missing Full name blocks save with a warning.
|
||||||
|
- Exact date inputs are native browser date inputs.
|
||||||
|
- FamilySearch IDs are normalized and validated by `PeopleService`.
|
||||||
|
- Photos are managed on Person Detail (not in create/edit form fields).
|
||||||
|
- Metadata JSON remains hidden.
|
||||||
|
- Save success returns to Person Detail.
|
||||||
|
|
||||||
|
## Detail Behavior
|
||||||
|
|
||||||
|
- The header provides **New Document**, **Edit Person**, and **Delete**.
|
||||||
|
- **New Document** opens Document creation with this Person requested for author preselection.
|
||||||
|
- Person Detail includes a photo gallery card with multi-file upload, per-photo description edits, set-primary, and delete.
|
||||||
|
- Primary photo is shown first and labeled as the primary portrait.
|
||||||
|
- Biographical Record shows names, compact birth/death dates, and places.
|
||||||
|
- Birth and death place values are clickable links to Google Maps when present.
|
||||||
|
- FamilySearch ID is shown as a metadata value and is clickable to the FamilySearch person details route when present.
|
||||||
|
- Maiden Name is only shown in Biographical Record when a value exists.
|
||||||
|
- Biography has an explicit empty value.
|
||||||
|
- Linked Documents render as a table with **Document Name**, **Role**, and **Number of Pages**; selecting a row opens Document Detail.
|
||||||
|
- No links shows both an empty state and guidance to link from a Document workflow.
|
||||||
|
- System Logistics shows created and updated timestamps.
|
||||||
|
|
||||||
|
## Delete Behavior
|
||||||
|
|
||||||
|
- The page warns when linked Document relationships exist.
|
||||||
|
- Delete is blocked when related Photos exist.
|
||||||
|
- Confirmed deletion removes the Person and its relationship links; it does not delete Documents.
|
||||||
|
- Success returns to the People list.
|
||||||
|
- Missing or already-deleted records return to a safe list state.
|
||||||
|
|
||||||
|
## Acceptance Checklist
|
||||||
|
|
||||||
|
- List fields, alignment, date fallback, search, sorting, and navigation match this contract.
|
||||||
|
- Full name is enforced on create and edit.
|
||||||
|
- FamilySearch ID validation and link generation use the fixed supported identifier format.
|
||||||
|
- Photo upload and rendering remain constrained to supported media paths.
|
||||||
|
- New Document carries the Person context.
|
||||||
|
- Linked Documents show the correct role and target.
|
||||||
|
- Delete wording distinguishes removal of relationship links from deletion of Documents.
|
||||||
|
|
||||||
|
## Implementation Anchors
|
||||||
|
|
||||||
|
- `src/transcription/ui/pages/people_page.py`
|
||||||
|
- `src/transcription/ui/components/table/people.py`
|
||||||
|
- `src/transcription/services/people.py`
|
||||||
|
- `tests/ui/test_people_page.py`
|
||||||
|
- `tests/services/test_v2_crud.py`
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
- Structured name fields, merge/deduplication, advanced metadata editing, and Person-side relationship editing are not current behavior.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Settings Page Contract
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Settings manages installation-local registries and editable text assets from one route.
|
||||||
|
|
||||||
|
## Route
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `/settings` | Manage Document Types, Person Roles, Tags, Prompts, and Home Page Text. |
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- The page title is **Settings**.
|
||||||
|
- Configuration surfaces are grouped as tabs:
|
||||||
|
- **Document Types**
|
||||||
|
- **Person Roles**
|
||||||
|
- **Tags**
|
||||||
|
- **Prompts**
|
||||||
|
- **Home Page Text**
|
||||||
|
- Document Types, Person Roles, and Tags support Add/Edit/Delete with existing guardrails.
|
||||||
|
- Prompts exposes only `transcribe_document.md` for editing and restore-from-backup.
|
||||||
|
- Home Page Text edits the same Markdown content rendered on `/homepage`.
|
||||||
|
|
||||||
|
## Acceptance Checklist
|
||||||
|
|
||||||
|
- `/ui/settings` renders all five tabs.
|
||||||
|
- Registry and prompt workflows keep existing validation and error handling.
|
||||||
|
- Saving Home Page Text persists content for the homepage view.
|
||||||
|
|
||||||
|
## Implementation Anchors
|
||||||
|
|
||||||
|
- `src/transcription/ui/pages/settings_page.py`
|
||||||
|
- `src/transcription/ui/homepage_store.py`
|
||||||
|
- `tests/ui/test_pages_registration.py`
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Sources Page Contract
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Sources manages individual archived page/file records. It provides source-media viewing, current processing context, provider evidence inspection, previous/next page navigation, and human revision without allowing machine output to be edited.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `/sources` | Global or filtered Source list. |
|
||||||
|
| `/sources/{source_id}` | View media, transcription, revision, metadata, and evidence. |
|
||||||
|
| `/sources/{source_id}/delete` | Confirm or block deletion. |
|
||||||
|
|
||||||
|
The list accepts optional `document_id` and `job_id` query parameters. Document context takes precedence if both parse successfully.
|
||||||
|
|
||||||
|
## List Behavior
|
||||||
|
|
||||||
|
- The title is **Source Asset Records**, **Sources for Document**, or **Sources for Job** according to context.
|
||||||
|
- Global context provides **Create Job**.
|
||||||
|
- Filtered context provides **Back to Document** or **Back to Job**.
|
||||||
|
- Rows are ordered by page number and then upload name.
|
||||||
|
- Columns are Document Name, Page Number, Upload Title, Status, and Error Detail.
|
||||||
|
- Document Name, Upload Title, and Error Detail are left-aligned; Status is centered.
|
||||||
|
- Stored Filename is intentionally absent from the list.
|
||||||
|
- Selecting a row opens Source Detail.
|
||||||
|
- No records displays `No source asset records found in repository.`
|
||||||
|
|
||||||
|
## Detail Behavior
|
||||||
|
|
||||||
|
- The heading shows page number, upload name, and Source ID.
|
||||||
|
- **Back to Sources** returns to the global list.
|
||||||
|
- **Retranscribe Source** opens Create Processing Job with this Source and its Document locked.
|
||||||
|
- **Delete Source** opens the guarded delete route.
|
||||||
|
- Previous and Next navigate only among Sources belonging to the same Document in page order; unavailable boundary actions are disabled.
|
||||||
|
- The media viewer resolves the stored Source path through the configured upload root.
|
||||||
|
- The top layout is adaptive:
|
||||||
|
- Standard pages use three columns with a wider Editable Revision column than the image column.
|
||||||
|
- Wide+narrow landscape images switch to a stacked left layout (image above Editable Revision) with metadata on the right.
|
||||||
|
- Editable Revision is seeded from an existing revision or the preferred machine transcription.
|
||||||
|
- Source Metadata shows upload name, stored filename, page number, Document Name, Document ID, and stored path. Source ID appears in the page-header subtitle.
|
||||||
|
- SourceJob Metadata shows latest status, Job ID, execution time, provider, model, prompt, and failure detail.
|
||||||
|
- Revision Logistics shows revised state, last-revised time, and upload time.
|
||||||
|
- Candidate Machine Transcriptions appears below the image/revision area, remains compact until expanded, then compares it with the preferred
|
||||||
|
machine result and requires confirmation before **Use this transcription**.
|
||||||
|
- Candidate promotion does not alter a human revision. Empty states distinguish no machine result from no candidates.
|
||||||
|
- An orientation-normalized artifact appears in evidence only when recognized metadata required a physical rotation.
|
||||||
|
|
||||||
|
## Provider Evidence
|
||||||
|
|
||||||
|
- Provider Evidence is associated with the latest JobSource execution.
|
||||||
|
- New attempts display separate expandable Request Manifest, Transport Response, OpenRouter SDK Response Snapshot,
|
||||||
|
Normalized Metadata, Software Context, and Derived Artifacts sections.
|
||||||
|
- Historical `raw_api_response` values are labeled as OpenRouter SDK response snapshots.
|
||||||
|
- Missing evidence has an explicit empty state.
|
||||||
|
- Historical executions explicitly state that exact transport evidence was not captured.
|
||||||
|
- Quality warning artifacts remain attached to their machine attempt and are not recomputed during page rendering.
|
||||||
|
- **Export Evidence** downloads a versioned package containing source identity, attempts, artifacts, relationships,
|
||||||
|
schema versions, and integrity digests without source binaries, credentials, or machine-local source paths.
|
||||||
|
|
||||||
|
## Revision Behavior
|
||||||
|
|
||||||
|
- Machine transcription is never edited directly.
|
||||||
|
- A revision must contain non-whitespace text.
|
||||||
|
- Save persists revised text and updates the saved timestamp without leaving the page.
|
||||||
|
- Reset restores the in-memory revision from page load or the most recent successful save. When no revision exists, it restores the machine transcription; it does not re-read the database.
|
||||||
|
- A failed latest execution displays guidance that a human revision can preserve corrected text.
|
||||||
|
|
||||||
|
## Delete Behavior
|
||||||
|
|
||||||
|
- Deletion is allowed only when the Source has no JobSource links.
|
||||||
|
- A linked Source shows cleanup guidance and navigation to Jobs.
|
||||||
|
- An unlinked Source requires explicit permanent deletion.
|
||||||
|
- Success returns to the Sources list.
|
||||||
|
|
||||||
|
## Acceptance Checklist
|
||||||
|
|
||||||
|
- Global, Document-filtered, and Job-filtered lists show the correct context and return action.
|
||||||
|
- List columns and alignments match this contract and omit Stored Filename.
|
||||||
|
- Previous/next navigation never crosses Document boundaries.
|
||||||
|
- Detail keeps machine output read-only and human revision separately editable.
|
||||||
|
- Retranscription, candidate comparison, warnings, and explicit promotion preserve every prior attempt.
|
||||||
|
- Empty, failed, and missing-evidence states remain explicit.
|
||||||
|
- JSON evidence is readable without being mislabeled as native transport evidence.
|
||||||
|
- Delete cannot remove a Source with processing-history links.
|
||||||
|
|
||||||
|
## Implementation Anchors
|
||||||
|
|
||||||
|
- `src/transcription/ui/pages/sources_page.py`
|
||||||
|
- `src/transcription/ui/components/table/sources.py`
|
||||||
|
- `src/transcription/services/sources.py`
|
||||||
|
- `tests/ui/test_sources_page.py`
|
||||||
|
- `tests/services/test_transcription_service.py`
|
||||||
|
- `tests/services/test_v2_crud.py`
|
||||||
|
|
||||||
|
## Planned Changes
|
||||||
|
|
||||||
|
- Source page reordering is deferred beyond V4.3 and may be reconsidered if a demonstrated workflow need emerges.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Tags Page Contract
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Tags provides a dedicated browse/filter entry point for document tagging workflows.
|
||||||
|
|
||||||
|
## Route
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `/tags` | Browse Documents grouped by Tag and filter to one Tag. |
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- The page title is **Tags**.
|
||||||
|
- When no tags exist, the page shows `No tags are configured yet.`
|
||||||
|
- A Tag filter select allows narrowing to one tag.
|
||||||
|
- Each rendered group header includes the tag label and document count.
|
||||||
|
- Document names are clickable and open Document Detail.
|
||||||
|
|
||||||
|
## Acceptance Checklist
|
||||||
|
|
||||||
|
- `/ui/tags` renders successfully from the main navigation.
|
||||||
|
- Group counts match the number of linked Documents per Tag.
|
||||||
|
- Filtering hides non-matching tag groups.
|
||||||
|
|
||||||
|
## Implementation Anchors
|
||||||
|
|
||||||
|
- `src/transcription/ui/pages/tags_page.py`
|
||||||
|
- `src/transcription/services/documents.py`
|
||||||
|
- `tests/ui/test_tags_page.py`
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
# Version 1 Implementation Plan
|
|
||||||
|
|
||||||
This plan defines the path from MVP to **Version 1 complete**.
|
|
||||||
The objective is to deliver the full scoped product with production readiness, while explicitly separating refinements/enhancements into a future document.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0) Plan Governance & Scope Control (Foundation)
|
|
||||||
|
|
||||||
**Goal:** Keep execution focused on V1 completion, not optimization/perfection.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Create and maintain a **V1 Traceability Matrix**:
|
|
||||||
- Requirement ID
|
|
||||||
- Current status (`done`, `partial`, `not started`)
|
|
||||||
- Owner
|
|
||||||
- Validation method
|
|
||||||
2. Define V1 completion gates:
|
|
||||||
- Functional complete
|
|
||||||
- Operationally complete
|
|
||||||
- Production-ready complete
|
|
||||||
3. Snapshot the MVP baseline (tag/changelog reference).
|
|
||||||
4. Create a standing rule: any non-V1 idea is logged to a separate enhancements backlog document (to be named later), not added to active V1 scope unless explicitly approved.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- `docs/ver1/ver1.md` (this plan)
|
|
||||||
- V1 traceability artifact (linked from here when created)
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Every in-scope requirement has explicit ownership and status.
|
|
||||||
- Scope-change process is agreed and followed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) Architecture Consolidation
|
|
||||||
|
|
||||||
**Goal:** Align implementation with the intended architecture and reduce MVP shortcuts.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Compare implemented modules/components with architecture documentation.
|
|
||||||
2. Identify and classify architectural debt:
|
|
||||||
- Temporary coupling
|
|
||||||
- Missing interfaces
|
|
||||||
- Placeholder services/components
|
|
||||||
3. Resolve high-risk architectural gaps first.
|
|
||||||
4. Record key decisions and tradeoffs in ADRs.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Updated architecture diagrams and boundaries
|
|
||||||
- ADR entries for major decisions
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Architecture documentation reflects system reality.
|
|
||||||
- Critical architecture risks are addressed or scheduled with owners/dates.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) Error Handling & Reliability Hardening
|
|
||||||
|
|
||||||
**Goal:** Ensure predictable, safe behavior under failure conditions.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Standardize error taxonomy and envelope format across all layers.
|
|
||||||
2. Ensure clear distinction between:
|
|
||||||
- User-facing errors
|
|
||||||
- Internal/system errors
|
|
||||||
- Retryable vs non-retryable failures
|
|
||||||
3. Add resilience controls where needed:
|
|
||||||
- Timeouts
|
|
||||||
- Retries with backoff
|
|
||||||
- Circuit breaking / fallback logic
|
|
||||||
4. Add failure-path tests for critical workflows.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Error code catalog/reference
|
|
||||||
- Failure mode test coverage for critical paths
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Error behavior is consistent across major flows.
|
|
||||||
- Known failure scenarios are tested and pass.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) Functional Completion by Requirement Domain
|
|
||||||
|
|
||||||
**Goal:** Complete all V1 functional requirements in a risk-aware order.
|
|
||||||
|
|
||||||
### Recommended Order
|
|
||||||
1. Business-critical end-user flows
|
|
||||||
2. Data integrity and consistency capabilities
|
|
||||||
3. Admin/operational controls
|
|
||||||
4. Lower-priority UX and quality-of-life items that are in V1 scope
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
For each requirement slice:
|
|
||||||
1. Finalize contract/schema
|
|
||||||
2. Implement domain logic
|
|
||||||
3. Implement persistence/state changes
|
|
||||||
4. Integrate API/UI
|
|
||||||
5. Add automated tests
|
|
||||||
6. Update docs
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Requirement completion report with validation evidence
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- All V1 “must-have” requirements are complete and validated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) Data Model, Migration, and Backfill Safety
|
|
||||||
|
|
||||||
**Goal:** Ensure data model and migrations are production-safe.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Validate schema against final V1 domain needs.
|
|
||||||
2. Implement forward-safe migrations.
|
|
||||||
3. Define rollback/mitigation plans for migration failures.
|
|
||||||
4. Build and verify backfill scripts (if needed).
|
|
||||||
5. Add migration rehearsal in staging with representative data.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Migration runbook
|
|
||||||
- Backfill verification checklist
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Migration plan validated in staging.
|
|
||||||
- No unresolved data-loss risk for V1 rollout.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5) Security, Access Control, and Compliance Baseline
|
|
||||||
|
|
||||||
**Goal:** Close MVP security gaps and establish V1 baseline controls.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Complete authn/authz coverage for all routes/actions.
|
|
||||||
2. Enforce input validation and output sanitization.
|
|
||||||
3. Verify secret management and credential rotation process.
|
|
||||||
4. Add audit logging for sensitive operations.
|
|
||||||
5. Run dependency/security scanning in CI and remediate findings.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Security checklist with status
|
|
||||||
- Threat/risk update for V1 scope
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- No unresolved critical/high vulnerabilities for V1 launch.
|
|
||||||
- Access control behavior verified by tests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6) Observability & Operability
|
|
||||||
|
|
||||||
**Goal:** Make system behavior observable and supportable in production.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Standardize structured logging and correlation IDs.
|
|
||||||
2. Add core metrics:
|
|
||||||
- Latency
|
|
||||||
- Throughput
|
|
||||||
- Error rates
|
|
||||||
- Resource saturation
|
|
||||||
3. Add tracing for critical request/workflow paths.
|
|
||||||
4. Define SLOs/SLIs and alert thresholds.
|
|
||||||
5. Prepare incident response and rollback runbooks.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Dashboards and alerts
|
|
||||||
- Operations runbooks
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Team can detect, triage, and remediate incidents quickly.
|
|
||||||
- Core production signals are available and reliable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7) Test Strategy Expansion & Quality Gates
|
|
||||||
|
|
||||||
**Goal:** Raise confidence for repeatable, low-risk releases.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Expand unit and integration tests across V1 features.
|
|
||||||
2. Add contract tests between key components/services.
|
|
||||||
3. Add end-to-end tests for critical user journeys.
|
|
||||||
4. Add non-functional tests where relevant:
|
|
||||||
- Performance/load
|
|
||||||
- Soak
|
|
||||||
- Failure-injection scenarios
|
|
||||||
5. Enforce CI quality gates (tests, lint, type checks, security scans).
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Test matrix with ownership
|
|
||||||
- CI gate definition and thresholds
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Critical-path regressions are blocked automatically.
|
|
||||||
- Test coverage and reliability thresholds meet V1 targets.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8) Performance & Scalability Validation
|
|
||||||
|
|
||||||
**Goal:** Meet expected V1 performance at projected load.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Define performance budgets per key flow.
|
|
||||||
2. Benchmark current behavior in staging.
|
|
||||||
3. Optimize bottlenecks (queries, caching, concurrency, etc.).
|
|
||||||
4. Re-test after each optimization and compare against budget.
|
|
||||||
5. Document known limits and safe operating bounds.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Performance benchmark report
|
|
||||||
- Optimization log
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- V1 performance targets met for expected usage profile.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9) Release Engineering & Environment Readiness
|
|
||||||
|
|
||||||
**Goal:** Make deployment repeatable, controlled, and reversible.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Harden CI/CD pipeline with clear promotion gates.
|
|
||||||
2. Ensure config parity and consistency across environments.
|
|
||||||
3. Define rollout strategy (phased/canary/limited release as applicable).
|
|
||||||
4. Validate rollback procedures in staging.
|
|
||||||
5. Produce release checklist and ownership model.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Release playbook
|
|
||||||
- Environment readiness checklist
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Deployment and rollback are rehearsed and reliable.
|
|
||||||
- Release process is executable without tribal knowledge.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10) Documentation Completion
|
|
||||||
|
|
||||||
**Goal:** Ensure V1 can be built, operated, and supported from documentation.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Update core project docs to match final V1 behavior:
|
|
||||||
- Architecture
|
|
||||||
- Error handling
|
|
||||||
- Requirements status
|
|
||||||
- Index/navigation
|
|
||||||
- Intent alignment summary
|
|
||||||
2. Add operator troubleshooting guides.
|
|
||||||
3. Add integration/API examples for consumers.
|
|
||||||
4. Publish changelog/version notes for V1.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Updated documentation set for V1
|
|
||||||
- V1 release notes
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- A new team member can run/support the system using docs alone.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11) Final Validation, UAT, and Launch
|
|
||||||
|
|
||||||
**Goal:** Confirm readiness and launch V1 safely.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Run full-system acceptance validation against the V1 traceability matrix.
|
|
||||||
2. Conduct stakeholder UAT and capture sign-off.
|
|
||||||
3. Execute production readiness review.
|
|
||||||
4. Launch in controlled phases and monitor key signals.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- UAT/PRR sign-off records
|
|
||||||
- Launch checklist and monitoring plan
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Stakeholder approval achieved.
|
|
||||||
- Launch metrics are stable within defined thresholds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12) Post-Launch Stabilization (30–60 Days)
|
|
||||||
|
|
||||||
**Goal:** Consolidate V1 in production before major expansion.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
1. Track incidents, defects, and user feedback.
|
|
||||||
2. Prioritize stabilization fixes with short cycle times.
|
|
||||||
3. Remove temporary flags/mitigations introduced during launch.
|
|
||||||
4. Produce post-launch retrospective and handoff to standard roadmap cadence.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Stabilization report
|
|
||||||
- Prioritized backlog update
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Incident/error rates converge to steady-state targets.
|
|
||||||
- V1 transitions from launch mode to normal operations.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommended Execution Rhythm
|
|
||||||
|
|
||||||
- **Weekly:** Requirement closure + risk review
|
|
||||||
- **Biweekly:** Release train with quality gates
|
|
||||||
- **Milestone reviews:** After phases 2, 6, 9, and 11
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope Discipline Rule (V1 Focus)
|
|
||||||
|
|
||||||
To preserve delivery focus:
|
|
||||||
- V1 execution prioritizes completion of scoped requirements.
|
|
||||||
- Refinements/enhancements are captured in a separate future document and backlog.
|
|
||||||
- Only explicitly approved scope changes may enter this plan.
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# V4.8 Feature Backlog
|
||||||
|
|
||||||
|
**Status: not scoped.** This is a parking document, not a frozen boundary. It records feature work deferred out of V4.6 and V4.7 together with the evidence gathered so far, so that scoping V4.8 does not start from a blank page.
|
||||||
|
|
||||||
|
V4.8 is the first release since V4.5 to add **new user-facing behavior**. V4.6 was pure remediation and V4.7 is architectural cleanup; both were held to "no new features." That constraint ends here, which means V4.8 needs a different verification gate: V4.6 and V4.7 could be validated by "the suite still passes unchanged," and V4.8 cannot.
|
||||||
|
|
||||||
|
## Dependency on V4.7
|
||||||
|
|
||||||
|
**The model-performance rollup below must not begin until V4.7 Phase 4 lands.** `duration_ms` currently measures provider call *plus* image normalization, artifact persistence, and a DB commit, while the timeout governs only the provider call. A rollup built on it would chart preprocessing time mixed with provider latency and look authoritative while quietly misleading. V4.7 Phase 1 removes normalization and artifact persistence from that window, but the commit remains inside it until Phase 4.
|
||||||
|
|
||||||
|
## Candidate Features
|
||||||
|
|
||||||
|
### 1. Pan and Zoom on Source Detail
|
||||||
|
|
||||||
|
**Practicality: high. Effort: S.**
|
||||||
|
|
||||||
|
`ui/components/document_panzoom.py` existed and was **deleted in V4.6 Phase 5** (`6a3ee26`) because it was exported but wired to no page. It is 136 lines and recoverable:
|
||||||
|
|
||||||
|
```
|
||||||
|
git show 6a3ee26^:src/transcription/ui/components/document_panzoom.py
|
||||||
|
```
|
||||||
|
|
||||||
|
It already handled both images and PDFs (the latter via an iframe).
|
||||||
|
|
||||||
|
Two things must change on reintroduction - this is not a straight revert:
|
||||||
|
|
||||||
|
- It loaded Panzoom from the **unpkg CDN**. For an archival application the library should be vendored locally, otherwise the viewer breaks offline and depends on a third party staying available.
|
||||||
|
- It carried its own `_document_url()` helper. V4.6 Phase 5 extracted exactly that logic into `ui/components/media_urls.py` as `resolve_media_url`. Reintroducing the old helper would recreate the duplication Phase 5 removed.
|
||||||
|
|
||||||
|
Scope note: apply it to **Source Detail only**. `dark_room_viewer` (`ui/components/viewers.py`) is shared by four pages - `sources_page.py:268`, `home_page.py:25` and `:88`, `people_page.py:453`, `documents_page.py:524` - so a flag on it would leak pan-zoom into the homepage and document detail, which is not wanted. Add a separate component and use it only at `sources_page.py:268`.
|
||||||
|
|
||||||
|
Numbering note: the Phase 5 commit message states pan-zoom would return "in V4.7 alongside the other photo/image work." Moving it to V4.8 preserves that **intent** - it stays grouped with the photo work - and changes only the release number.
|
||||||
|
|
||||||
|
### 2. Homepage Image Gallery
|
||||||
|
|
||||||
|
**Practicality: high. Effort: S. Recommended first feature.**
|
||||||
|
|
||||||
|
The storage layer is already built:
|
||||||
|
|
||||||
|
- `ui/homepage_store.py:82` `list_homepage_images()` already returns **every** stored image, sorted by modification time.
|
||||||
|
- `store_homepage_image()` already accumulates files rather than overwriting.
|
||||||
|
- Today the UI calls only `latest_homepage_image()` and displays one image. `list_homepage_images()` is currently exercised **only by tests**.
|
||||||
|
|
||||||
|
So multi-image upload is effectively done; what is missing is presentation. NiceGUI 3.13.0 provides `ui.carousel` for left/right navigation and `ui.timer` for rotation.
|
||||||
|
|
||||||
|
Sub-items:
|
||||||
|
|
||||||
|
- Multi-image display with left/right navigation - small, mostly wiring.
|
||||||
|
- Optional slideshow rotating every ~10 minutes.
|
||||||
|
|
||||||
|
**Performance caveat:** `list_homepage_images()` performs a directory scan with a `stat()` per file on every call, and `home_page.py` already performs blocking I/O in the page handler (V4.6 review log [25], which was deliberately left alone). A rotating timer that re-enumerates on every tick would repeat that scan indefinitely. Enumerate once at page load and cache the list.
|
||||||
|
|
||||||
|
### 3. Multiple Person Portraits
|
||||||
|
|
||||||
|
**Practicality: medium. Effort: M/L. Defer behind item 2.**
|
||||||
|
|
||||||
|
`Person.portrait_path` is a **single string column**. Supporting multiple portraits requires a new table, a data migration, and upload UI - a materially larger job than item 2, and a different one.
|
||||||
|
|
||||||
|
### 4. Image Descriptions
|
||||||
|
|
||||||
|
**Practicality: medium, conditional. Effort: M.**
|
||||||
|
|
||||||
|
Homepage images are **filesystem-only with no metadata store**, so a caption has nowhere to live today. This needs either a sidecar JSON file or a real table.
|
||||||
|
|
||||||
|
This is cheap **only if** item 3 is being done at the same time, since both need the same metadata layer. Designing that layer twice would be wasteful; design it once or not at all.
|
||||||
|
|
||||||
|
## Suggested Grouping
|
||||||
|
|
||||||
|
If V4.8 is scoped as one release, the natural split is:
|
||||||
|
|
||||||
|
**Track A - image experience:** items 1 and 2. Both are small, both are self-contained UI work, and item 2's storage layer already exists. This is the highest value for the least risk.
|
||||||
|
|
||||||
|
**Track B - metadata layer:** items 3 and 4 together, since they share a table. Only worth starting if both are wanted.
|
||||||
|
|
||||||
|
**Track C - telemetry:** item 5, gated on V4.7 Phase 4.
|
||||||
|
|
||||||
|
Item 6 is not recommended.
|
||||||
|
|
||||||
|
## Open Questions for Scoping
|
||||||
|
|
||||||
|
- Should Track B happen at all, or is one portrait per person sufficient?
|
||||||
|
- Should the slideshow interval be configurable, or fixed?
|
||||||
|
- Should vendored Panzoom be committed to the repository, or fetched at build time?
|
||||||
|
|
||||||
@@ -5,9 +5,11 @@ This directory stores transcription prompts as individual Markdown artifacts.
|
|||||||
## Conventions
|
## Conventions
|
||||||
- Keep one prompt per file.
|
- Keep one prompt per file.
|
||||||
- Use stable, descriptive snake_case file names.
|
- Use stable, descriptive snake_case file names.
|
||||||
|
- Store prompt files directly in this directory; nested paths are rejected.
|
||||||
- Prefer incremental edits to a single prompt per change for clean history.
|
- Prefer incremental edits to a single prompt per change for clean history.
|
||||||
- Keep prompts human-readable and policy-focused.
|
- Keep prompts human-readable and policy-focused.
|
||||||
- Do not store secrets in prompt files.
|
- Do not store secrets in prompt files.
|
||||||
|
- Runtime jobs snapshot prompt text, SHA-256 provenance, and sampling configuration.
|
||||||
|
|
||||||
## Current Prompt
|
## Current Prompt
|
||||||
- `transcribe_document.md`: baseline verbatim transcription policy for historical documents.
|
- `transcribe_document.md`: baseline verbatim transcription policy for historical documents.
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
You are an assistant that may call tools.
|
|
||||||
|
|
||||||
Tool safety rules:
|
|
||||||
1) Tool arguments MUST be strict JSON matching the schema exactly.
|
|
||||||
2) Never place disallowed, sensitive, explicit, or policy-violating text directly into tool arguments.
|
|
||||||
3) If user content may be unsafe, first produce a brief neutral summary and pass only that summary.
|
|
||||||
4) Prefer IDs, enums, booleans, and short fields over raw free-form text.
|
|
||||||
5) Keep all string arguments <= 300 chars unless schema says otherwise.
|
|
||||||
6) If you cannot safely provide valid tool args, do not call the tool; respond with "NO_TOOL_CALL" and explain briefly.
|
|
||||||
7) Never include markdown/code fences in tool arguments.
|
|
||||||
@@ -6,9 +6,15 @@ Do not summarize. Do not paraphrase. Do not modernize style.
|
|||||||
|
|
||||||
## Output Contract
|
## Output Contract
|
||||||
- Return only the transcription text.
|
- Return only the transcription text.
|
||||||
|
- Begin with exactly one applicable body marker:
|
||||||
|
- `[document body handwritten]`
|
||||||
|
- `[document body typewritten]`
|
||||||
|
- `[document body typeset]`
|
||||||
|
- `[document body mixed]`
|
||||||
- Preserve original wording, punctuation, and meaningful structure.
|
- Preserve original wording, punctuation, and meaningful structure.
|
||||||
- Keep line/section flow readable while preserving intent and document organization.
|
- Keep line/section flow readable while preserving intent and document organization.
|
||||||
- Never invent missing content.
|
- Never invent missing content.
|
||||||
|
- Use ordinary plain-text characters rather than HTML entities.
|
||||||
|
|
||||||
## Rules for Ambiguous or Damaged Text
|
## Rules for Ambiguous or Damaged Text
|
||||||
|
|
||||||
@@ -46,6 +52,31 @@ Do not summarize. Do not paraphrase. Do not modernize style.
|
|||||||
- Signal location before the note text.
|
- Signal location before the note text.
|
||||||
- Example form: `[written in left margin: ...]`
|
- Example form: `[written in left margin: ...]`
|
||||||
|
|
||||||
|
### Document body medium
|
||||||
|
- Use `[document body handwritten]` when the main body is written by hand.
|
||||||
|
- Use `[document body typewritten]` for mechanically typewritten pages. Uneven impressions,
|
||||||
|
monospaced characters, worn type, and other typewriter defects are not handwriting.
|
||||||
|
- Use `[document body typeset]` for printed pages composed with movable type or comparable
|
||||||
|
typesetting.
|
||||||
|
- Use `[document body mixed]` when substantial body content uses more than one medium, such
|
||||||
|
as a completed printed form.
|
||||||
|
- Preserve printed and handwritten text together in their original reading context.
|
||||||
|
- On mixed documents, leave printed labels and instructions unmarked and wrap only actual
|
||||||
|
handwritten entries in `[handwritten: ...]`.
|
||||||
|
- Mark handwritten signatures as `[handwritten signature: ...]`.
|
||||||
|
- If the main body is entirely handwritten, use its one body marker rather than wrapping
|
||||||
|
each line in `[handwritten: ...]`.
|
||||||
|
- Mark later notes or uncertain additions as `[handwritten annotation: ...]`.
|
||||||
|
- When authorship is unclear, use `[handwritten annotation, author uncertain: ...]`.
|
||||||
|
- Do not infer authorship, writing date, or whether different handwriting belongs to different people unless explicitly evident.
|
||||||
|
|
||||||
|
### Structured layouts
|
||||||
|
- Preserve tables of contents as associated title, dotted-leader, and page-reference rows.
|
||||||
|
- Preserve tables and forms in reading order while keeping labels associated with their values.
|
||||||
|
- Preserve columns in their evident reading order; do not interleave unrelated rows.
|
||||||
|
- Preserve captions with the visual element they describe.
|
||||||
|
- Preserve marginalia with its location marker and page numbers in their evident position.
|
||||||
|
|
||||||
### Line-break hyphenation
|
### Line-break hyphenation
|
||||||
- Rejoin words split across line breaks when they are clearly one word.
|
- Rejoin words split across line breaks when they are clearly one word.
|
||||||
- Remove only line-break hyphens used for wrapping.
|
- Remove only line-break hyphens used for wrapping.
|
||||||
@@ -70,3 +101,5 @@ Before finalizing, ensure:
|
|||||||
2. Uncertain/illegible areas are explicitly marked.
|
2. Uncertain/illegible areas are explicitly marked.
|
||||||
3. Crossed-out and inserted text are preserved with required tags.
|
3. Crossed-out and inserted text are preserved with required tags.
|
||||||
4. Structure/ordering is preserved as faithfully as possible.
|
4. Structure/ordering is preserved as faithfully as possible.
|
||||||
|
5. Exactly one document-body marker appears, and localized handwriting markers are used only where applicable.
|
||||||
|
6. Tables, forms, columns, captions, marginalia, dotted leaders, and page references retain their associations.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ dependencies = [
|
|||||||
"fastapi>=0.138.0",
|
"fastapi>=0.138.0",
|
||||||
"nicegui==3.13.0",
|
"nicegui==3.13.0",
|
||||||
"openrouter>=0.7.0",
|
"openrouter>=0.7.0",
|
||||||
|
"pillow>=10.0.0",
|
||||||
"psycopg2-binary>=2.9.12",
|
"psycopg2-binary>=2.9.12",
|
||||||
"pydantic>=2.13.4",
|
"pydantic>=2.13.4",
|
||||||
"pydantic-settings>=2.9.1",
|
"pydantic-settings>=2.9.1",
|
||||||
@@ -39,6 +40,11 @@ dev = [
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "--strict-markers -q"
|
addopts = "--strict-markers -q"
|
||||||
|
asyncio_mode = "strict"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
filterwarnings = [
|
||||||
|
"error:coroutine .* was never awaited:RuntimeWarning",
|
||||||
|
]
|
||||||
markers = [
|
markers = [
|
||||||
"unit: pure logic tests with no external dependencies",
|
"unit: pure logic tests with no external dependencies",
|
||||||
"integration: tests that touch framework or database contracts",
|
"integration: tests that touch framework or database contracts",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from .app import create_app
|
||||||
|
from .config import parse_cli_settings
|
||||||
|
|
||||||
|
|
||||||
|
def create_cli_app() -> FastAPI:
|
||||||
|
"""Create an app from CLI settings for Uvicorn's reload process."""
|
||||||
|
return create_app(settings=parse_cli_settings())
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
settings = parse_cli_settings()
|
||||||
|
application = "transcription.__main__:create_cli_app" if settings.reload else create_app(settings=settings)
|
||||||
|
uvicorn.run(
|
||||||
|
application,
|
||||||
|
factory=settings.reload,
|
||||||
|
host=settings.host,
|
||||||
|
port=settings.port,
|
||||||
|
log_level=settings.log_level,
|
||||||
|
reload=settings.reload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""API routes for relationship and classification registries."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi import Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic import ConfigDict
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import DocumentPerson
|
||||||
|
from transcription.db.models import DocumentType
|
||||||
|
from transcription.db.models import PersonRole
|
||||||
|
from transcription.services import DocumentService
|
||||||
|
from transcription.services import PeopleService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["documents"])
|
||||||
|
|
||||||
|
|
||||||
|
class ApiModel(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentTypeRead(ApiModel):
|
||||||
|
id: UUID
|
||||||
|
label: str
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PersonRoleRead(ApiModel):
|
||||||
|
id: UUID
|
||||||
|
label: str
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentTypeWriteRequest(ApiModel):
|
||||||
|
document_type_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentTypeWriteResponse(ApiModel):
|
||||||
|
document_id: UUID
|
||||||
|
document_type_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPersonWriteRequest(ApiModel):
|
||||||
|
person_id: UUID
|
||||||
|
role_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPersonRoleUpdateRequest(ApiModel):
|
||||||
|
role_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPersonRead(ApiModel):
|
||||||
|
id: UUID
|
||||||
|
document_id: UUID
|
||||||
|
person_id: UUID
|
||||||
|
role_id: UUID
|
||||||
|
role_label: str | None = None
|
||||||
|
person_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPeopleResponse(ApiModel):
|
||||||
|
document_id: UUID
|
||||||
|
links: list[DocumentPersonRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _document_type_to_read(item: DocumentType) -> DocumentTypeRead:
|
||||||
|
item_id, label, is_active = _registry_read_values(item)
|
||||||
|
return DocumentTypeRead(id=item_id, label=label, is_active=is_active)
|
||||||
|
|
||||||
|
|
||||||
|
def _person_role_to_read(item: PersonRole) -> PersonRoleRead:
|
||||||
|
item_id, label, is_active = _registry_read_values(item)
|
||||||
|
return PersonRoleRead(id=item_id, label=label, is_active=is_active)
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_read_values(item: DocumentType | PersonRole) -> tuple[UUID, str, bool]:
|
||||||
|
return item.id, item.label, item.is_active
|
||||||
|
|
||||||
|
|
||||||
|
def _document_person_to_read(item: DocumentPerson) -> DocumentPersonRead:
|
||||||
|
person_name = item.person.full_name if item.person is not None else None
|
||||||
|
return DocumentPersonRead(
|
||||||
|
id=item.id,
|
||||||
|
document_id=item.document_id,
|
||||||
|
person_id=item.person_id,
|
||||||
|
role_id=item.role_id,
|
||||||
|
role_label=item.role_ref.label if item.role_ref is not None else None,
|
||||||
|
person_name=person_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _document_to_type_response(item: Document) -> DocumentTypeWriteResponse:
|
||||||
|
if item.document_type_id is None:
|
||||||
|
raise ValueError("Document Type assignment did not persist")
|
||||||
|
return DocumentTypeWriteResponse(
|
||||||
|
document_id=item.id,
|
||||||
|
document_type_id=item.document_type_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_service(request: Request) -> DocumentService:
|
||||||
|
"""Resolve the document service from app lifespan state when available."""
|
||||||
|
services = getattr(request.app.state, "services", None)
|
||||||
|
if services is not None:
|
||||||
|
return services.documents
|
||||||
|
return DocumentService()
|
||||||
|
|
||||||
|
|
||||||
|
def get_people_service(request: Request) -> PeopleService:
|
||||||
|
"""Resolve the People service from app lifespan state when available."""
|
||||||
|
services = getattr(request.app.state, "services", None)
|
||||||
|
if services is not None:
|
||||||
|
return services.people
|
||||||
|
return PeopleService()
|
||||||
|
|
||||||
|
|
||||||
|
DocumentServiceDependency = Annotated[DocumentService, Depends(get_document_service)]
|
||||||
|
PeopleServiceDependency = Annotated[PeopleService, Depends(get_people_service)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/document-types", response_model=list[DocumentTypeRead])
|
||||||
|
async def list_document_types(
|
||||||
|
service: DocumentServiceDependency,
|
||||||
|
active_only: bool = True,
|
||||||
|
) -> list[DocumentTypeRead]:
|
||||||
|
items = await service.list_document_types(active_only=active_only)
|
||||||
|
return [_document_type_to_read(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/person-roles", response_model=list[PersonRoleRead])
|
||||||
|
async def list_person_roles(
|
||||||
|
service: PeopleServiceDependency,
|
||||||
|
active_only: bool = True,
|
||||||
|
) -> list[PersonRoleRead]:
|
||||||
|
items = await service.list_person_roles(active_only=active_only)
|
||||||
|
return [_person_role_to_read(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/documents/{document_id}/type", response_model=DocumentTypeWriteResponse)
|
||||||
|
async def set_document_type(
|
||||||
|
document_id: UUID,
|
||||||
|
payload: DocumentTypeWriteRequest,
|
||||||
|
service: DocumentServiceDependency,
|
||||||
|
) -> DocumentTypeWriteResponse:
|
||||||
|
document = await service.set_document_type(
|
||||||
|
document_id=document_id,
|
||||||
|
document_type_id=payload.document_type_id,
|
||||||
|
)
|
||||||
|
return _document_to_type_response(document)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documents/{document_id}/people", response_model=DocumentPeopleResponse)
|
||||||
|
async def list_document_people(
|
||||||
|
document_id: UUID,
|
||||||
|
service: PeopleServiceDependency,
|
||||||
|
) -> DocumentPeopleResponse:
|
||||||
|
links = await service.list_document_people(document_id=document_id)
|
||||||
|
return DocumentPeopleResponse(document_id=document_id, links=[_document_person_to_read(item) for item in links])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/documents/{document_id}/people", response_model=DocumentPersonRead)
|
||||||
|
async def add_document_person_link(
|
||||||
|
document_id: UUID,
|
||||||
|
payload: DocumentPersonWriteRequest,
|
||||||
|
service: PeopleServiceDependency,
|
||||||
|
) -> DocumentPersonRead:
|
||||||
|
link = await service.add_document_person_link(
|
||||||
|
document_id=document_id,
|
||||||
|
person_id=payload.person_id,
|
||||||
|
role_id=payload.role_id,
|
||||||
|
)
|
||||||
|
return _document_person_to_read(link)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/document-people/{document_person_id}", response_model=DocumentPersonRead)
|
||||||
|
async def set_document_person_role(
|
||||||
|
document_person_id: UUID,
|
||||||
|
payload: DocumentPersonRoleUpdateRequest,
|
||||||
|
service: PeopleServiceDependency,
|
||||||
|
) -> DocumentPersonRead:
|
||||||
|
link = await service.set_document_person_role(
|
||||||
|
document_person_id=document_person_id,
|
||||||
|
role_id=payload.role_id,
|
||||||
|
)
|
||||||
|
return _document_person_to_read(link)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/document-people/{document_person_id}", status_code=204)
|
||||||
|
async def delete_document_person_link(
|
||||||
|
document_person_id: UUID,
|
||||||
|
service: PeopleServiceDependency,
|
||||||
|
) -> Response:
|
||||||
|
await service.remove_document_person_link(document_person_id=document_person_id)
|
||||||
|
return Response(status_code=204)
|
||||||
@@ -21,7 +21,9 @@ _STATUS_BY_CATEGORY: dict[ErrorCategory, int] = {
|
|||||||
ErrorCategory.NOT_FOUND: 404,
|
ErrorCategory.NOT_FOUND: 404,
|
||||||
ErrorCategory.CONFLICT: 409,
|
ErrorCategory.CONFLICT: 409,
|
||||||
ErrorCategory.EXTERNAL_PROVIDER: 503,
|
ErrorCategory.EXTERNAL_PROVIDER: 503,
|
||||||
|
ErrorCategory.EXTERNAL_TIMEOUT: 503,
|
||||||
ErrorCategory.INFRA_TRANSIENT: 503,
|
ErrorCategory.INFRA_TRANSIENT: 503,
|
||||||
|
ErrorCategory.PROCESSING: 500,
|
||||||
ErrorCategory.INFRA_PERSISTENT: 500,
|
ErrorCategory.INFRA_PERSISTENT: 500,
|
||||||
ErrorCategory.INTERNAL_UNEXPECTED: 500,
|
ErrorCategory.INTERNAL_UNEXPECTED: 500,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,44 @@
|
|||||||
"""Health endpoint routes."""
|
"""Health endpoint routes."""
|
||||||
|
|
||||||
|
from typing import NotRequired
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from transcription.worker import resolve_worker_health
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def healthz() -> dict[str, str]:
|
class WorkerHealthPayload(TypedDict):
|
||||||
"""Return a simple health status payload."""
|
state: str
|
||||||
return {"status": "ok"}
|
error_id: NotRequired[str]
|
||||||
|
error_category: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
class HealthPayload(TypedDict):
|
||||||
|
status: str
|
||||||
|
worker: WorkerHealthPayload
|
||||||
|
|
||||||
|
|
||||||
|
def healthz(request: Request) -> HealthPayload:
|
||||||
|
"""Return health status with worker-liveness signal."""
|
||||||
|
worker = resolve_worker_health(request.app.state)
|
||||||
|
payload: HealthPayload = {
|
||||||
|
"status": "ok",
|
||||||
|
"worker": {
|
||||||
|
"state": worker.state,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if worker.error_id is not None:
|
||||||
|
payload["worker"]["error_id"] = worker.error_id
|
||||||
|
if worker.error_category is not None:
|
||||||
|
payload["worker"]["error_category"] = worker.error_category
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.get("/healthz")
|
@router.get("/healthz")
|
||||||
def healthz_route() -> dict[str, str]:
|
def healthz_route(request: Request) -> HealthPayload:
|
||||||
"""Route wrapper for health status payload."""
|
"""Route wrapper for health status payload."""
|
||||||
return healthz()
|
return healthz(request)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Safe media route for Document print previews."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from transcription.services.source_media import SOURCE_MIME_TYPES
|
||||||
|
from transcription.services.sources import SourceService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["print"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_source_service(request: Request) -> SourceService:
|
||||||
|
services = getattr(request.app.state, "services", None)
|
||||||
|
if services is not None:
|
||||||
|
return services.sources
|
||||||
|
return SourceService()
|
||||||
|
|
||||||
|
|
||||||
|
SourceServiceDependency = Annotated[SourceService, Depends(get_source_service)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documents/{document_id}/sources/{source_id}/media", response_class=FileResponse)
|
||||||
|
async def read_document_source_media(
|
||||||
|
document_id: UUID,
|
||||||
|
source_id: UUID,
|
||||||
|
service: SourceServiceDependency,
|
||||||
|
) -> FileResponse:
|
||||||
|
"""Serve one validated Source through record identifiers, never a supplied path."""
|
||||||
|
source = await service.read_source(source_id)
|
||||||
|
if source.document_id != document_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Source not found for Document")
|
||||||
|
|
||||||
|
upload_root = service.settings.upload_dir.resolve()
|
||||||
|
path = (upload_root / Path(source.file_path)).resolve()
|
||||||
|
try:
|
||||||
|
path.relative_to(upload_root)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail="Source media is outside managed storage") from exc
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Source media is unavailable")
|
||||||
|
|
||||||
|
media_type = SOURCE_MIME_TYPES.get(path.suffix.lower())
|
||||||
|
if media_type is None:
|
||||||
|
raise HTTPException(status_code=415, detail="Unsupported Source media type")
|
||||||
|
return FileResponse(path, media_type=media_type)
|
||||||
+44
-11
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from contextlib import AsyncExitStack
|
from contextlib import AsyncExitStack
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
@@ -11,35 +15,48 @@ from fastapi.responses import RedirectResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from .api.errors import register_error_handlers
|
from .api.errors import register_error_handlers
|
||||||
|
from .api.documents_api import router as documents_router
|
||||||
from .api.health import router as health_router
|
from .api.health import router as health_router
|
||||||
|
from .api.print_api import router as print_router
|
||||||
|
from .config import Settings
|
||||||
from .config import configure_logging
|
from .config import configure_logging
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .db import create_all
|
from .db import create_all
|
||||||
from .db import dispose_database_runtime
|
from .db import dispose_database_runtime
|
||||||
from .db import initialize_database_runtime
|
from .db import initialize_database_runtime
|
||||||
|
from .db import reconcile_canonical_media_paths
|
||||||
|
from .db import reconcile_legacy_job_source_columns
|
||||||
from .services import ServiceBundle
|
from .services import ServiceBundle
|
||||||
from .ui import register_pages
|
from .ui import register_pages
|
||||||
from .worker import worker_consumer_lifespan
|
from .worker import worker_consumer_lifespan
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _lifespan(app: FastAPI):
|
async def _lifespan(app: FastAPI):
|
||||||
configure_logging()
|
|
||||||
|
|
||||||
settings = getattr(app.state, "settings", None) or get_settings()
|
settings = getattr(app.state, "settings", None) or get_settings()
|
||||||
|
configure_logging(settings)
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.services = ServiceBundle()
|
|
||||||
app.state.runtime = initialize_database_runtime(settings=settings)
|
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||||
|
session_factory = app.state.runtime.session_factory
|
||||||
|
app.state.services = ServiceBundle.from_session_factory(session_factory, settings=settings)
|
||||||
|
|
||||||
if settings.should_bootstrap_schema:
|
if settings.should_bootstrap_schema:
|
||||||
await create_all(engine=app.state.runtime.engine)
|
await create_all(engine=app.state.runtime.engine)
|
||||||
|
await reconcile_legacy_job_source_columns(engine=app.state.runtime.engine)
|
||||||
|
await reconcile_canonical_media_paths(engine=app.state.runtime.engine)
|
||||||
|
|
||||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
settings.log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
settings.database_backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
await _recover_stale_processing_jobs(app)
|
||||||
|
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
stack.push_async_callback(dispose_database_runtime)
|
stack.push_async_callback(dispose_database_runtime)
|
||||||
stop_event, worker_notifier = await stack.enter_async_context(
|
stop_event, worker_notifier, worker_health = await stack.enter_async_context(
|
||||||
worker_consumer_lifespan(
|
worker_consumer_lifespan(
|
||||||
session_factory=app.state.runtime.session_factory,
|
session_factory=app.state.runtime.session_factory,
|
||||||
poll_interval_seconds=1.0,
|
poll_interval_seconds=1.0,
|
||||||
@@ -47,29 +64,45 @@ async def _lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
app.state.worker_stop_event = stop_event
|
app.state.worker_stop_event = stop_event
|
||||||
app.state.worker_notifier = worker_notifier
|
app.state.worker_notifier = worker_notifier
|
||||||
|
app.state.worker_health = worker_health
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
async def _recover_stale_processing_jobs(app: FastAPI) -> None:
|
||||||
|
"""Re-queue stale processing jobs at startup.
|
||||||
|
|
||||||
|
Any job left in PROCESSING longer than the configured provider timeout is
|
||||||
|
assumed orphaned and moved back to QUEUED before the worker starts.
|
||||||
|
"""
|
||||||
|
settings = app.state.settings
|
||||||
|
stale_before = datetime.now(UTC) - timedelta(seconds=settings.worker_provider_timeout_seconds)
|
||||||
|
recovered = await app.state.services.jobs.requeue_stale_processing_jobs(stale_before=stale_before)
|
||||||
|
if recovered > 0:
|
||||||
|
logger.warning("Recovered %s stale processing job(s) at startup", recovered)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
"""Create and configure the FastAPI application."""
|
"""Create and configure the FastAPI application."""
|
||||||
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
||||||
settings = get_settings()
|
active_settings = settings or get_settings()
|
||||||
app.state.settings = settings
|
app.state.settings = active_settings
|
||||||
app.mount(
|
app.mount(
|
||||||
"/uploads",
|
"/uploads",
|
||||||
StaticFiles(directory=settings.upload_dir, check_dir=False),
|
StaticFiles(directory=active_settings.upload_dir, check_dir=False),
|
||||||
name="uploads",
|
name="uploads",
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
@app.get("/", include_in_schema=False)
|
||||||
async def root_redirect() -> RedirectResponse:
|
async def root_redirect() -> RedirectResponse:
|
||||||
return RedirectResponse(url="/ui", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
return RedirectResponse(url="/ui/homepage", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||||
|
|
||||||
@app.get("/ui", include_in_schema=False)
|
@app.get("/ui", include_in_schema=False)
|
||||||
async def ui_redirect() -> RedirectResponse:
|
async def ui_redirect() -> RedirectResponse:
|
||||||
return RedirectResponse(url="/ui/upload", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
return RedirectResponse(url="/ui/homepage", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||||
|
|
||||||
register_error_handlers(app)
|
register_error_handlers(app)
|
||||||
register_pages(app)
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
app.include_router(documents_router)
|
||||||
|
app.include_router(print_router)
|
||||||
|
register_pages(app)
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
"""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.runtime 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)
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Private-corpus benchmark contracts and deterministic text scoring."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic import ConfigDict
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
|
||||||
|
class BenchmarkModel(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BenchmarkItem(BenchmarkModel):
|
||||||
|
"""One private benchmark item referenced by archival identity."""
|
||||||
|
|
||||||
|
source_id: UUID
|
||||||
|
source_digest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||||
|
categories: frozenset[str] = Field(min_length=1)
|
||||||
|
reference_transcription: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class BenchmarkManifest(BenchmarkModel):
|
||||||
|
"""Versioned private benchmark definition without copied source media."""
|
||||||
|
|
||||||
|
schema_name: str = "transcription.private-benchmark"
|
||||||
|
schema_version: str = "1"
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
items: tuple[BenchmarkItem, ...] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class EditorialAssessment(BenchmarkModel):
|
||||||
|
"""Manually reviewed errors not represented adequately by CER or WER."""
|
||||||
|
|
||||||
|
omissions: int = Field(default=0, ge=0)
|
||||||
|
inventions: int = Field(default=0, ge=0)
|
||||||
|
silent_normalizations: int = Field(default=0, ge=0)
|
||||||
|
uncertainty_errors: int = Field(default=0, ge=0)
|
||||||
|
layout_errors: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class BenchmarkScore(BenchmarkModel):
|
||||||
|
"""Measured score for one preserved execution attempt."""
|
||||||
|
|
||||||
|
execution_attempt_id: UUID
|
||||||
|
character_error_rate: float = Field(ge=0)
|
||||||
|
word_error_rate: float = Field(ge=0)
|
||||||
|
character_edits: int = Field(ge=0)
|
||||||
|
word_edits: int = Field(ge=0)
|
||||||
|
reference_characters: int = Field(ge=0)
|
||||||
|
reference_words: int = Field(ge=0)
|
||||||
|
assessment: EditorialAssessment
|
||||||
|
latency_ms: int = Field(ge=0)
|
||||||
|
cost_usd: float | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
def score_transcription(
|
||||||
|
*,
|
||||||
|
execution_attempt_id: UUID,
|
||||||
|
reference: str,
|
||||||
|
candidate: str,
|
||||||
|
assessment: EditorialAssessment,
|
||||||
|
latency_ms: int,
|
||||||
|
cost_usd: float | None = None,
|
||||||
|
) -> BenchmarkScore:
|
||||||
|
"""Score literal text without case-folding or silent normalization."""
|
||||||
|
reference_words = reference.split()
|
||||||
|
candidate_words = candidate.split()
|
||||||
|
character_edits = _levenshtein(list(reference), list(candidate))
|
||||||
|
word_edits = _levenshtein(reference_words, candidate_words)
|
||||||
|
return BenchmarkScore(
|
||||||
|
execution_attempt_id=execution_attempt_id,
|
||||||
|
character_error_rate=character_edits / max(1, len(reference)),
|
||||||
|
word_error_rate=word_edits / max(1, len(reference_words)),
|
||||||
|
character_edits=character_edits,
|
||||||
|
word_edits=word_edits,
|
||||||
|
reference_characters=len(reference),
|
||||||
|
reference_words=len(reference_words),
|
||||||
|
assessment=assessment,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
cost_usd=cost_usd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _levenshtein(reference: list[str], candidate: list[str]) -> int:
|
||||||
|
if len(reference) < len(candidate):
|
||||||
|
reference, candidate = candidate, reference
|
||||||
|
previous = list(range(len(candidate) + 1))
|
||||||
|
for reference_index, reference_value in enumerate(reference, start=1):
|
||||||
|
current = [reference_index]
|
||||||
|
for candidate_index, candidate_value in enumerate(candidate, start=1):
|
||||||
|
current.append(
|
||||||
|
min(
|
||||||
|
current[-1] + 1,
|
||||||
|
previous[candidate_index] + 1,
|
||||||
|
previous[candidate_index - 1] + (reference_value != candidate_value),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
previous = current
|
||||||
|
return previous[-1]
|
||||||
+154
-23
@@ -5,12 +5,23 @@ once at startup. Provider-specific defaults (model names, base URLs)
|
|||||||
are resolved by the provider adapters, not here.
|
are resolved by the provider adapters, not here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import logging.config
|
import logging.config
|
||||||
from contextvars import ContextVar
|
from collections.abc import Sequence
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
from functools import cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
from typing import Any
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic import ConfigDict
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic import SecretStr
|
||||||
|
from pydantic import StringConstraints
|
||||||
|
from pydantic import field_validator
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from pydantic_settings import SettingsConfigDict
|
from pydantic_settings import SettingsConfigDict
|
||||||
|
|
||||||
@@ -21,56 +32,160 @@ class Provider(StrEnum):
|
|||||||
OPENROUTER = "openrouter"
|
OPENROUTER = "openrouter"
|
||||||
|
|
||||||
|
|
||||||
|
NonEmptyStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||||
|
PromptFilename = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, pattern=r"^[^/\\]+$")]
|
||||||
|
Probability = Annotated[float, Field(ge=0.0, le=1.0)]
|
||||||
|
Temperature = Annotated[float, Field(ge=0.0, le=2.0)]
|
||||||
|
DEFAULT_PROVIDER_MODEL = "google/gemini-2.5-flash"
|
||||||
|
|
||||||
|
|
||||||
|
class SqliteSettings(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
|
driver: Literal["sqlite"] = "sqlite"
|
||||||
|
path: NonEmptyStr = "./data/transcription.db"
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresSettings(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
|
driver: Literal["postgres"] = "postgres"
|
||||||
|
host: NonEmptyStr
|
||||||
|
port: int = Field(default=5432, ge=1, le=65535)
|
||||||
|
database: NonEmptyStr
|
||||||
|
user: NonEmptyStr
|
||||||
|
password: SecretStr
|
||||||
|
|
||||||
|
|
||||||
|
DatabaseSettings = Annotated[
|
||||||
|
SqliteSettings | PostgresSettings,
|
||||||
|
Field(discriminator="driver"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
cli_implicit_flags=True,
|
||||||
|
cli_kebab_case=True,
|
||||||
|
frozen=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- NiceGUI Server ---
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8000
|
||||||
|
log_level: Literal["critical", "error", "warning", "info", "debug", "trace"] = "info"
|
||||||
|
reload: bool = False
|
||||||
|
log_dir: Path = Path("./data/logs")
|
||||||
|
log_file_name: NonEmptyStr = "transcription.log"
|
||||||
|
log_file_max_bytes: int = Field(default=10 * 1024 * 1024, gt=0)
|
||||||
|
log_file_backup_count: int = Field(default=5, ge=1)
|
||||||
|
|
||||||
# --- AI provider ---
|
# --- AI provider ---
|
||||||
provider: Provider = Provider.OPENROUTER
|
provider: Provider = Provider.OPENROUTER
|
||||||
openrouter_api_key: str
|
openrouter_api_key: SecretStr
|
||||||
provider_model: str | None = None
|
provider_model: NonEmptyStr | None = DEFAULT_PROVIDER_MODEL
|
||||||
openrouter_http_referer: str | None = None
|
provider_models: tuple[NonEmptyStr, ...] = ()
|
||||||
openrouter_app_title: str | None = None
|
openrouter_http_referer: NonEmptyStr | None = None
|
||||||
|
openrouter_app_title: NonEmptyStr | None = None
|
||||||
|
default_prompt_name: PromptFilename = "transcribe_document.md"
|
||||||
|
transcription_temperature: Temperature | None = None
|
||||||
|
transcription_top_p: Probability | None = None
|
||||||
|
|
||||||
# --- runtime environment ---
|
# --- runtime environment ---
|
||||||
environment: Literal["development", "test", "production"] = "development"
|
environment: Literal["development", "test", "production"] = "development"
|
||||||
|
transcription_commit: NonEmptyStr | None = None
|
||||||
|
|
||||||
# --- persistence ---
|
# --- persistence ---
|
||||||
database_url: str = "sqlite:///./transcription.db"
|
database: DatabaseSettings = Field(default_factory=SqliteSettings)
|
||||||
bootstrap_schema_on_startup: bool | None = None
|
bootstrap_schema_on_startup: bool = False
|
||||||
sqlite_check_same_thread: bool = False
|
sqlite_check_same_thread: bool = False
|
||||||
|
|
||||||
# --- filesystem paths ---
|
# --- filesystem paths ---
|
||||||
upload_dir: Path = Path("./uploads")
|
upload_dir: Path = Path("./data")
|
||||||
prompt_dir: Path = Path("./prompts")
|
prompt_dir: Path = Path("./prompts")
|
||||||
|
database_backup_dir: Path = Path("./data/backups")
|
||||||
|
|
||||||
# --- worker reliability ---
|
# --- worker reliability ---
|
||||||
worker_max_retries: int = 0
|
worker_max_retries: int = Field(default=0, ge=0)
|
||||||
worker_retry_backoff_seconds: float = 0.0
|
# Bounded only from below. Vision transcription of a dense page routinely runs
|
||||||
|
# well past twenty seconds, so an upper cap here would silently fail real work.
|
||||||
|
worker_provider_timeout_seconds: float = Field(default=30.0, gt=0.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
|
||||||
|
|
||||||
|
@field_validator("provider_models", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_provider_models_input(cls, value: object) -> object:
|
||||||
|
if value is None:
|
||||||
|
return ()
|
||||||
|
if isinstance(value, (list, tuple)) and not value:
|
||||||
|
raise ValueError("PROVIDER_MODELS must contain at least one model")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_provider_models(cls, data: object) -> object:
|
||||||
|
"""Build the immutable model selector with the configured default first.
|
||||||
|
|
||||||
|
This runs before field validation so the derived value is produced by
|
||||||
|
normal construction rather than by mutating a frozen instance.
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
|
||||||
|
default_model = data.get("provider_model") or DEFAULT_PROVIDER_MODEL
|
||||||
|
if not isinstance(default_model, str):
|
||||||
|
return data
|
||||||
|
default_model = default_model.strip()
|
||||||
|
|
||||||
|
configured = data.get("provider_models")
|
||||||
|
if configured is None:
|
||||||
|
configured = ()
|
||||||
|
elif isinstance(configured, str):
|
||||||
|
# Left as-is so the field validator can report the malformed value.
|
||||||
|
return {**data, "provider_model": default_model}
|
||||||
|
elif not isinstance(configured, (list, tuple)):
|
||||||
|
return {**data, "provider_model": default_model}
|
||||||
|
elif not configured:
|
||||||
|
# Preserved so validate_provider_models_input can reject it.
|
||||||
|
return {**data, "provider_model": default_model}
|
||||||
|
|
||||||
|
deduplicated: list[str] = []
|
||||||
|
for model in (default_model, *configured):
|
||||||
|
if not isinstance(model, str):
|
||||||
|
return {**data, "provider_model": default_model}
|
||||||
|
normalized = model.strip()
|
||||||
|
if normalized not in deduplicated:
|
||||||
|
deduplicated.append(normalized)
|
||||||
|
|
||||||
|
return {**data, "provider_model": default_model, "provider_models": tuple(deduplicated)}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def should_bootstrap_schema(self) -> bool:
|
def should_bootstrap_schema(self) -> bool:
|
||||||
"""Return whether startup should auto-create schema for this environment."""
|
"""Return whether startup should auto-create schema for this environment."""
|
||||||
if self.bootstrap_schema_on_startup is not None:
|
if "bootstrap_schema_on_startup" in self.model_fields_set:
|
||||||
return self.bootstrap_schema_on_startup
|
return self.bootstrap_schema_on_startup
|
||||||
return self.environment in {"development", "test"}
|
return self.environment in {"development", "test"}
|
||||||
|
|
||||||
|
|
||||||
_settings: ContextVar[Settings | None] = ContextVar("settings", default=None)
|
@cache
|
||||||
|
def get_settings(**kwargs: Any) -> Settings:
|
||||||
|
"""Load cached settings without reading process CLI arguments."""
|
||||||
|
return Settings(_cli_parse_args=False, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def get_settings(**kwargs) -> Settings:
|
def parse_cli_settings(args: Sequence[str] | None = None) -> Settings:
|
||||||
settings = _settings.get()
|
"""Load settings with CLI arguments at the executable boundary."""
|
||||||
if settings is None:
|
cli_args = True if args is None else list(args)
|
||||||
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
return Settings(_cli_parse_args=cli_args)
|
||||||
_settings.set(settings)
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
LOGGING_CONFIG: dict[str, object] = {
|
LOGGING_CONFIG: dict[str, Any] = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
"formatters": {
|
"formatters": {
|
||||||
@@ -84,23 +199,39 @@ LOGGING_CONFIG: dict[str, object] = {
|
|||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
"formatter": "standard",
|
"formatter": "standard",
|
||||||
"stream": "ext://sys.stdout",
|
"stream": "ext://sys.stdout",
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"class": "logging.handlers.RotatingFileHandler",
|
||||||
|
"formatter": "standard",
|
||||||
|
"filename": str(Path("./data/logs") / "transcription.log"),
|
||||||
|
"maxBytes": 10 * 1024 * 1024,
|
||||||
|
"backupCount": 5,
|
||||||
|
"encoding": "utf-8",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"handlers": ["console"],
|
"handlers": ["console", "file"],
|
||||||
},
|
},
|
||||||
"loggers": {
|
"loggers": {
|
||||||
"transcription": {
|
"transcription": {
|
||||||
"level": "DEBUG",
|
"level": "DEBUG",
|
||||||
"handlers": ["console"],
|
"handlers": ["console", "file"],
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging(settings: Settings | None = None) -> None:
|
||||||
"""Configure root logging once at startup."""
|
"""Configure root logging once at startup."""
|
||||||
logging.config.dictConfig(LOGGING_CONFIG)
|
cfg = copy.deepcopy(LOGGING_CONFIG)
|
||||||
|
active_settings = settings or get_settings()
|
||||||
|
active_settings.log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_handler = cfg["handlers"]["file"]
|
||||||
|
file_handler["filename"] = str(active_settings.log_dir / active_settings.log_file_name)
|
||||||
|
file_handler["maxBytes"] = active_settings.log_file_max_bytes
|
||||||
|
file_handler["backupCount"] = active_settings.log_file_backup_count
|
||||||
|
cfg["loggers"]["transcription"]["level"] = active_settings.log_level.upper()
|
||||||
|
logging.config.dictConfig(cfg)
|
||||||
logger.debug("Logging configured")
|
logger.debug("Logging configured")
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
from .operations import create_all
|
from .operations import create_all
|
||||||
|
from .operations import reconcile_canonical_media_paths
|
||||||
|
from .operations import reconcile_legacy_job_source_columns
|
||||||
from .runtime import dispose_database_runtime
|
from .runtime import dispose_database_runtime
|
||||||
from .runtime import get_session
|
|
||||||
from .runtime import initialize_database_runtime
|
from .runtime import initialize_database_runtime
|
||||||
|
from .session import session_scope
|
||||||
|
from .session import transaction_scope
|
||||||
|
|
||||||
__all__ = ["create_all", "dispose_database_runtime", "get_session", "initialize_database_runtime"]
|
__all__ = [
|
||||||
|
"create_all",
|
||||||
|
"dispose_database_runtime",
|
||||||
|
"initialize_database_runtime",
|
||||||
|
"reconcile_canonical_media_paths",
|
||||||
|
"reconcile_legacy_job_source_columns",
|
||||||
|
"session_scope",
|
||||||
|
"transaction_scope",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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),
|
||||||
|
sqlite_check_same_thread=active_settings.sqlite_check_same_thread,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ENGINES: dict[str, AsyncEngine] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_engine(database_url: str, *, sqlite_check_same_thread: bool) -> AsyncEngine:
|
||||||
|
kwargs: dict[str, Any] = {"echo": False, "pool_pre_ping": True}
|
||||||
|
if database_url.startswith("sqlite"):
|
||||||
|
kwargs["connect_args"] = {"check_same_thread": sqlite_check_same_thread}
|
||||||
|
if ":memory:" in database_url:
|
||||||
|
kwargs["poolclass"] = StaticPool
|
||||||
|
|
||||||
|
return create_async_engine(database_url, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine(database_url: str, *, sqlite_check_same_thread: bool = False) -> AsyncEngine:
|
||||||
|
"""Return the process-wide engine for ``database_url``, creating it on first use.
|
||||||
|
|
||||||
|
Engines are registered per URL so that disposing one leaves every other
|
||||||
|
database untouched.
|
||||||
|
"""
|
||||||
|
engine = _ENGINES.get(database_url)
|
||||||
|
if engine is None:
|
||||||
|
engine = _create_engine(database_url, sqlite_check_same_thread=sqlite_check_same_thread)
|
||||||
|
_ENGINES[database_url] = engine
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
async def dispose_engine(database_url: str) -> None:
|
||||||
|
"""Dispose and unregister the engine for ``database_url`` only.
|
||||||
|
|
||||||
|
Unknown URLs are a no-op rather than provoking the creation of an engine
|
||||||
|
purely so that it can be thrown away.
|
||||||
|
"""
|
||||||
|
engine = _ENGINES.pop(database_url, None)
|
||||||
|
if engine is not None:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def dispose_all_engines() -> None:
|
||||||
|
while _ENGINES:
|
||||||
|
_, engine = _ENGINES.popitem()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_engine(database_url: str) -> AsyncEngine:
|
||||||
|
await dispose_engine(database_url)
|
||||||
|
return get_engine(database_url)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Typed loader-option wrappers for SQLModel relationship attributes.
|
||||||
|
|
||||||
|
SQLModel declares relationships with their runtime Python type, so
|
||||||
|
``Document.jobs`` is annotated ``list[Job]`` even though at runtime it is an
|
||||||
|
``InstrumentedAttribute``. SQLAlchemy's loader options are typed against
|
||||||
|
``QueryableAttribute``, so every eager-load call site reads as a type error to a
|
||||||
|
static checker even though the code is correct.
|
||||||
|
|
||||||
|
These wrappers put that reinterpretation in one documented place instead of
|
||||||
|
scattering a suppression comment across every eager-load call. Import
|
||||||
|
``selectinload`` and ``defer`` from here rather than from ``sqlalchemy.orm``.
|
||||||
|
|
||||||
|
Multi-level eager loads must keep using the chained form --
|
||||||
|
``selectinload(A.b).selectinload(orm_attribute(B.c))`` -- and not the varargs
|
||||||
|
form ``selectinload(A.b, B.c)``. The two produce the same loader path, but
|
||||||
|
varargs applies the selectin strategy only to the last element while the
|
||||||
|
intermediate falls back to its default strategy. Every relationship here
|
||||||
|
declares ``lazy="raise"``, so the varargs form raises at render time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from sqlalchemy.orm import defer as _defer
|
||||||
|
from sqlalchemy.orm import selectinload as _selectinload
|
||||||
|
from sqlalchemy.orm.attributes import QueryableAttribute
|
||||||
|
from sqlalchemy.orm.strategy_options import _AbstractLoad
|
||||||
|
|
||||||
|
|
||||||
|
def orm_attribute(attribute: object) -> QueryableAttribute[Any]:
|
||||||
|
"""Reinterpret a SQLModel relationship or field as its ORM descriptor."""
|
||||||
|
return cast("QueryableAttribute[Any]", attribute)
|
||||||
|
|
||||||
|
|
||||||
|
def selectinload(*keys: object) -> _AbstractLoad:
|
||||||
|
"""``sqlalchemy.orm.selectinload`` accepting SQLModel-annotated attributes."""
|
||||||
|
return _selectinload(*(orm_attribute(key) for key in keys))
|
||||||
|
|
||||||
|
|
||||||
|
def defer(*keys: object, raiseload: bool = False) -> _AbstractLoad:
|
||||||
|
"""``sqlalchemy.orm.defer`` accepting SQLModel-annotated attributes."""
|
||||||
|
first, *rest = (orm_attribute(key) for key in keys)
|
||||||
|
return _defer(first, *rest, raiseload=raiseload)
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import URL
|
||||||
|
from sqlalchemy import MetaData
|
||||||
|
from sqlalchemy import Table
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.config import get_settings
|
||||||
|
|
||||||
|
# Register table metadata.
|
||||||
|
from transcription.db import models as _models # noqa: F401
|
||||||
|
from transcription.db.engine import get_database_url
|
||||||
|
|
||||||
|
EXPORT_TABLE_ORDER = (
|
||||||
|
"document_type",
|
||||||
|
"person_role",
|
||||||
|
"tag",
|
||||||
|
"document",
|
||||||
|
"person",
|
||||||
|
"photo",
|
||||||
|
"document_person",
|
||||||
|
"document_tag",
|
||||||
|
"job",
|
||||||
|
"source",
|
||||||
|
"job_source",
|
||||||
|
"execution_attempt",
|
||||||
|
)
|
||||||
|
|
||||||
|
BYTES_FIELDS = {"transport_body"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MigrationPaths:
|
||||||
|
source_db_url: str
|
||||||
|
target_db_url: str
|
||||||
|
source_upload_dir: Path
|
||||||
|
target_upload_dir: Path
|
||||||
|
bundle_dir: Path
|
||||||
|
|
||||||
|
|
||||||
|
def export_bundle(*, source_db_url: str, source_upload_dir: Path, bundle_dir: Path) -> None:
|
||||||
|
bundle_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
export_json = bundle_dir / "database.json"
|
||||||
|
uploads_bundle_dir = bundle_dir / "uploads"
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"schema_name": "transcription.export-import",
|
||||||
|
"schema_version": "1",
|
||||||
|
"created_at": datetime.now().isoformat(),
|
||||||
|
"tables": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
engine = create_engine(source_db_url)
|
||||||
|
legacy_portrait_rows: list[dict[str, Any]] = []
|
||||||
|
source_has_photo_table = False
|
||||||
|
try:
|
||||||
|
inspector = sqlalchemy_inspect(engine)
|
||||||
|
source_tables = set(inspector.get_table_names())
|
||||||
|
source_has_photo_table = "photo" in source_tables
|
||||||
|
metadata = MetaData()
|
||||||
|
metadata.reflect(bind=engine)
|
||||||
|
current_metadata = SQLModel.metadata
|
||||||
|
|
||||||
|
with engine.connect() as connection:
|
||||||
|
for table_name in EXPORT_TABLE_ORDER:
|
||||||
|
if table_name not in source_tables:
|
||||||
|
payload["tables"][table_name] = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_table = metadata.tables[table_name]
|
||||||
|
target_table = current_metadata.tables[table_name]
|
||||||
|
export_columns = [
|
||||||
|
column.name for column in target_table.columns if column.name in source_table.columns
|
||||||
|
]
|
||||||
|
if table_name == "person" and "portrait_path" in source_table.columns:
|
||||||
|
legacy_portrait_rows = connection.execute(
|
||||||
|
select(source_table.c["id"], source_table.c["portrait_path"]).where(
|
||||||
|
source_table.c["portrait_path"].is_not(None)
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
rows = connection.execute(select(*(source_table.c[name] for name in export_columns))).mappings().all()
|
||||||
|
payload["tables"][table_name] = [
|
||||||
|
_serialize_row(row, table_name=table_name, source_upload_dir=source_upload_dir) for row in rows
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
if uploads_bundle_dir.exists():
|
||||||
|
shutil.rmtree(uploads_bundle_dir)
|
||||||
|
if source_upload_dir.exists():
|
||||||
|
shutil.copytree(source_upload_dir, uploads_bundle_dir)
|
||||||
|
else:
|
||||||
|
uploads_bundle_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
_prepare_photo_payload_and_uploads(
|
||||||
|
payload=payload,
|
||||||
|
uploads_bundle_dir=uploads_bundle_dir,
|
||||||
|
source_has_photo_table=source_has_photo_table,
|
||||||
|
legacy_portrait_rows=legacy_portrait_rows,
|
||||||
|
)
|
||||||
|
_relocate_homepage_markdown(uploads_bundle_dir=uploads_bundle_dir)
|
||||||
|
|
||||||
|
export_json.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def import_bundle(*, target_db_url: str, target_upload_dir: Path, bundle_dir: Path) -> None:
|
||||||
|
export_json = bundle_dir / "database.json"
|
||||||
|
uploads_bundle_dir = bundle_dir / "uploads"
|
||||||
|
payload = json.loads(export_json.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
engine = create_engine(target_db_url)
|
||||||
|
try:
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
with engine.begin() as connection:
|
||||||
|
for table_name in reversed(EXPORT_TABLE_ORDER):
|
||||||
|
table = SQLModel.metadata.tables[table_name]
|
||||||
|
connection.execute(table.delete())
|
||||||
|
|
||||||
|
for table_name in EXPORT_TABLE_ORDER:
|
||||||
|
rows = payload.get("tables", {}).get(table_name, [])
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
table = SQLModel.metadata.tables[table_name]
|
||||||
|
connection.execute(table.insert(), [_deserialize_row(row, table) for row in rows])
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
if target_upload_dir.exists():
|
||||||
|
shutil.rmtree(target_upload_dir)
|
||||||
|
target_upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
if uploads_bundle_dir.exists():
|
||||||
|
shutil.copytree(uploads_bundle_dir, target_upload_dir, dirs_exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_via_bundle(paths: MigrationPaths) -> None:
|
||||||
|
export_bundle(
|
||||||
|
source_db_url=paths.source_db_url,
|
||||||
|
source_upload_dir=paths.source_upload_dir,
|
||||||
|
bundle_dir=paths.bundle_dir,
|
||||||
|
)
|
||||||
|
import_bundle(
|
||||||
|
target_db_url=paths.target_db_url,
|
||||||
|
target_upload_dir=paths.target_upload_dir,
|
||||||
|
bundle_dir=paths.bundle_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sqlite_url_from_path(path: Path) -> str:
|
||||||
|
return URL.create(drivername="sqlite", database=str(path)).render_as_string(hide_password=False)
|
||||||
|
|
||||||
|
|
||||||
|
def default_sync_db_url(settings: Settings | None = None) -> str:
|
||||||
|
runtime_settings = settings or get_settings()
|
||||||
|
return get_database_url(runtime_settings).replace("+aiosqlite", "").replace("+asyncpg", "").replace("+psycopg", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_row(row: dict[str, Any], *, table_name: str, source_upload_dir: Path) -> dict[str, Any]:
|
||||||
|
serialized: dict[str, Any] = {}
|
||||||
|
for key, value in row.items():
|
||||||
|
serialized_value = _serialize_value(key, value)
|
||||||
|
if table_name == "source" and key == "file_path" and isinstance(serialized_value, str):
|
||||||
|
serialized[key] = _canonical_media_relative_path(
|
||||||
|
serialized_value,
|
||||||
|
source_upload_dir=source_upload_dir,
|
||||||
|
preferred_prefix="documents/",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if table_name == "photo" and key == "path" and isinstance(serialized_value, str):
|
||||||
|
serialized[key] = _canonical_media_relative_path(
|
||||||
|
serialized_value,
|
||||||
|
source_upload_dir=source_upload_dir,
|
||||||
|
preferred_prefix="photos/",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
serialized[key] = serialized_value
|
||||||
|
return serialized
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_value(key: str, value: Any) -> Any:
|
||||||
|
if isinstance(value, UUID):
|
||||||
|
return str(value)
|
||||||
|
if isinstance(value, (datetime, date)):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return {"encoding": "base64", "data": base64.b64encode(value).decode("ascii")}
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(k): _serialize_value("", v) for k, v in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_serialize_value("", item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _deserialize_row(row: dict[str, Any], table: Table) -> dict[str, Any]:
|
||||||
|
deserialized: dict[str, Any] = {}
|
||||||
|
for key, value in row.items():
|
||||||
|
if key in BYTES_FIELDS and isinstance(value, dict) and value.get("encoding") == "base64":
|
||||||
|
deserialized[key] = base64.b64decode(value["data"])
|
||||||
|
continue
|
||||||
|
if key in table.columns:
|
||||||
|
try:
|
||||||
|
python_type: type[Any] = table.columns[key].type.python_type
|
||||||
|
except NotImplementedError:
|
||||||
|
deserialized[key] = value
|
||||||
|
continue
|
||||||
|
deserialized[key] = _deserialize_value(python_type, value)
|
||||||
|
return deserialized
|
||||||
|
|
||||||
|
|
||||||
|
def _deserialize_value(python_type: type[Any], value: Any) -> Any:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if python_type is UUID and isinstance(value, str):
|
||||||
|
return UUID(value)
|
||||||
|
if python_type is datetime and isinstance(value, str):
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
if python_type is date and isinstance(value, str):
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_media_relative_path(value: str, *, source_upload_dir: Path, preferred_prefix: str) -> str:
|
||||||
|
normalized = value.strip().replace("\\", "/")
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
upload_root = source_upload_dir.resolve().as_posix().casefold().rstrip("/")
|
||||||
|
if lowered.startswith(upload_root + "/"):
|
||||||
|
normalized = normalized[len(source_upload_dir.resolve().as_posix()) + 1 :]
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
|
||||||
|
if lowered.startswith("/uploads/"):
|
||||||
|
normalized = normalized[len("/uploads/") :]
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
elif lowered.startswith("uploads/"):
|
||||||
|
normalized = normalized[len("uploads/") :]
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
elif lowered.startswith("data/"):
|
||||||
|
normalized = normalized[len("data/") :]
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
|
||||||
|
if preferred_prefix == "persons/" and lowered.startswith("portraits/"):
|
||||||
|
normalized = "persons/" + normalized[len("portraits/") :]
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
|
||||||
|
for prefix in ("documents/", "photos/", "persons/"):
|
||||||
|
marker = f"/{prefix}"
|
||||||
|
index = lowered.find(marker)
|
||||||
|
if index >= 0:
|
||||||
|
normalized = normalized[index + 1 :]
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
break
|
||||||
|
|
||||||
|
if not lowered.startswith(preferred_prefix):
|
||||||
|
return normalized
|
||||||
|
return Path(normalized).as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_photo_payload_and_uploads(
|
||||||
|
*,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
uploads_bundle_dir: Path,
|
||||||
|
source_has_photo_table: bool,
|
||||||
|
legacy_portrait_rows: list[dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
photo_rows = payload.setdefault("tables", {}).setdefault("photo", [])
|
||||||
|
photos_dir = uploads_bundle_dir / "photos"
|
||||||
|
photos_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if source_has_photo_table:
|
||||||
|
return
|
||||||
|
|
||||||
|
now_iso = datetime.now().isoformat()
|
||||||
|
for row in legacy_portrait_rows:
|
||||||
|
portrait_path = row.get("portrait_path")
|
||||||
|
person_id = row.get("id")
|
||||||
|
if not isinstance(portrait_path, str) or not portrait_path.strip():
|
||||||
|
continue
|
||||||
|
if person_id is None:
|
||||||
|
continue
|
||||||
|
canonical = _canonical_media_relative_path(
|
||||||
|
portrait_path,
|
||||||
|
source_upload_dir=uploads_bundle_dir,
|
||||||
|
preferred_prefix="persons/",
|
||||||
|
)
|
||||||
|
source_file = uploads_bundle_dir / canonical
|
||||||
|
suffix = Path(canonical).suffix.lower() or ".jpg"
|
||||||
|
photo_id = str(uuid4())
|
||||||
|
relative_path = f"photos/{photo_id}{suffix}"
|
||||||
|
if source_file.exists():
|
||||||
|
target_file = uploads_bundle_dir / relative_path
|
||||||
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(source_file, target_file)
|
||||||
|
else:
|
||||||
|
relative_path = canonical
|
||||||
|
photo_rows.append(
|
||||||
|
{
|
||||||
|
"id": photo_id,
|
||||||
|
"person_id": str(person_id),
|
||||||
|
"path": relative_path,
|
||||||
|
"description": None,
|
||||||
|
"is_primary": True,
|
||||||
|
"created_at": now_iso,
|
||||||
|
"updated_at": now_iso,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
legacy_homepage_dir = uploads_bundle_dir / "homepage"
|
||||||
|
if not legacy_homepage_dir.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
homepage_images = sorted(
|
||||||
|
[
|
||||||
|
path
|
||||||
|
for path in legacy_homepage_dir.iterdir()
|
||||||
|
if path.is_file() and path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
|
||||||
|
],
|
||||||
|
key=lambda path: (path.stat().st_mtime, path.name),
|
||||||
|
)
|
||||||
|
for index, image_path in enumerate(homepage_images):
|
||||||
|
photo_id = str(uuid4())
|
||||||
|
relative_path = f"photos/{photo_id}{image_path.suffix.lower()}"
|
||||||
|
target_file = uploads_bundle_dir / relative_path
|
||||||
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(image_path, target_file)
|
||||||
|
photo_rows.append(
|
||||||
|
{
|
||||||
|
"id": photo_id,
|
||||||
|
"person_id": None,
|
||||||
|
"path": relative_path,
|
||||||
|
"description": None,
|
||||||
|
"is_primary": index == 0,
|
||||||
|
"created_at": now_iso,
|
||||||
|
"updated_at": now_iso,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _relocate_homepage_markdown(*, uploads_bundle_dir: Path) -> None:
|
||||||
|
legacy_markdown = uploads_bundle_dir / "homepage" / "homepage.md"
|
||||||
|
target_markdown = uploads_bundle_dir / "homepage.md"
|
||||||
|
if not legacy_markdown.exists() or target_markdown.exists():
|
||||||
|
return
|
||||||
|
target_markdown.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(legacy_markdown, target_markdown)
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
"""SQLModel domain models for the V3 transcription system."""
|
||||||
|
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import date
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from pydantic import JsonValue
|
||||||
|
from sqlalchemy import JSON
|
||||||
|
from sqlalchemy import BigInteger
|
||||||
|
from sqlalchemy import Column
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy import ForeignKey
|
||||||
|
from sqlalchemy import Index
|
||||||
|
from sqlalchemy import LargeBinary
|
||||||
|
from sqlalchemy import UniqueConstraint
|
||||||
|
from sqlalchemy import Uuid
|
||||||
|
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.exc import NoInspectionAvailable
|
||||||
|
from sqlalchemy.orm.state import InstanceState
|
||||||
|
from sqlalchemy.types import TypeDecorator
|
||||||
|
from sqlmodel import Field
|
||||||
|
from sqlmodel import Relationship
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
def _loaded_attribute(instance: object, attribute: str) -> Any | None:
|
||||||
|
"""Return ``attribute`` only when it is already loaded on ``instance``.
|
||||||
|
|
||||||
|
Relationships in this module declare ``lazy="raise"``, so reading an
|
||||||
|
unloaded attribute is an error rather than a silent query. Callers that
|
||||||
|
render optional detail use this to distinguish "not loaded" from "absent"
|
||||||
|
without catching exceptions indiscriminately.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
state: InstanceState[Any] = sqlalchemy_inspect(instance, raiseerr=True)
|
||||||
|
except NoInspectionAvailable:
|
||||||
|
return None
|
||||||
|
if attribute in state.unloaded:
|
||||||
|
return None
|
||||||
|
return state.dict.get(attribute)
|
||||||
|
|
||||||
|
|
||||||
|
class JSONBCompat(TypeDecorator):
|
||||||
|
"""JSONB for PostgreSQL and JSON for SQLite/testing backends."""
|
||||||
|
|
||||||
|
impl = JSON(none_as_null=True)
|
||||||
|
|
||||||
|
def load_dialect_impl(self, dialect):
|
||||||
|
if dialect.name == "postgresql":
|
||||||
|
return dialect.type_descriptor(JSONB(none_as_null=True))
|
||||||
|
return dialect.type_descriptor(JSON(none_as_null=True))
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(StrEnum):
|
||||||
|
QUEUED = "queued"
|
||||||
|
PROCESSING = "processing"
|
||||||
|
TRANSCRIBED = "transcribed"
|
||||||
|
PARTIAL_SUCCESS = "partial_success"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class JobSourceStatus(StrEnum):
|
||||||
|
PENDING = "pending"
|
||||||
|
TRANSCRIBED = "transcribed"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class JobPurpose(StrEnum):
|
||||||
|
TRANSCRIPTION = "transcription"
|
||||||
|
RETRANSCRIPTION = "retranscription"
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentType(SQLModel, table=True):
|
||||||
|
"""Registry of allowed document types."""
|
||||||
|
|
||||||
|
__tablename__ = "document_type"
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
semantic_key: str | None = Field(default=None, index=True, unique=True)
|
||||||
|
label: str
|
||||||
|
normalized_label: str = Field(index=True, unique=True)
|
||||||
|
is_active: bool = True
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(UTC),
|
||||||
|
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
||||||
|
)
|
||||||
|
|
||||||
|
documents: list["Document"] = Relationship(
|
||||||
|
back_populates="document_type_ref", sa_relationship_kwargs={"lazy": "raise"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PersonRole(SQLModel, table=True):
|
||||||
|
"""Registry of allowed document-person relationship roles."""
|
||||||
|
|
||||||
|
__tablename__ = "person_role"
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
semantic_key: str | None = Field(default=None, index=True, unique=True)
|
||||||
|
label: str
|
||||||
|
normalized_label: str = Field(index=True, unique=True)
|
||||||
|
is_active: bool = True
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(UTC),
|
||||||
|
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
||||||
|
)
|
||||||
|
|
||||||
|
document_people: list["DocumentPerson"] = Relationship(
|
||||||
|
back_populates="role_ref", sa_relationship_kwargs={"lazy": "raise"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(SQLModel, table=True):
|
||||||
|
"""Registry of labels that can be attached to Documents."""
|
||||||
|
|
||||||
|
__tablename__ = "tag"
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
semantic_key: str | None = Field(default=None, index=True, unique=True)
|
||||||
|
label: str
|
||||||
|
normalized_label: str = Field(index=True, unique=True)
|
||||||
|
is_active: bool = True
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(UTC),
|
||||||
|
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
||||||
|
)
|
||||||
|
|
||||||
|
document_tags: list["DocumentTag"] = Relationship(
|
||||||
|
back_populates="tag_ref",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Document(SQLModel, table=True):
|
||||||
|
"""An historical document."""
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
name: str
|
||||||
|
document_type_id: UUID | None = Field(default=None, foreign_key="document_type.id", index=True)
|
||||||
|
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),
|
||||||
|
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
||||||
|
)
|
||||||
|
|
||||||
|
jobs: list["Job"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "raise"})
|
||||||
|
sources: list["Source"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "raise"})
|
||||||
|
document_people: list["DocumentPerson"] = Relationship(
|
||||||
|
back_populates="document", sa_relationship_kwargs={"lazy": "raise"}
|
||||||
|
)
|
||||||
|
document_tags: list["DocumentTag"] = Relationship(
|
||||||
|
back_populates="document",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
document_type_ref: Optional["DocumentType"] = Relationship(
|
||||||
|
back_populates="documents", sa_relationship_kwargs={"lazy": "raise"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
family_search_id: str | None = Field(default=None, unique=True)
|
||||||
|
metadata_: dict[str, JsonValue] | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column("metadata", JSONBCompat(), nullable=True),
|
||||||
|
)
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(UTC),
|
||||||
|
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
||||||
|
)
|
||||||
|
|
||||||
|
document_people: list["DocumentPerson"] = Relationship(
|
||||||
|
back_populates="person", sa_relationship_kwargs={"lazy": "raise"}
|
||||||
|
)
|
||||||
|
photos: list["Photo"] = Relationship(
|
||||||
|
back_populates="person",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Photo(SQLModel, table=True):
|
||||||
|
"""A reusable image record for Person and homepage galleries."""
|
||||||
|
|
||||||
|
__tablename__ = "photo"
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
person_id: UUID | None = Field(default=None, foreign_key="person.id", index=True)
|
||||||
|
path: str
|
||||||
|
description: str | None = None
|
||||||
|
is_primary: bool = False
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(UTC),
|
||||||
|
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
||||||
|
)
|
||||||
|
|
||||||
|
person: Optional["Person"] = Relationship(
|
||||||
|
back_populates="photos",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPerson(SQLModel, table=True):
|
||||||
|
"""Associates documents with people in a given role."""
|
||||||
|
|
||||||
|
__tablename__ = "document_person"
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
document_id: UUID = Field(foreign_key="document.id", index=True)
|
||||||
|
person_id: UUID = Field(foreign_key="person.id", index=True)
|
||||||
|
role_id: UUID = Field(foreign_key="person_role.id", index=True)
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(UTC),
|
||||||
|
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("document_id", "person_id", name="uq_document_person"),)
|
||||||
|
|
||||||
|
document: Optional["Document"] = Relationship(
|
||||||
|
back_populates="document_people", sa_relationship_kwargs={"lazy": "raise"}
|
||||||
|
)
|
||||||
|
person: Optional["Person"] = Relationship(
|
||||||
|
back_populates="document_people", sa_relationship_kwargs={"lazy": "raise"}
|
||||||
|
)
|
||||||
|
role_ref: Optional["PersonRole"] = Relationship(
|
||||||
|
back_populates="document_people", sa_relationship_kwargs={"lazy": "raise"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentTag(SQLModel, table=True):
|
||||||
|
"""Associates Documents with Tags."""
|
||||||
|
|
||||||
|
__tablename__ = "document_tag"
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
document_id: UUID = Field(foreign_key="document.id", index=True)
|
||||||
|
tag_id: UUID = Field(foreign_key="tag.id", index=True)
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(UTC),
|
||||||
|
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("document_id", "tag_id", name="uq_document_tag"),)
|
||||||
|
|
||||||
|
document: Optional["Document"] = Relationship(
|
||||||
|
back_populates="document_tags",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
tag_ref: Optional["Tag"] = Relationship(
|
||||||
|
back_populates="document_tags",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Job(SQLModel, table=True):
|
||||||
|
"""A transcription job tied to a single document."""
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_job_status_date_created", "status", "date_created"),)
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
document_id: UUID = Field(foreign_key="document.id", index=True)
|
||||||
|
status: JobStatus = Field(
|
||||||
|
default=JobStatus.QUEUED,
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(
|
||||||
|
JobStatus,
|
||||||
|
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||||
|
native_enum=False,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
retry_count: int = Field(default=0, ge=0)
|
||||||
|
purpose: JobPurpose = Field(
|
||||||
|
default=JobPurpose.TRANSCRIPTION,
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(
|
||||||
|
JobPurpose,
|
||||||
|
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||||
|
native_enum=False,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
default=JobPurpose.TRANSCRIPTION.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
date_created: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
date_updated: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(UTC),
|
||||||
|
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
||||||
|
)
|
||||||
|
provider: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
prompt_name: str | None = None
|
||||||
|
prompt_hash: str | None = None
|
||||||
|
system_prompt: str | None = None
|
||||||
|
user_prompt: str | None = None
|
||||||
|
temperature: float | None = None
|
||||||
|
top_p: float | None = None
|
||||||
|
|
||||||
|
document: Optional["Document"] = Relationship(back_populates="jobs", sa_relationship_kwargs={"lazy": "raise"})
|
||||||
|
job_sources: list["JobSource"] = Relationship(back_populates="job", sa_relationship_kwargs={"lazy": "raise"})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filename(self) -> str:
|
||||||
|
"""Return the filename of the first loaded source, when available.
|
||||||
|
|
||||||
|
Relationships on this model use ``lazy="raise"``, so this deliberately
|
||||||
|
inspects load state rather than triggering (or swallowing) a lazy load:
|
||||||
|
a read model that did not eager-load its sources gets "unknown" instead
|
||||||
|
of an unhandled error, and genuine errors are no longer hidden.
|
||||||
|
"""
|
||||||
|
for job_source in _loaded_attribute(self, "job_sources") or ():
|
||||||
|
source = _loaded_attribute(job_source, "source")
|
||||||
|
if source is not None:
|
||||||
|
return source.filename
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
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", index=True)
|
||||||
|
page_number: int = Field(default=1, ge=1)
|
||||||
|
upload_name: str
|
||||||
|
filename: str
|
||||||
|
file_path: str
|
||||||
|
file_hash: str
|
||||||
|
file_size_bytes: int = Field(sa_column=Column(BigInteger(), nullable=False))
|
||||||
|
raw_transcription: str | None = None
|
||||||
|
preferred_execution_attempt_id: UUID | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(
|
||||||
|
Uuid(),
|
||||||
|
# use_alter breaks the source / job_source / execution_attempt cycle so
|
||||||
|
# metadata.create_all can order table creation on every dialect.
|
||||||
|
ForeignKey(
|
||||||
|
"execution_attempt.id",
|
||||||
|
use_alter=True,
|
||||||
|
name="fk_source_preferred_execution_attempt_id",
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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": "raise"},
|
||||||
|
)
|
||||||
|
job_sources: list["JobSource"] = Relationship(
|
||||||
|
back_populates="source",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latest_job_source(self) -> Optional["JobSource"]:
|
||||||
|
"""Return the most recent job execution record for this source.
|
||||||
|
|
||||||
|
``JobSource`` carries no timestamp of its own, so recency is the parent
|
||||||
|
job's creation time. ``(job_id, source_id)`` is unique per source, so
|
||||||
|
this is exactly "the most recent job that included this page".
|
||||||
|
"""
|
||||||
|
job_sources = _loaded_attribute(self, "job_sources") or ()
|
||||||
|
dated = [
|
||||||
|
(job, job_source)
|
||||||
|
for job_source in job_sources
|
||||||
|
if (job := _loaded_attribute(job_source, "job")) is not None
|
||||||
|
]
|
||||||
|
if dated:
|
||||||
|
return max(dated, key=lambda pair: pair[0].date_created)[1]
|
||||||
|
return job_sources[0] if job_sources else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latest_status(self) -> JobSourceStatus | None:
|
||||||
|
"""Return the execution status of the latest job run."""
|
||||||
|
latest = self.latest_job_source
|
||||||
|
return latest.status if latest else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latest_error_detail(self) -> str | None:
|
||||||
|
"""Return the error detail of the latest attempt on the latest job run.
|
||||||
|
|
||||||
|
Failure detail lives on ``ExecutionAttempt``; ``JobSource`` records only
|
||||||
|
which page a job is working on and how far it got.
|
||||||
|
"""
|
||||||
|
latest = self.latest_job_source
|
||||||
|
if latest is None:
|
||||||
|
return None
|
||||||
|
attempts = _loaded_attribute(latest, "execution_attempts") or ()
|
||||||
|
if not attempts:
|
||||||
|
return None
|
||||||
|
latest_attempt = max(attempts, key=lambda item: item.attempt_number)
|
||||||
|
return latest_attempt.error_detail
|
||||||
|
|
||||||
|
@property
|
||||||
|
def document_name(self) -> str | None:
|
||||||
|
"""Return the parent document name if loaded."""
|
||||||
|
return self.document.name if self.document else None
|
||||||
|
|
||||||
|
|
||||||
|
class JobSource(SQLModel, table=True):
|
||||||
|
"""A single AI execution record for one source page."""
|
||||||
|
|
||||||
|
__tablename__ = "job_source"
|
||||||
|
__table_args__ = (UniqueConstraint("job_id", "source_id", name="uq_job_source_job_source"),)
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
job_id: UUID = Field(foreign_key="job.id", index=True)
|
||||||
|
source_id: UUID = Field(foreign_key="source.id", index=True)
|
||||||
|
status: JobSourceStatus = Field(
|
||||||
|
default=JobSourceStatus.PENDING,
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(
|
||||||
|
JobSourceStatus,
|
||||||
|
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||||
|
native_enum=False,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
job: Optional["Job"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "raise"})
|
||||||
|
source: Optional["Source"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "raise"})
|
||||||
|
execution_attempts: list["ExecutionAttempt"] = Relationship(
|
||||||
|
back_populates="job_source",
|
||||||
|
sa_relationship_kwargs={"lazy": "noload", "order_by": "ExecutionAttempt.attempt_number"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionAttempt(SQLModel, table=True):
|
||||||
|
"""Immutable evidence for one provider call attempt."""
|
||||||
|
|
||||||
|
__tablename__ = "execution_attempt"
|
||||||
|
__table_args__ = (UniqueConstraint("job_id", "source_id", "attempt_number", name="uq_execution_attempt_number"),)
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
job_source_id: UUID = Field(foreign_key="job_source.id", index=True)
|
||||||
|
job_id: UUID = Field(foreign_key="job.id", index=True)
|
||||||
|
source_id: UUID = Field(foreign_key="source.id", index=True)
|
||||||
|
attempt_number: int = Field(ge=1)
|
||||||
|
status: JobSourceStatus = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
# Declared identically to job_source.status. Without values_callable
|
||||||
|
# SQLAlchemy persists enum *names*, which is defect [45]: the two
|
||||||
|
# columns spelled the same status differently and never compared equal.
|
||||||
|
SAEnum(
|
||||||
|
JobSourceStatus,
|
||||||
|
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||||
|
native_enum=False,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider: str
|
||||||
|
model: str | None = None
|
||||||
|
request_manifest: dict[str, JsonValue] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
||||||
|
request_manifest_sha256: str | None = None
|
||||||
|
request_manifest_schema_version: str | None = None
|
||||||
|
response_received: bool = False
|
||||||
|
transport_status_code: int | None = None
|
||||||
|
transport_body: bytes | None = Field(default=None, sa_column=Column(LargeBinary(), nullable=True))
|
||||||
|
transport_content_type: str | None = None
|
||||||
|
transport_content_encoding: str | None = None
|
||||||
|
transport_safe_headers: dict[str, JsonValue] | None = Field(
|
||||||
|
default=None, sa_column=Column(JSONBCompat(), nullable=True)
|
||||||
|
)
|
||||||
|
router_request_id: str | None = None
|
||||||
|
router_generation_id: str | None = None
|
||||||
|
sdk_response_snapshot: dict[str, JsonValue] | None = Field(
|
||||||
|
default=None, sa_column=Column(JSONBCompat(), nullable=True)
|
||||||
|
)
|
||||||
|
normalized_metadata: dict[str, JsonValue] | None = Field(
|
||||||
|
default=None, sa_column=Column(JSONBCompat(), nullable=True)
|
||||||
|
)
|
||||||
|
software_context: dict[str, JsonValue] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
||||||
|
raw_transcription: str | None = None
|
||||||
|
error_category: str | None = None
|
||||||
|
error_detail: str | None = None
|
||||||
|
failure_phase: str | None = None
|
||||||
|
started_at: datetime
|
||||||
|
finished_at: datetime
|
||||||
|
duration_ms: int = Field(ge=0)
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
job_source: Optional["JobSource"] = Relationship(
|
||||||
|
back_populates="execution_attempts", sa_relationship_kwargs={"lazy": "raise"}
|
||||||
|
)
|
||||||
@@ -1,65 +1,188 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.engine import Connection
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from ..models import Job
|
from .engine import resolve_engine
|
||||||
from ..models import JobStatus
|
from .models import DocumentType
|
||||||
from .runtime import get_engine
|
from .models import PersonRole
|
||||||
|
from .registries import BUILT_IN_DOCUMENT_TYPES
|
||||||
|
from .registries import BUILT_IN_PERSON_ROLES
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
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.created_at) # pyright: ignore[reportArgumentType]
|
|
||||||
.limit(1)
|
|
||||||
) # fmt: skip
|
|
||||||
return result.first()
|
|
||||||
|
|
||||||
|
|
||||||
async def create_all(*, engine: AsyncEngine | None = None) -> None:
|
async def create_all(*, engine: AsyncEngine | None = None) -> None:
|
||||||
"""Create all tables on the selected engine."""
|
"""Create any missing tables on the selected engine."""
|
||||||
# Import models so SQLModel metadata is fully registered before bootstrap.
|
# Import models so SQLModel metadata is fully registered before bootstrap.
|
||||||
from transcription import models as _models # noqa: F401
|
from transcription.db import models as _models # noqa: F401
|
||||||
|
|
||||||
active_engine = engine or get_engine()
|
active_engine = engine or resolve_engine()
|
||||||
async with active_engine.begin() as connection:
|
async with active_engine.begin() as connection:
|
||||||
await connection.run_sync(SQLModel.metadata.create_all)
|
await connection.run_sync(SQLModel.metadata.create_all)
|
||||||
await connection.run_sync(_ensure_sqlite_compat_columns)
|
await seed_registry_defaults(engine=active_engine)
|
||||||
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
|
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_sqlite_compat_columns(connection: Connection) -> None:
|
async def reconcile_legacy_job_source_columns(*, engine: AsyncEngine | None = None) -> int:
|
||||||
"""Apply lightweight dev/test SQLite compatibility column patches.
|
"""Remove stale V4.6 ``job_source`` evidence columns from existing databases.
|
||||||
|
|
||||||
This keeps local bootstrap resilient when models evolve but no full
|
Runtime models define ``job_source`` as a queue/projection table only. If an
|
||||||
migration tooling is in place yet.
|
older database still carries the retired evidence columns, writes can fail
|
||||||
|
on stale constraints (for example ``executed_at NOT NULL``).
|
||||||
"""
|
"""
|
||||||
if connection.engine.url.get_backend_name() != "sqlite":
|
active_engine = engine or resolve_engine()
|
||||||
return
|
if not hasattr(active_engine, "begin"):
|
||||||
|
return 0
|
||||||
|
|
||||||
inspector = inspect(connection)
|
def _reconcile(sync_connection) -> int:
|
||||||
|
inspector = sqlalchemy_inspect(sync_connection)
|
||||||
table_names = set(inspector.get_table_names())
|
table_names = set(inspector.get_table_names())
|
||||||
|
if "job_source" not in table_names:
|
||||||
|
return 0
|
||||||
|
present_columns = {column["name"] for column in inspector.get_columns("job_source")}
|
||||||
|
dropped = 0
|
||||||
|
for column_name in (
|
||||||
|
"raw_transcription",
|
||||||
|
"ai_metadata",
|
||||||
|
"raw_api_response",
|
||||||
|
"error_detail",
|
||||||
|
"executed_at",
|
||||||
|
):
|
||||||
|
if column_name not in present_columns:
|
||||||
|
continue
|
||||||
|
sync_connection.execute(text(f'alter table "job_source" drop column "{column_name}"'))
|
||||||
|
dropped += 1
|
||||||
|
return dropped
|
||||||
|
|
||||||
if "job" in table_names:
|
async with active_engine.begin() as connection:
|
||||||
job_columns = {column["name"] for column in inspector.get_columns("job")}
|
dropped_columns = await connection.run_sync(_reconcile)
|
||||||
if "retry_count" not in job_columns:
|
if dropped_columns:
|
||||||
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
|
logger.warning("Dropped %s legacy job_source column(s) during startup reconciliation", dropped_columns)
|
||||||
logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0")
|
return dropped_columns
|
||||||
|
|
||||||
if "transcript" in table_names:
|
|
||||||
transcript_columns = {column["name"] for column in inspector.get_columns("transcript")}
|
async def reconcile_canonical_media_paths(*, engine: AsyncEngine | None = None) -> int:
|
||||||
if "model" not in transcript_columns:
|
"""Normalize stored media paths to upload-root-relative POSIX form."""
|
||||||
connection.execute(text("ALTER TABLE transcript ADD COLUMN model VARCHAR NOT NULL DEFAULT 'unknown'"))
|
active_engine = engine or resolve_engine()
|
||||||
logger.warning("Applied SQLite compatibility schema patch table=transcript column=model default=unknown")
|
if not hasattr(active_engine, "begin"):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _reconcile(sync_connection) -> int:
|
||||||
|
rows_changed = 0
|
||||||
|
inspector = sqlalchemy_inspect(sync_connection)
|
||||||
|
table_names = set(inspector.get_table_names())
|
||||||
|
if "source" in table_names:
|
||||||
|
rows = sync_connection.execute(text('select id, file_path from "source" where file_path is not null')).mappings().all()
|
||||||
|
for row in rows:
|
||||||
|
original = str(row["file_path"])
|
||||||
|
normalized = _canonical_relative_path(original, preferred_prefix="documents/")
|
||||||
|
if normalized is None or normalized == original:
|
||||||
|
continue
|
||||||
|
sync_connection.execute(
|
||||||
|
text('update "source" set file_path = :file_path where id = :id'),
|
||||||
|
{"id": row["id"], "file_path": normalized},
|
||||||
|
)
|
||||||
|
rows_changed += 1
|
||||||
|
|
||||||
|
if "photo" in table_names:
|
||||||
|
rows = sync_connection.execute(text('select id, path from "photo" where path is not null')).mappings().all()
|
||||||
|
for row in rows:
|
||||||
|
original = str(row["path"])
|
||||||
|
normalized = _canonical_relative_path(original, preferred_prefix="photos/")
|
||||||
|
if normalized is None or normalized == original:
|
||||||
|
continue
|
||||||
|
sync_connection.execute(
|
||||||
|
text('update "photo" set path = :path where id = :id'),
|
||||||
|
{"id": row["id"], "path": normalized},
|
||||||
|
)
|
||||||
|
rows_changed += 1
|
||||||
|
return rows_changed
|
||||||
|
|
||||||
|
async with active_engine.begin() as connection:
|
||||||
|
rows_changed = await connection.run_sync(_reconcile)
|
||||||
|
if rows_changed:
|
||||||
|
logger.warning("Normalized %s media-path row(s) to canonical relative format", rows_changed)
|
||||||
|
return rows_changed
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_relative_path(value: str, *, preferred_prefix: str) -> str | None:
|
||||||
|
normalized = value.strip().replace("\\", "/")
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
if lowered.startswith("http://") or lowered.startswith("https://") or lowered.startswith("data:"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if lowered.startswith("/uploads/"):
|
||||||
|
normalized = normalized[len("/uploads/") :]
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
elif lowered.startswith("uploads/"):
|
||||||
|
normalized = normalized[len("uploads/") :]
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
elif lowered.startswith("data/"):
|
||||||
|
normalized = normalized[len("data/") :]
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
|
||||||
|
for prefix in ("documents/", "photos/", "persons/", "portraits/"):
|
||||||
|
marker = f"/{prefix}"
|
||||||
|
index = lowered.find(marker)
|
||||||
|
if index >= 0:
|
||||||
|
normalized = normalized[index + 1 :]
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
break
|
||||||
|
if lowered.startswith(prefix):
|
||||||
|
break
|
||||||
|
|
||||||
|
if preferred_prefix == "persons/" and lowered.startswith("portraits/"):
|
||||||
|
normalized = "persons/" + normalized[len("portraits/") :]
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
|
||||||
|
if not lowered.startswith(preferred_prefix):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Collapse any accidental "." segments while preserving relative semantics.
|
||||||
|
collapsed = Path(normalized).as_posix()
|
||||||
|
if collapsed.startswith("../") or collapsed == "..":
|
||||||
|
return None
|
||||||
|
return collapsed
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_registry_defaults(*, engine: AsyncEngine | None = None) -> None:
|
||||||
|
"""Seed default registry rows for role and document type taxonomies."""
|
||||||
|
active_engine = engine or resolve_engine()
|
||||||
|
session_factory = async_sessionmaker(active_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
role_keys = set((await session.exec(select(PersonRole.semantic_key))).all())
|
||||||
|
for semantic_key, label in BUILT_IN_PERSON_ROLES:
|
||||||
|
if semantic_key not in role_keys:
|
||||||
|
session.add(
|
||||||
|
PersonRole(
|
||||||
|
semantic_key=semantic_key,
|
||||||
|
label=label,
|
||||||
|
normalized_label=label.casefold(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
type_keys = set((await session.exec(select(DocumentType.semantic_key))).all())
|
||||||
|
for semantic_key, label in BUILT_IN_DOCUMENT_TYPES:
|
||||||
|
if semantic_key not in type_keys:
|
||||||
|
session.add(
|
||||||
|
DocumentType(
|
||||||
|
semantic_key=semantic_key,
|
||||||
|
label=label,
|
||||||
|
normalized_label=label.casefold(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Application-defined semantic registry entries."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
BUILT_IN_DOCUMENT_TYPES: tuple[tuple[str, str], ...] = (
|
||||||
|
("book", "Book"),
|
||||||
|
("letter", "Letter"),
|
||||||
|
("postcard", "Postcard"),
|
||||||
|
("photo", "Photo"),
|
||||||
|
("journal", "Journal"),
|
||||||
|
("form", "Form"),
|
||||||
|
)
|
||||||
|
|
||||||
|
BUILT_IN_PERSON_ROLES: tuple[tuple[str, str], ...] = (
|
||||||
|
("author", "Author"),
|
||||||
|
("recipient", "Recipient"),
|
||||||
|
("mentioned", "Mentioned"),
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTHOR_ROLE_SEMANTIC_KEY = "author"
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncGenerator
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from contextvars import ContextVar
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from sqlmodel.pool import StaticPool
|
|
||||||
|
|
||||||
from ..config import Settings
|
from ..config import Settings
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
|
from .engine import get_database_url
|
||||||
|
from .engine import get_engine
|
||||||
|
from .session import get_session_factory
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,79 +22,42 @@ class DatabaseRuntime:
|
|||||||
session_factory: async_sessionmaker[AsyncSession]
|
session_factory: async_sessionmaker[AsyncSession]
|
||||||
|
|
||||||
|
|
||||||
_runtime: ContextVar[DatabaseRuntime | None] = ContextVar("database_runtime", default=None)
|
_runtime: DatabaseRuntime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_runtime() -> DatabaseRuntime | None:
|
||||||
|
"""Return the process-owned database runtime."""
|
||||||
|
return _runtime
|
||||||
|
|
||||||
|
|
||||||
async def dispose_database_runtime() -> None:
|
async def dispose_database_runtime() -> None:
|
||||||
"""Dispose lifespan-owned async database resources."""
|
"""Dispose lifespan-owned async database resources."""
|
||||||
runtime = _runtime.get()
|
global _runtime
|
||||||
|
runtime = _runtime
|
||||||
if runtime is None:
|
if runtime is None:
|
||||||
return
|
return
|
||||||
await runtime.engine.dispose()
|
await runtime.engine.dispose()
|
||||||
_runtime.set(None)
|
_runtime = None
|
||||||
|
|
||||||
|
|
||||||
def _to_async_database_url(database_url: str) -> str:
|
|
||||||
"""Normalize configured database URL to an async SQLAlchemy driver URL."""
|
|
||||||
if database_url.startswith("sqlite://") and not database_url.startswith("sqlite+aiosqlite://"):
|
|
||||||
return database_url.replace("sqlite://", "sqlite+aiosqlite://", 1)
|
|
||||||
if database_url.startswith("postgresql://") and not database_url.startswith("postgresql+asyncpg://"):
|
|
||||||
return database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
||||||
return database_url
|
|
||||||
|
|
||||||
|
|
||||||
def _build_engine(settings: Settings) -> AsyncEngine:
|
|
||||||
database_url = _to_async_database_url(settings.database_url)
|
|
||||||
engine_factory = partial(
|
|
||||||
create_async_engine,
|
|
||||||
url=database_url,
|
|
||||||
echo=False,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if database_url.startswith("sqlite"):
|
|
||||||
sqlite_connect_settings = {"check_same_thread": settings.sqlite_check_same_thread}
|
|
||||||
engine_factory = partial(engine_factory, connect_args=sqlite_connect_settings)
|
|
||||||
if ":memory:" in database_url:
|
|
||||||
engine_factory = partial(engine_factory, poolclass=StaticPool)
|
|
||||||
|
|
||||||
return engine_factory()
|
|
||||||
|
|
||||||
|
|
||||||
def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime:
|
def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime:
|
||||||
"""Initialize lifespan-owned async DB resources once per process."""
|
"""Initialize lifespan-owned async DB resources once per process."""
|
||||||
runtime = _runtime.get()
|
global _runtime
|
||||||
|
active_settings = settings or get_settings()
|
||||||
|
database_url = get_database_url(active_settings)
|
||||||
|
runtime = _runtime
|
||||||
if runtime is not None:
|
if runtime is not None:
|
||||||
|
runtime_url = runtime.engine.url.render_as_string(hide_password=False)
|
||||||
|
if runtime_url != database_url:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Database runtime is already initialized for a different database: "
|
||||||
|
f"{runtime_url!r} != {database_url!r}"
|
||||||
|
)
|
||||||
return runtime
|
return runtime
|
||||||
|
|
||||||
active_settings = settings or get_settings()
|
engine = get_engine(database_url)
|
||||||
engine = _build_engine(active_settings)
|
session_factory = get_session_factory(database_url)
|
||||||
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
||||||
runtime = DatabaseRuntime(engine=engine, session_factory=session_factory)
|
runtime = DatabaseRuntime(engine=engine, session_factory=session_factory)
|
||||||
_runtime.set(runtime)
|
_runtime = runtime
|
||||||
logger.debug("Initialized async database runtime for database_url=%s", engine.url)
|
logger.debug("Initialized async database runtime for database_url=%s", engine.url)
|
||||||
return runtime
|
return runtime
|
||||||
|
|
||||||
|
|
||||||
def get_engine(settings: Settings | None = None) -> AsyncEngine:
|
|
||||||
"""Return the current async SQLAlchemy engine."""
|
|
||||||
runtime = _runtime.get() or initialize_database_runtime(settings=settings)
|
|
||||||
return runtime.engine
|
|
||||||
|
|
||||||
|
|
||||||
def get_session_factory(settings: Settings | None = None) -> async_sessionmaker[AsyncSession]:
|
|
||||||
"""Return the shared async session factory."""
|
|
||||||
runtime = _runtime.get() or initialize_database_runtime(settings=settings)
|
|
||||||
return runtime.session_factory
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def get_session(
|
|
||||||
*,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
|
||||||
) -> AsyncGenerator[AsyncSession]:
|
|
||||||
"""Yield a database session and ensure cleanup."""
|
|
||||||
active_session_factory = session_factory or get_session_factory(settings)
|
|
||||||
async with active_session_factory() as session:
|
|
||||||
yield session
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
_SESSION_FACTORIES: dict[str, SessionFactory] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_factory(database_url: str) -> SessionFactory:
|
||||||
|
"""Return the process-wide session factory for ``database_url``."""
|
||||||
|
factory = _SESSION_FACTORIES.get(database_url)
|
||||||
|
if factory is None:
|
||||||
|
factory = async_sessionmaker(
|
||||||
|
bind=get_engine(database_url),
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
_SESSION_FACTORIES[database_url] = factory
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_session_factory(
|
||||||
|
database_url: str | None = None,
|
||||||
|
*,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
) -> SessionFactory:
|
||||||
|
if database_url is not None:
|
||||||
|
return get_session_factory(database_url)
|
||||||
|
if settings is None:
|
||||||
|
from .runtime import get_database_runtime
|
||||||
|
|
||||||
|
runtime = get_database_runtime()
|
||||||
|
if runtime is not None:
|
||||||
|
return runtime.session_factory
|
||||||
|
return get_session_factory(get_database_url(settings or get_settings()))
|
||||||
|
|
||||||
|
|
||||||
|
type SessionFactoryDep = Annotated[SessionFactory, Depends(resolve_session_factory)]
|
||||||
|
|
||||||
|
|
||||||
|
async def dispose_session_factory(database_url: str) -> None:
|
||||||
|
"""Drop the session factory and engine for ``database_url`` only."""
|
||||||
|
_SESSION_FACTORIES.pop(database_url, None)
|
||||||
|
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 | None = None,
|
||||||
|
) -> AsyncGenerator[AsyncSession]:
|
||||||
|
if session is not None:
|
||||||
|
if not session.in_transaction():
|
||||||
|
raise RuntimeError("A supplied session must have an active transaction")
|
||||||
|
yield session
|
||||||
|
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, Depends(transaction_scope)]
|
||||||
@@ -17,6 +17,7 @@ class ErrorCategory(StrEnum):
|
|||||||
NOT_FOUND = "not_found_error"
|
NOT_FOUND = "not_found_error"
|
||||||
CONFLICT = "conflict_error"
|
CONFLICT = "conflict_error"
|
||||||
EXTERNAL_PROVIDER = "external_provider_error"
|
EXTERNAL_PROVIDER = "external_provider_error"
|
||||||
|
EXTERNAL_TIMEOUT = "external_timeout_error"
|
||||||
PROCESSING = "processing_error"
|
PROCESSING = "processing_error"
|
||||||
INFRA_TRANSIENT = "infrastructure_transient_error"
|
INFRA_TRANSIENT = "infrastructure_transient_error"
|
||||||
INFRA_PERSISTENT = "infrastructure_persistent_error"
|
INFRA_PERSISTENT = "infrastructure_persistent_error"
|
||||||
@@ -59,11 +60,28 @@ class ErrorEnvelope:
|
|||||||
timestamp: str
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_error_category(error: AppError) -> str:
|
||||||
|
"""Map internal categories to canonical API/UI envelope categories."""
|
||||||
|
mapping: dict[ErrorCategory, str] = {
|
||||||
|
ErrorCategory.VALIDATION: "validation",
|
||||||
|
ErrorCategory.USER_INPUT: "validation",
|
||||||
|
ErrorCategory.NOT_FOUND: "not_found",
|
||||||
|
ErrorCategory.CONFLICT: "conflict",
|
||||||
|
ErrorCategory.EXTERNAL_PROVIDER: "external",
|
||||||
|
ErrorCategory.EXTERNAL_TIMEOUT: "timeout",
|
||||||
|
ErrorCategory.INFRA_TRANSIENT: "timeout",
|
||||||
|
ErrorCategory.PROCESSING: "internal",
|
||||||
|
ErrorCategory.INFRA_PERSISTENT: "internal",
|
||||||
|
ErrorCategory.INTERNAL_UNEXPECTED: "internal",
|
||||||
|
}
|
||||||
|
return mapping.get(error.category, "internal")
|
||||||
|
|
||||||
|
|
||||||
def build_error_envelope(error: AppError) -> ErrorEnvelope:
|
def build_error_envelope(error: AppError) -> ErrorEnvelope:
|
||||||
"""Build an API-safe response envelope from an AppError."""
|
"""Build an API-safe response envelope from an AppError."""
|
||||||
return ErrorEnvelope(
|
return ErrorEnvelope(
|
||||||
error_id=error.error_id,
|
error_id=error.error_id,
|
||||||
category=error.category.value,
|
category=canonical_error_category(error),
|
||||||
message=error.message,
|
message=error.message,
|
||||||
suggestion=error.suggestion,
|
suggestion=error.suggestion,
|
||||||
timestamp=datetime.now(UTC).isoformat(),
|
timestamp=datetime.now(UTC).isoformat(),
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
"""SQLModel domain models for the transcription system.
|
|
||||||
|
|
||||||
Three models capture the MVP lifecycle:
|
|
||||||
Document -> one-to-many -> Job -> one-to-many -> Transcript
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import datetime
|
|
||||||
from enum import StrEnum
|
|
||||||
from uuid import UUID
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import UniqueConstraint
|
|
||||||
from sqlmodel import Field
|
|
||||||
from sqlmodel import Relationship
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(StrEnum):
|
|
||||||
QUEUED = "queued"
|
|
||||||
PROCESSING = "processing"
|
|
||||||
TRANSCRIBED = "transcribed"
|
|
||||||
FAILED = "failed"
|
|
||||||
|
|
||||||
|
|
||||||
class Document(SQLModel, table=True):
|
|
||||||
"""An uploaded document image."""
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
filename: str
|
|
||||||
file_path: str
|
|
||||||
uploaded_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
|
|
||||||
# --- relationships ---
|
|
||||||
jobs: list["Job"] = Relationship(back_populates="document")
|
|
||||||
|
|
||||||
|
|
||||||
class Job(SQLModel, table=True):
|
|
||||||
"""A transcription job tied to a single document."""
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
document_id: UUID = Field(foreign_key="document.id")
|
|
||||||
status: JobStatus = Field(default=JobStatus.QUEUED)
|
|
||||||
retry_count: int = Field(default=0, ge=0)
|
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
|
|
||||||
# --- relationships ---
|
|
||||||
document: Document = Relationship(back_populates="jobs")
|
|
||||||
transcripts: list["Transcript"] = Relationship(back_populates="job")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def filename(self) -> str:
|
|
||||||
"""Return the filename of the associated document."""
|
|
||||||
return self.document.filename if self.document else "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
class Transcript(SQLModel, table=True):
|
|
||||||
"""The output of a transcription job."""
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
job_id: UUID = Field(foreign_key="job.id")
|
|
||||||
"""ID for the associated job."""
|
|
||||||
revision: int = Field(default=0, ge=0)
|
|
||||||
"""Revision number for this job's transcript history, starting at 0."""
|
|
||||||
provider: str
|
|
||||||
"""Name of the transcription provider used to generate this transcript."""
|
|
||||||
model: str
|
|
||||||
"""Model identifier used to generate this transcript revision."""
|
|
||||||
prompt_name: str
|
|
||||||
"""Name of the prompt used to generate this transcript."""
|
|
||||||
text: str | None = None
|
|
||||||
"""The transcribed text. This may be None if the job failed or is still in progress."""
|
|
||||||
error_detail: str | None = None
|
|
||||||
"""Details of any error that occurred during transcription."""
|
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint("job_id", "revision", name="uq_transcript_job_revision"),)
|
|
||||||
|
|
||||||
# --- relationships ---
|
|
||||||
job: Job = Relationship(back_populates="transcripts")
|
|
||||||
@@ -6,8 +6,12 @@ from transcription.config import get_settings
|
|||||||
from transcription.providers.base import ProviderAuthError
|
from transcription.providers.base import ProviderAuthError
|
||||||
from transcription.providers.base import ProviderError
|
from transcription.providers.base import ProviderError
|
||||||
from transcription.providers.base import ProviderResponseError
|
from transcription.providers.base import ProviderResponseError
|
||||||
|
from transcription.providers.base import TranscriptionMetadata
|
||||||
from transcription.providers.base import TranscriptionProvider
|
from transcription.providers.base import TranscriptionProvider
|
||||||
from transcription.providers.base import TranscriptionResult
|
from transcription.providers.base import TranscriptionResult
|
||||||
|
from transcription.providers.evidence import RequestManifest
|
||||||
|
from transcription.providers.evidence import SourceEvidenceReference
|
||||||
|
from transcription.providers.evidence import TransportEvidence
|
||||||
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
|
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -25,7 +29,11 @@ __all__ = [
|
|||||||
"ProviderAuthError",
|
"ProviderAuthError",
|
||||||
"ProviderError",
|
"ProviderError",
|
||||||
"ProviderResponseError",
|
"ProviderResponseError",
|
||||||
|
"RequestManifest",
|
||||||
|
"SourceEvidenceReference",
|
||||||
|
"TranscriptionMetadata",
|
||||||
"TranscriptionProvider",
|
"TranscriptionProvider",
|
||||||
"TranscriptionResult",
|
"TranscriptionResult",
|
||||||
|
"TransportEvidence",
|
||||||
"get_transcription_provider",
|
"get_transcription_provider",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,15 +1,33 @@
|
|||||||
"""Provider interfaces and shared types for transcription adapters."""
|
"""Provider interfaces and validated shared contracts for transcription adapters."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from ..models import Transcript
|
from pydantic import BaseModel
|
||||||
|
from pydantic import ConfigDict
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic import JsonValue
|
||||||
|
|
||||||
|
from transcription.providers.evidence import RequestManifest
|
||||||
|
from transcription.providers.evidence import SourceEvidenceReference
|
||||||
|
from transcription.providers.evidence import TransportEvidence
|
||||||
|
|
||||||
|
|
||||||
class ProviderError(RuntimeError):
|
class ProviderError(RuntimeError):
|
||||||
"""Base error for provider failures."""
|
"""Base error for provider failures."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
request_manifest: RequestManifest | None = None,
|
||||||
|
transport_evidence: TransportEvidence | None = None,
|
||||||
|
failure_phase: str = "provider_request",
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.request_manifest = request_manifest
|
||||||
|
self.transport_evidence = transport_evidence
|
||||||
|
self.failure_phase = failure_phase
|
||||||
|
|
||||||
|
|
||||||
class ProviderAuthError(ProviderError):
|
class ProviderAuthError(ProviderError):
|
||||||
"""Raised when provider authentication fails."""
|
"""Raised when provider authentication fails."""
|
||||||
@@ -19,30 +37,100 @@ class ProviderResponseError(ProviderError):
|
|||||||
"""Raised when provider responses are malformed or unusable."""
|
"""Raised when provider responses are malformed or unusable."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
class ProviderUsage(BaseModel):
|
||||||
class TranscriptionResult:
|
"""Normalized provider token accounting."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
|
input_tokens: int | None = Field(default=None, ge=0)
|
||||||
|
output_tokens: int | None = Field(default=None, ge=0)
|
||||||
|
total_tokens: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptionMetadata(BaseModel):
|
||||||
|
"""Stable structured metadata persisted for one provider execution."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
|
finish_reason: str | None = Field(default=None, min_length=1)
|
||||||
|
usage: ProviderUsage | None = None
|
||||||
|
|
||||||
|
def as_json_object(self) -> dict[str, JsonValue] | None:
|
||||||
|
payload = self.model_dump(mode="json", exclude_none=True)
|
||||||
|
return payload or None
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptionResult(BaseModel):
|
||||||
"""Normalized output returned by any transcription provider."""
|
"""Normalized output returned by any transcription provider."""
|
||||||
|
|
||||||
text: str
|
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True)
|
||||||
provider: str
|
|
||||||
prompt_name: str
|
|
||||||
model: str
|
|
||||||
|
|
||||||
def to_transcript(self, job_id: UUID, *, revision: int = 0) -> Transcript:
|
text: str = Field(min_length=1)
|
||||||
"""Convert a TranscriptionResult to a Transcript model instance."""
|
provider: str = Field(min_length=1)
|
||||||
return Transcript(
|
model: str = Field(min_length=1)
|
||||||
job_id=job_id,
|
prompt_name: str | None = None
|
||||||
revision=revision,
|
prompt_hash: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
|
||||||
provider=self.provider,
|
system_prompt: str | None = None
|
||||||
prompt_name=self.prompt_name,
|
user_prompt: str | None = None
|
||||||
model=self.model,
|
temperature: float | None = Field(default=None, ge=0.0, le=2.0)
|
||||||
text=self.text,
|
top_p: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||||
)
|
metadata: TranscriptionMetadata = Field(default_factory=TranscriptionMetadata)
|
||||||
|
raw_api_response: dict[str, JsonValue] | None = None
|
||||||
|
request_manifest: RequestManifest | None = None
|
||||||
|
transport_evidence: TransportEvidence | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def finish_reason(self) -> str | None:
|
||||||
|
return self.metadata.finish_reason
|
||||||
|
|
||||||
|
@property
|
||||||
|
def usage_input_tokens(self) -> int | None:
|
||||||
|
return self.metadata.usage.input_tokens if self.metadata.usage else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def usage_output_tokens(self) -> int | None:
|
||||||
|
return self.metadata.usage.output_tokens if self.metadata.usage else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def usage_total_tokens(self) -> int | None:
|
||||||
|
return self.metadata.usage.total_tokens if self.metadata.usage else None
|
||||||
|
|
||||||
|
def metadata_payload(self) -> dict[str, JsonValue] | None:
|
||||||
|
return self.metadata.as_json_object()
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionProvider(Protocol):
|
class TranscriptionProvider(Protocol):
|
||||||
"""Contract every transcription provider adapter must satisfy."""
|
"""Contract every transcription provider adapter must satisfy."""
|
||||||
|
|
||||||
async def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
|
@property
|
||||||
|
def model(self) -> str:
|
||||||
|
"""Return the resolved model slug this adapter will call."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_request_manifest(self) -> RequestManifest | None:
|
||||||
|
"""Return the manifest for the most recent call, for failure evidence."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_transport_evidence(self) -> TransportEvidence | None:
|
||||||
|
"""Return transport-level evidence for the most recent call."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def transcribe(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt_text: str,
|
||||||
|
image_bytes: bytes,
|
||||||
|
mime_type: str,
|
||||||
|
temperature: float | None = None,
|
||||||
|
top_p: float | None = None,
|
||||||
|
source_reference: SourceEvidenceReference | None = None,
|
||||||
|
requested_model: str | None = None,
|
||||||
|
) -> TranscriptionResult:
|
||||||
"""Transcribe the provided image according to the prompt text."""
|
"""Transcribe the provided image according to the prompt text."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Release any pooled network resources held by the adapter."""
|
||||||
|
...
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Versioned, provider-neutral contracts for processing evidence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import platform
|
||||||
|
from importlib.metadata import PackageNotFoundError
|
||||||
|
from importlib.metadata import version
|
||||||
|
from typing import Any
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic import ConfigDict
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic import JsonValue
|
||||||
|
|
||||||
|
from transcription.config import Settings
|
||||||
|
|
||||||
|
REQUEST_MANIFEST_SCHEMA = "transcription.request-manifest"
|
||||||
|
REQUEST_MANIFEST_VERSION = "1"
|
||||||
|
SOFTWARE_CONTEXT_SCHEMA = "transcription.software-context"
|
||||||
|
SOFTWARE_CONTEXT_VERSION = "1"
|
||||||
|
TRANSPORT_EVIDENCE_SCHEMA = "transcription.transport-evidence"
|
||||||
|
TRANSPORT_EVIDENCE_VERSION = "1"
|
||||||
|
CANONICAL_JSON_ALGORITHM = "transcription-canonical-json-v1"
|
||||||
|
|
||||||
|
SAFE_RESPONSE_HEADERS = frozenset(
|
||||||
|
{
|
||||||
|
"content-type",
|
||||||
|
"content-encoding",
|
||||||
|
"date",
|
||||||
|
"retry-after",
|
||||||
|
"x-request-id",
|
||||||
|
"x-openrouter-generation-id",
|
||||||
|
"x-ratelimit-limit",
|
||||||
|
"x-ratelimit-remaining",
|
||||||
|
"x-ratelimit-reset",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceModel(BaseModel):
|
||||||
|
"""Strict immutable base for persisted evidence contracts."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceEvidenceReference(EvidenceModel):
|
||||||
|
"""Secret-safe identity for source content used by one execution."""
|
||||||
|
|
||||||
|
source_id: UUID
|
||||||
|
digest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||||
|
byte_size: int = Field(ge=0)
|
||||||
|
media_type: str = Field(min_length=1)
|
||||||
|
page_number: int = Field(ge=1)
|
||||||
|
width: int | None = Field(default=None, ge=1)
|
||||||
|
height: int | None = Field(default=None, ge=1)
|
||||||
|
derivative_id: UUID | None = None
|
||||||
|
transformation: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SoftwareContext(EvidenceModel):
|
||||||
|
"""Versions needed to interpret a provider execution."""
|
||||||
|
|
||||||
|
schema_name: Literal["transcription.software-context"] = SOFTWARE_CONTEXT_SCHEMA
|
||||||
|
schema_version: Literal["1"] = SOFTWARE_CONTEXT_VERSION
|
||||||
|
application_version: str
|
||||||
|
application_commit: str | None = None
|
||||||
|
adapter_name: str
|
||||||
|
adapter_version: str
|
||||||
|
client_library: str
|
||||||
|
client_library_version: str
|
||||||
|
python_version: str
|
||||||
|
|
||||||
|
|
||||||
|
class RequestManifest(EvidenceModel):
|
||||||
|
"""Frozen, secret-safe representation of one concrete provider request."""
|
||||||
|
|
||||||
|
schema_name: Literal["transcription.request-manifest"] = REQUEST_MANIFEST_SCHEMA
|
||||||
|
schema_version: Literal["1"] = REQUEST_MANIFEST_VERSION
|
||||||
|
provider: str = Field(min_length=1)
|
||||||
|
requested_model: str = Field(min_length=1)
|
||||||
|
request: dict[str, JsonValue]
|
||||||
|
source: SourceEvidenceReference
|
||||||
|
explicitly_supplied_parameters: tuple[str, ...] = ()
|
||||||
|
omitted_optional_parameters: tuple[str, ...] = ()
|
||||||
|
optional_parameter_states: dict[str, Literal["omitted", "null", "value"]]
|
||||||
|
prompt_content: str = Field(min_length=1)
|
||||||
|
prompt_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||||
|
timeout_seconds: float = Field(gt=0)
|
||||||
|
retry_policy: str = Field(min_length=1)
|
||||||
|
software: SoftwareContext
|
||||||
|
canonicalization: Literal["transcription-canonical-json-v1"] = CANONICAL_JSON_ALGORITHM
|
||||||
|
|
||||||
|
def canonical_bytes(self) -> bytes:
|
||||||
|
return canonical_json_bytes(self.model_dump(mode="json"))
|
||||||
|
|
||||||
|
def digest(self) -> str:
|
||||||
|
return hashlib.sha256(self.canonical_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class TransportEvidence(EvidenceModel):
|
||||||
|
"""Exact response captured at the application/router HTTP boundary."""
|
||||||
|
|
||||||
|
schema_name: Literal["transcription.transport-evidence"] = TRANSPORT_EVIDENCE_SCHEMA
|
||||||
|
schema_version: Literal["1"] = TRANSPORT_EVIDENCE_VERSION
|
||||||
|
response_received: bool
|
||||||
|
status_code: int | None = Field(default=None, ge=100, le=599)
|
||||||
|
body: bytes | None = None
|
||||||
|
safe_headers: dict[str, str] = Field(default_factory=dict)
|
||||||
|
content_type: str | None = None
|
||||||
|
content_encoding: str | None = None
|
||||||
|
request_id: str | None = None
|
||||||
|
generation_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_json_bytes(value: Any) -> bytes:
|
||||||
|
"""Serialize JSON deterministically for evidence integrity hashes."""
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
allow_nan=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def filter_safe_response_headers(headers: Any) -> dict[str, str]:
|
||||||
|
"""Return only explicitly allowlisted response headers."""
|
||||||
|
return {
|
||||||
|
str(name).lower(): str(value) for name, value in headers.items() if str(name).lower() in SAFE_RESPONSE_HEADERS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def package_version(package: str) -> str:
|
||||||
|
"""Return an installed package version without failing evidence capture."""
|
||||||
|
try:
|
||||||
|
return version(package)
|
||||||
|
except PackageNotFoundError:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def build_software_context(
|
||||||
|
*,
|
||||||
|
adapter_name: str,
|
||||||
|
adapter_version: str,
|
||||||
|
client_library: str,
|
||||||
|
settings: Settings,
|
||||||
|
) -> SoftwareContext:
|
||||||
|
"""Build the runtime software identity for an execution."""
|
||||||
|
return SoftwareContext(
|
||||||
|
application_version=package_version("transcription"),
|
||||||
|
application_commit=settings.transcription_commit,
|
||||||
|
adapter_name=adapter_name,
|
||||||
|
adapter_version=adapter_version,
|
||||||
|
client_library=client_library,
|
||||||
|
client_library_version=package_version(client_library),
|
||||||
|
python_version=platform.python_version(),
|
||||||
|
)
|
||||||
@@ -3,127 +3,530 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from collections.abc import AsyncIterator
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Annotated
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import cast
|
from typing import Literal
|
||||||
|
|
||||||
|
import httpx
|
||||||
from openrouter import OpenRouter
|
from openrouter import OpenRouter
|
||||||
from openrouter.components.chatmessages import ChatMessagesTypedDict
|
from openrouter import errors as openrouter_errors
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic import ConfigDict
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic import JsonValue
|
||||||
|
from pydantic import TypeAdapter
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.config import get_settings
|
from transcription.config import get_settings
|
||||||
from transcription.providers.base import ProviderAuthError
|
from transcription.providers.base import ProviderAuthError
|
||||||
from transcription.providers.base import ProviderError
|
from transcription.providers.base import ProviderError
|
||||||
from transcription.providers.base import ProviderResponseError
|
from transcription.providers.base import ProviderResponseError
|
||||||
|
from transcription.providers.base import ProviderUsage
|
||||||
|
from transcription.providers.base import TranscriptionMetadata
|
||||||
from transcription.providers.base import TranscriptionResult
|
from transcription.providers.base import TranscriptionResult
|
||||||
|
from transcription.providers.evidence import RequestManifest
|
||||||
|
from transcription.providers.evidence import SourceEvidenceReference
|
||||||
|
from transcription.providers.evidence import TransportEvidence
|
||||||
|
from transcription.providers.evidence import build_software_context
|
||||||
|
from transcription.providers.evidence import filter_safe_response_headers
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_OPENROUTER_MODEL = "google/gemini-2.5-flash"
|
DEFAULT_OPENROUTER_MODEL = "google/gemini-2.5-flash"
|
||||||
|
OPENROUTER_ADAPTER_VERSION = "2"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
class _CapturingAsyncByteStream(httpx.AsyncByteStream):
|
||||||
class OpenRouterRequest:
|
"""Copy streamed response bytes without changing what the SDK consumes."""
|
||||||
|
|
||||||
|
def __init__(self, stream: httpx.AsyncByteStream, on_complete: Callable[[bytes], None]):
|
||||||
|
self._stream = stream
|
||||||
|
self._on_complete = on_complete
|
||||||
|
|
||||||
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
||||||
|
content = bytearray()
|
||||||
|
async for chunk in self._stream:
|
||||||
|
content.extend(chunk)
|
||||||
|
yield chunk
|
||||||
|
self._on_complete(bytes(content))
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self._stream.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class _CapturingAsyncClient:
|
||||||
|
"""Delegate SDK HTTP calls while retaining the response before SDK parsing."""
|
||||||
|
|
||||||
|
def __init__(self, client: httpx.AsyncClient):
|
||||||
|
self._client = client
|
||||||
|
self.last_response: httpx.Response | None = None
|
||||||
|
self.last_body: bytes | None = None
|
||||||
|
|
||||||
|
async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
|
||||||
|
response = await self._client.send(request, **kwargs)
|
||||||
|
self.last_response = response
|
||||||
|
try:
|
||||||
|
self.last_body = response.content
|
||||||
|
except httpx.ResponseNotRead:
|
||||||
|
stream = response.stream
|
||||||
|
if not isinstance(stream, httpx.AsyncByteStream):
|
||||||
|
raise
|
||||||
|
response.stream = _CapturingAsyncByteStream(stream, self._capture_body)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def build_request(self, *args: Any, **kwargs: Any) -> httpx.Request:
|
||||||
|
return self._client.build_request(*args, **kwargs)
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.last_response = None
|
||||||
|
self.last_body = None
|
||||||
|
|
||||||
|
def _capture_body(self, body: bytes) -> None:
|
||||||
|
self.last_body = body
|
||||||
|
|
||||||
|
|
||||||
|
class _ProviderModel(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TextContent(_ProviderModel):
|
||||||
|
type: Literal["text"] = "text"
|
||||||
|
text: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageUrl(_ProviderModel):
|
||||||
|
url: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageContent(_ProviderModel):
|
||||||
|
type: Literal["image_url"] = "image_url"
|
||||||
|
image_url: ImageUrl
|
||||||
|
|
||||||
|
|
||||||
|
class FileData(_ProviderModel):
|
||||||
|
filename: str = Field(min_length=1)
|
||||||
|
file_data: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class FileContent(_ProviderModel):
|
||||||
|
type: Literal["file"] = "file"
|
||||||
|
file: FileData
|
||||||
|
|
||||||
|
|
||||||
|
MessageContent = Annotated[TextContent | ImageContent | FileContent, Field(discriminator="type")]
|
||||||
|
|
||||||
|
|
||||||
|
class UserMessage(_ProviderModel):
|
||||||
|
role: Literal["user"] = "user"
|
||||||
|
content: tuple[MessageContent, ...] = Field(min_length=2)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterRequest(_ProviderModel):
|
||||||
"""Normalized request payload fields for OpenRouter calls."""
|
"""Normalized request payload fields for OpenRouter calls."""
|
||||||
|
|
||||||
model: str
|
model: str = Field(min_length=1)
|
||||||
messages: list[dict[str, Any]]
|
messages: tuple[UserMessage, ...] = Field(min_length=1)
|
||||||
http_referer: str | None
|
http_referer: str | None
|
||||||
x_open_router_title: str | None
|
x_open_router_title: str | None
|
||||||
|
temperature: float | None = Field(ge=0.0, le=2.0)
|
||||||
|
top_p: float | None = Field(ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseContentPart(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow", frozen=True)
|
||||||
|
|
||||||
|
text: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseMessage(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow", frozen=True)
|
||||||
|
|
||||||
|
content: str | tuple[ResponseContentPart, ...] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseChoice(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow", frozen=True)
|
||||||
|
|
||||||
|
message: ResponseMessage
|
||||||
|
finish_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseUsage(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow", frozen=True)
|
||||||
|
|
||||||
|
prompt_tokens: int | None = Field(default=None, ge=0)
|
||||||
|
completion_tokens: int | None = Field(default=None, ge=0)
|
||||||
|
total_tokens: int | None = Field(default=None, ge=0)
|
||||||
|
input_tokens: int | None = Field(default=None, ge=0)
|
||||||
|
output_tokens: int | None = Field(default=None, ge=0)
|
||||||
|
total: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow", frozen=True)
|
||||||
|
|
||||||
|
model: str | None = None
|
||||||
|
choices: tuple[ResponseChoice, ...] = Field(min_length=1)
|
||||||
|
usage: dict[str, JsonValue] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
JSON_OBJECT_ADAPTER = TypeAdapter(dict[str, JsonValue])
|
||||||
|
|
||||||
|
|
||||||
class OpenRouterTranscriptionProvider:
|
class OpenRouterTranscriptionProvider:
|
||||||
"""Adapter that performs image transcription through OpenRouter."""
|
"""Adapter that performs image transcription through OpenRouter."""
|
||||||
|
|
||||||
def __init__(self, *, settings: Settings | None = None, client: OpenRouter | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
client: OpenRouter | None = None,
|
||||||
|
async_client: httpx.AsyncClient | None = None,
|
||||||
|
):
|
||||||
self._settings = settings or get_settings()
|
self._settings = settings or get_settings()
|
||||||
self._model = self._settings.provider_model or DEFAULT_OPENROUTER_MODEL
|
self._model = self._settings.provider_model or DEFAULT_OPENROUTER_MODEL
|
||||||
self._client = client or OpenRouter(api_key=self._settings.openrouter_api_key)
|
self._capturing_client: _CapturingAsyncClient | None = None
|
||||||
|
self._current_request_manifest: RequestManifest | None = None
|
||||||
|
self._current_transport_evidence: TransportEvidence | None = None
|
||||||
|
if client is None:
|
||||||
|
# httpx defaults every phase to 5s, which silently caps provider calls far
|
||||||
|
# below worker_provider_timeout_seconds. Track the configured budget instead.
|
||||||
|
timeout = httpx.Timeout(
|
||||||
|
self._settings.worker_provider_timeout_seconds,
|
||||||
|
connect=10.0,
|
||||||
|
)
|
||||||
|
self._capturing_client = _CapturingAsyncClient(
|
||||||
|
async_client or httpx.AsyncClient(follow_redirects=True, timeout=timeout)
|
||||||
|
)
|
||||||
|
client = OpenRouter(
|
||||||
|
api_key=self._settings.openrouter_api_key.get_secret_value(),
|
||||||
|
async_client=self._capturing_client,
|
||||||
|
)
|
||||||
|
self._client = client
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def model(self) -> str:
|
def model(self) -> str:
|
||||||
"""Return the resolved OpenRouter model slug."""
|
"""Return the resolved OpenRouter model slug."""
|
||||||
return self._model
|
return self._model
|
||||||
|
|
||||||
async def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
|
@property
|
||||||
|
def current_request_manifest(self) -> RequestManifest | None:
|
||||||
|
return self._current_request_manifest
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_transport_evidence(self) -> TransportEvidence | None:
|
||||||
|
if self._current_transport_evidence is not None:
|
||||||
|
return self._current_transport_evidence
|
||||||
|
if self._current_request_manifest is None:
|
||||||
|
return None
|
||||||
|
return self._captured_transport_evidence()
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
if self._capturing_client is not None:
|
||||||
|
await self._capturing_client.aclose()
|
||||||
|
|
||||||
|
async def transcribe(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt_text: str,
|
||||||
|
image_bytes: bytes,
|
||||||
|
mime_type: str,
|
||||||
|
temperature: float | None = None,
|
||||||
|
top_p: float | None = None,
|
||||||
|
source_reference: SourceEvidenceReference | None = None,
|
||||||
|
requested_model: str | None = None,
|
||||||
|
) -> TranscriptionResult:
|
||||||
"""Send prompt + image to OpenRouter and return normalized text output."""
|
"""Send prompt + image to OpenRouter and return normalized text output."""
|
||||||
request = self._build_request(prompt_text=prompt_text, image_bytes=image_bytes, mime_type=mime_type)
|
request = self._build_request(
|
||||||
|
prompt_text=prompt_text,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
mime_type=mime_type,
|
||||||
|
temperature=temperature,
|
||||||
|
top_p=top_p,
|
||||||
|
requested_model=requested_model,
|
||||||
|
)
|
||||||
|
manifest = self._build_request_manifest(
|
||||||
|
request=request,
|
||||||
|
prompt_text=prompt_text,
|
||||||
|
source_reference=source_reference,
|
||||||
|
temperature=temperature,
|
||||||
|
top_p=top_p,
|
||||||
|
)
|
||||||
|
self._current_request_manifest = manifest
|
||||||
|
self._current_transport_evidence = None
|
||||||
|
if self._capturing_client is not None:
|
||||||
|
self._capturing_client.reset()
|
||||||
try:
|
try:
|
||||||
response = await self._client.chat.send_async(
|
response = await self._client.chat.send_async(
|
||||||
messages=cast(list[ChatMessagesTypedDict], request.messages),
|
**request.model_dump(mode="json", exclude_none=True),
|
||||||
model=request.model,
|
retries=None,
|
||||||
http_referer=request.http_referer,
|
|
||||||
x_open_router_title=request.x_open_router_title,
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
message = str(exc).lower()
|
transport = self._captured_transport_evidence()
|
||||||
if "401" in message or "auth" in message or "api key" in message:
|
self._current_transport_evidence = transport
|
||||||
raise ProviderAuthError("OpenRouter authentication failed") from exc
|
if isinstance(exc, openrouter_errors.UnauthorizedResponseError):
|
||||||
raise ProviderError("OpenRouter request failed") from exc
|
raise ProviderAuthError(
|
||||||
|
"OpenRouter authentication failed",
|
||||||
|
request_manifest=manifest,
|
||||||
|
transport_evidence=transport,
|
||||||
|
failure_phase="http_response" if transport.response_received else "connection",
|
||||||
|
) from exc
|
||||||
|
failure_phase = (
|
||||||
|
"response_validation"
|
||||||
|
if isinstance(exc, openrouter_errors.ResponseValidationError)
|
||||||
|
else "http_response"
|
||||||
|
if transport.response_received
|
||||||
|
else "connection"
|
||||||
|
)
|
||||||
|
raise ProviderError(
|
||||||
|
self._transport_error_message(transport),
|
||||||
|
request_manifest=manifest,
|
||||||
|
transport_evidence=transport,
|
||||||
|
failure_phase=failure_phase,
|
||||||
|
) from exc
|
||||||
|
|
||||||
text = self._extract_text(response)
|
transport = self._captured_transport_evidence()
|
||||||
model = self._get_optional_attr(response, "model") or self.model
|
self._current_transport_evidence = transport
|
||||||
|
raw_api_response = self._coerce_raw_response(response)
|
||||||
|
try:
|
||||||
|
validated_response = OpenRouterResponse.model_validate(raw_api_response)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ProviderResponseError(
|
||||||
|
"OpenRouter response failed schema validation",
|
||||||
|
request_manifest=manifest,
|
||||||
|
transport_evidence=transport,
|
||||||
|
failure_phase="response_validation",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = self._extract_text(validated_response)
|
||||||
|
except ProviderResponseError as exc:
|
||||||
|
raise ProviderResponseError(
|
||||||
|
str(exc),
|
||||||
|
request_manifest=manifest,
|
||||||
|
transport_evidence=transport,
|
||||||
|
failure_phase="response_validation",
|
||||||
|
) from exc
|
||||||
|
model = validated_response.model or requested_model or self.model
|
||||||
|
metadata = self._build_metadata(validated_response)
|
||||||
logger.info("OpenRouter transcription completed using model=%s", model)
|
logger.info("OpenRouter transcription completed using model=%s", model)
|
||||||
return TranscriptionResult(text=text, provider="openrouter", prompt_name="", model=model)
|
return TranscriptionResult(
|
||||||
|
text=text,
|
||||||
def _build_request(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> OpenRouterRequest:
|
provider="openrouter",
|
||||||
image_b64 = base64.b64encode(image_bytes).decode("ascii")
|
prompt_name=None,
|
||||||
data_url = f"data:{mime_type};base64,{image_b64}"
|
prompt_hash=None,
|
||||||
|
system_prompt=None,
|
||||||
messages: list[dict[str, Any]] = [
|
user_prompt=prompt_text,
|
||||||
{
|
temperature=temperature,
|
||||||
"role": "user",
|
top_p=top_p,
|
||||||
"content": [
|
model=model,
|
||||||
{"type": "text", "text": prompt_text},
|
metadata=metadata,
|
||||||
{"type": "image_url", "image_url": {"url": data_url}},
|
raw_api_response=raw_api_response,
|
||||||
],
|
request_manifest=manifest,
|
||||||
}
|
transport_evidence=transport,
|
||||||
]
|
|
||||||
|
|
||||||
return OpenRouterRequest(
|
|
||||||
model=self.model,
|
|
||||||
messages=messages,
|
|
||||||
http_referer=self._settings.openrouter_http_referer,
|
|
||||||
x_open_router_title=self._settings.openrouter_app_title,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _extract_text(self, response: Any) -> str:
|
def _build_request_manifest(
|
||||||
choices = self._get_optional_attr(response, "choices")
|
self,
|
||||||
if not choices:
|
*,
|
||||||
raise ProviderResponseError("OpenRouter response missing choices")
|
request: OpenRouterRequest,
|
||||||
|
prompt_text: str,
|
||||||
|
source_reference: SourceEvidenceReference | None,
|
||||||
|
temperature: float | None,
|
||||||
|
top_p: float | None,
|
||||||
|
) -> RequestManifest | None:
|
||||||
|
if source_reference is None:
|
||||||
|
return None
|
||||||
|
request_payload = request.model_dump(mode="json", exclude_none=True)
|
||||||
|
sanitized_request = self._replace_embedded_media(request_payload, source_reference=source_reference)
|
||||||
|
explicit = tuple(name for name, value in (("temperature", temperature), ("top_p", top_p)) if value is not None)
|
||||||
|
omitted = tuple(name for name in ("temperature", "top_p") if name not in explicit)
|
||||||
|
return RequestManifest(
|
||||||
|
provider="openrouter",
|
||||||
|
requested_model=request.model,
|
||||||
|
request=JSON_OBJECT_ADAPTER.validate_python(sanitized_request),
|
||||||
|
source=source_reference,
|
||||||
|
explicitly_supplied_parameters=explicit,
|
||||||
|
omitted_optional_parameters=omitted,
|
||||||
|
optional_parameter_states={
|
||||||
|
"temperature": "value" if temperature is not None else "omitted",
|
||||||
|
"top_p": "value" if top_p is not None else "omitted",
|
||||||
|
},
|
||||||
|
prompt_content=prompt_text,
|
||||||
|
prompt_sha256=hashlib.sha256(prompt_text.encode("utf-8")).hexdigest(),
|
||||||
|
timeout_seconds=self._settings.worker_provider_timeout_seconds,
|
||||||
|
retry_policy="application-bounded; sdk-retries=0",
|
||||||
|
software=build_software_context(
|
||||||
|
adapter_name="openrouter",
|
||||||
|
adapter_version=OPENROUTER_ADAPTER_VERSION,
|
||||||
|
client_library="openrouter",
|
||||||
|
settings=self._settings,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
first_choice = choices[0]
|
def _replace_embedded_media(
|
||||||
message = self._get_optional_attr(first_choice, "message")
|
self,
|
||||||
if message is None:
|
value: Any,
|
||||||
raise ProviderResponseError("OpenRouter response missing assistant message")
|
*,
|
||||||
|
source_reference: SourceEvidenceReference,
|
||||||
|
) -> Any:
|
||||||
|
if isinstance(value, str) and value.startswith("data:") and ";base64," in value:
|
||||||
|
return {
|
||||||
|
"source_reference": source_reference.model_dump(mode="json"),
|
||||||
|
"embedded_media_omitted": True,
|
||||||
|
}
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
str(key): self._replace_embedded_media(item, source_reference=source_reference)
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, list | tuple):
|
||||||
|
return [self._replace_embedded_media(item, source_reference=source_reference) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
content = self._get_optional_attr(message, "content")
|
def _captured_transport_evidence(self) -> TransportEvidence:
|
||||||
|
response = self._capturing_client.last_response if self._capturing_client is not None else None
|
||||||
|
if response is None:
|
||||||
|
return TransportEvidence(response_received=False)
|
||||||
|
headers = filter_safe_response_headers(response.headers)
|
||||||
|
body = self._capturing_client.last_body if self._capturing_client is not None else None
|
||||||
|
return TransportEvidence(
|
||||||
|
response_received=True,
|
||||||
|
status_code=response.status_code,
|
||||||
|
body=body,
|
||||||
|
safe_headers=headers,
|
||||||
|
content_type=headers.get("content-type"),
|
||||||
|
content_encoding=headers.get("content-encoding"),
|
||||||
|
request_id=headers.get("x-request-id"),
|
||||||
|
generation_id=headers.get("x-openrouter-generation-id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _transport_error_message(transport: TransportEvidence) -> str:
|
||||||
|
message = "OpenRouter request failed"
|
||||||
|
if transport.status_code is not None:
|
||||||
|
message += f" with HTTP {transport.status_code}"
|
||||||
|
if transport.body is None:
|
||||||
|
return message
|
||||||
|
try:
|
||||||
|
payload = json.loads(transport.body)
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
return message
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return message
|
||||||
|
error = payload.get("error")
|
||||||
|
detail = error.get("message") if isinstance(error, dict) else None
|
||||||
|
if isinstance(detail, str) and detail.strip():
|
||||||
|
return f"{message}: {detail.strip()[:500]}"
|
||||||
|
return message
|
||||||
|
|
||||||
|
def _build_metadata(self, response: OpenRouterResponse) -> TranscriptionMetadata:
|
||||||
|
choice = response.choices[0]
|
||||||
|
finish_reason = choice.finish_reason.strip() if choice.finish_reason and choice.finish_reason.strip() else None
|
||||||
|
normalized_usage = None
|
||||||
|
if response.usage is not None:
|
||||||
|
try:
|
||||||
|
usage = ResponseUsage.model_validate(response.usage)
|
||||||
|
except ValidationError as exc:
|
||||||
|
logger.warning("Ignoring invalid OpenRouter usage metadata: %s", exc)
|
||||||
|
else:
|
||||||
|
normalized_usage = ProviderUsage(
|
||||||
|
input_tokens=usage.prompt_tokens if usage.prompt_tokens is not None else usage.input_tokens,
|
||||||
|
output_tokens=usage.completion_tokens
|
||||||
|
if usage.completion_tokens is not None
|
||||||
|
else usage.output_tokens,
|
||||||
|
total_tokens=usage.total_tokens if usage.total_tokens is not None else usage.total,
|
||||||
|
)
|
||||||
|
if normalized_usage.model_dump(exclude_none=True) == {}:
|
||||||
|
normalized_usage = None
|
||||||
|
return TranscriptionMetadata(finish_reason=finish_reason, usage=normalized_usage)
|
||||||
|
|
||||||
|
def _coerce_raw_response(self, response: Any) -> dict[str, JsonValue]:
|
||||||
|
payload = self._to_json_compatible(response)
|
||||||
|
try:
|
||||||
|
return JSON_OBJECT_ADAPTER.validate_python(payload)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ProviderResponseError("OpenRouter response is not a JSON object") from exc
|
||||||
|
|
||||||
|
def _to_json_compatible(self, value: Any) -> Any:
|
||||||
|
if value is None or isinstance(value, str | int | float | bool):
|
||||||
|
return value
|
||||||
|
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): self._to_json_compatible(item) for key, item in value.items()}
|
||||||
|
|
||||||
|
if isinstance(value, list | tuple | set):
|
||||||
|
return [self._to_json_compatible(item) for item in value]
|
||||||
|
|
||||||
|
for method_name in ("model_dump", "to_dict"):
|
||||||
|
serializer = getattr(value, method_name, None)
|
||||||
|
if callable(serializer):
|
||||||
|
try:
|
||||||
|
serialized = serializer(mode="json") if method_name == "model_dump" else serializer()
|
||||||
|
return self._to_json_compatible(serialized)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
logger.debug("OpenRouter response serializer %s failed: %s", method_name, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
object_dict = getattr(value, "__dict__", None)
|
||||||
|
if isinstance(object_dict, dict):
|
||||||
|
return {
|
||||||
|
str(key): self._to_json_compatible(item)
|
||||||
|
for key, item in object_dict.items()
|
||||||
|
if not str(key).startswith("_")
|
||||||
|
}
|
||||||
|
|
||||||
|
raise ProviderResponseError(f"OpenRouter response contains unsupported value type: {type(value).__name__}")
|
||||||
|
|
||||||
|
def _build_request(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prompt_text: str,
|
||||||
|
image_bytes: bytes,
|
||||||
|
mime_type: str,
|
||||||
|
temperature: float | None,
|
||||||
|
top_p: float | None,
|
||||||
|
requested_model: str | None = None,
|
||||||
|
) -> OpenRouterRequest:
|
||||||
|
image_b64 = base64.b64encode(image_bytes).decode("ascii")
|
||||||
|
data_url = f"data:{mime_type};base64,{image_b64}"
|
||||||
|
media_content: ImageContent | FileContent
|
||||||
|
if mime_type == "application/pdf":
|
||||||
|
media_content = FileContent(file=FileData(filename="source.pdf", file_data=data_url))
|
||||||
|
else:
|
||||||
|
media_content = ImageContent(image_url=ImageUrl(url=data_url))
|
||||||
|
|
||||||
|
return OpenRouterRequest(
|
||||||
|
model=requested_model or self.model,
|
||||||
|
messages=(UserMessage(content=(TextContent(text=prompt_text), media_content)),),
|
||||||
|
http_referer=self._settings.openrouter_http_referer,
|
||||||
|
x_open_router_title=self._settings.openrouter_app_title,
|
||||||
|
temperature=temperature,
|
||||||
|
top_p=top_p,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _extract_text(self, response: OpenRouterResponse) -> str:
|
||||||
|
content = response.choices[0].message.content
|
||||||
text = self._normalize_content(content)
|
text = self._normalize_content(content)
|
||||||
if not text:
|
if not text:
|
||||||
raise ProviderResponseError("OpenRouter response contained no transcription text")
|
raise ProviderResponseError("OpenRouter response contained no transcription text")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def _normalize_content(self, content: Any) -> str:
|
def _normalize_content(self, content: str | tuple[ResponseContentPart, ...] | None) -> str:
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
return content.strip()
|
return content.strip()
|
||||||
|
|
||||||
if isinstance(content, list):
|
if isinstance(content, tuple):
|
||||||
parts: list[str] = []
|
parts = [item.text.strip() for item in content if item.text and item.text.strip()]
|
||||||
for item in content:
|
|
||||||
text_part = None
|
|
||||||
text_part = item.get("text") if isinstance(item, dict) else self._get_optional_attr(item, "text")
|
|
||||||
|
|
||||||
if isinstance(text_part, str) and text_part.strip():
|
|
||||||
parts.append(text_part.strip())
|
|
||||||
return "\n".join(parts).strip()
|
return "\n".join(parts).strip()
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_optional_attr(obj: Any, key: str) -> Any:
|
|
||||||
if obj is None:
|
|
||||||
return None
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return obj.get(key)
|
|
||||||
return getattr(obj, key, None)
|
|
||||||
|
|||||||
@@ -2,12 +2,30 @@
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from dataclasses import field
|
from dataclasses import field
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ..config import Settings
|
||||||
from .documents import DocumentService
|
from .documents import DocumentService
|
||||||
|
from .evidence import EvidenceService
|
||||||
from .jobs import JobService
|
from .jobs import JobService
|
||||||
from .transcription import TranscriptionService
|
from .people import PeopleService
|
||||||
|
from .photos import PhotosService
|
||||||
|
from .prompts import PromptStore
|
||||||
|
from .sources import SourceService
|
||||||
|
|
||||||
__all__ = ["DocumentService", "JobService", "ServiceBundle", "TranscriptionService"]
|
__all__ = [
|
||||||
|
"DocumentService",
|
||||||
|
"EvidenceService",
|
||||||
|
"JobService",
|
||||||
|
"PeopleService",
|
||||||
|
"PhotosService",
|
||||||
|
"PromptStore",
|
||||||
|
"ServiceBundle",
|
||||||
|
"SourceService",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -15,5 +33,31 @@ class ServiceBundle:
|
|||||||
"""Container for all service instances."""
|
"""Container for all service instances."""
|
||||||
|
|
||||||
documents: DocumentService = field(default_factory=DocumentService)
|
documents: DocumentService = field(default_factory=DocumentService)
|
||||||
|
sources: SourceService = field(default_factory=SourceService)
|
||||||
jobs: JobService = field(default_factory=JobService)
|
jobs: JobService = field(default_factory=JobService)
|
||||||
transcriptions: TranscriptionService = field(default_factory=TranscriptionService)
|
people: PeopleService = field(default_factory=PeopleService)
|
||||||
|
photos: PhotosService = field(default_factory=PhotosService)
|
||||||
|
evidence: EvidenceService = field(default_factory=EvidenceService)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_session_factory(
|
||||||
|
cls,
|
||||||
|
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||||
|
*,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
) -> Self:
|
||||||
|
"""Build a bundle whose services all share one session factory and settings."""
|
||||||
|
if session_factory is None:
|
||||||
|
return cls()
|
||||||
|
return cls(
|
||||||
|
documents=DocumentService(session_factory=session_factory, settings=settings),
|
||||||
|
sources=SourceService(session_factory=session_factory, settings=settings),
|
||||||
|
jobs=JobService(session_factory=session_factory, settings=settings),
|
||||||
|
people=PeopleService(session_factory=session_factory, settings=settings),
|
||||||
|
photos=PhotosService(session_factory=session_factory, settings=settings),
|
||||||
|
evidence=EvidenceService(session_factory=session_factory, settings=settings),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Release provider resources held by the bundle."""
|
||||||
|
await self.sources.aclose()
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import asyncio
|
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from ..config import Settings
|
from ..config import Settings
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..db.runtime import get_session_factory
|
from ..db.session import resolve_session_factory
|
||||||
|
from ..db.session import session_scope
|
||||||
|
from ..errors import AppError
|
||||||
|
from ..errors import ErrorCategory
|
||||||
|
|
||||||
|
|
||||||
class ServiceBase(ABC):
|
class ServiceBase(ABC):
|
||||||
@@ -16,27 +19,23 @@ class ServiceBase(ABC):
|
|||||||
|
|
||||||
settings: Settings
|
settings: Settings
|
||||||
session_factory: async_sessionmaker[AsyncSession]
|
session_factory: async_sessionmaker[AsyncSession]
|
||||||
queue: asyncio.Queue
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||||
queue: asyncio.Queue | None = None,
|
settings: Settings | None = None,
|
||||||
):
|
):
|
||||||
self.settings = get_settings()
|
self.settings = settings or get_settings()
|
||||||
self.session_factory = session_factory or get_session_factory()
|
self.session_factory = session_factory or resolve_session_factory(settings=self.settings)
|
||||||
self.queue = queue or asyncio.Queue()
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _session_scope(self, session: AsyncSession | None = None):
|
async def _session_scope(self, session: AsyncSession | None = None):
|
||||||
"""Provide a transactional scope around a series of operations."""
|
"""Provide a transactional scope around a series of operations."""
|
||||||
if session is not None:
|
async with session_scope(
|
||||||
# Reuse the provided session if one is passed in
|
session_factory=self.session_factory,
|
||||||
yield session
|
session=session,
|
||||||
else:
|
) as active_session:
|
||||||
# Otherwise, create a new session for this scope
|
yield active_session
|
||||||
async with self.session_factory() as new_session:
|
|
||||||
yield new_session
|
|
||||||
|
|
||||||
async def _finalize(
|
async def _finalize(
|
||||||
self,
|
self,
|
||||||
@@ -58,3 +57,28 @@ class ServiceBase(ABC):
|
|||||||
|
|
||||||
for obj in refresh:
|
for obj in refresh:
|
||||||
await session.refresh(obj)
|
await session.refresh(obj)
|
||||||
|
|
||||||
|
async def _get_or_raise[ModelT](
|
||||||
|
self,
|
||||||
|
model: type[ModelT],
|
||||||
|
entity_id: object,
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
error: type[AppError],
|
||||||
|
noun: str,
|
||||||
|
suggestion: str,
|
||||||
|
options: Sequence[Any] = (),
|
||||||
|
) -> ModelT:
|
||||||
|
"""Load an entity by primary key or raise a not-found service error.
|
||||||
|
|
||||||
|
``noun`` and ``suggestion`` are supplied by the caller so each domain
|
||||||
|
keeps its own user-facing wording.
|
||||||
|
"""
|
||||||
|
entity = await session.get(model, entity_id, options=list(options) or None)
|
||||||
|
if entity is None:
|
||||||
|
raise error(
|
||||||
|
f"{noun} with id {entity_id} not found",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion=suggestion,
|
||||||
|
)
|
||||||
|
return entity
|
||||||
|
|||||||
@@ -1,18 +1,36 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import shutil
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
from sqlmodel import col
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ..config import Settings
|
||||||
|
from ..db.loading import orm_attribute
|
||||||
|
from ..db.loading import selectinload
|
||||||
|
from ..db.models import Document
|
||||||
|
from ..db.models import DocumentPerson
|
||||||
|
from ..db.models import DocumentTag
|
||||||
|
from ..db.models import DocumentType
|
||||||
|
from ..db.models import Tag
|
||||||
|
from ..db.registries import AUTHOR_ROLE_SEMANTIC_KEY
|
||||||
from ..errors import AppError
|
from ..errors import AppError
|
||||||
from ..errors import ErrorCategory
|
from ..errors import ErrorCategory
|
||||||
from ..models import Document
|
|
||||||
from .base import ServiceBase
|
from .base import ServiceBase
|
||||||
|
from .registry import RegistryService
|
||||||
|
from .registry import RegistrySummary
|
||||||
|
from .source_media import lookup_source_mime_type
|
||||||
|
from .source_media import supported_source_formats
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -21,31 +39,145 @@ class DocumentError(AppError):
|
|||||||
"""Raised when document operations fail."""
|
"""Raised when document operations fail."""
|
||||||
|
|
||||||
|
|
||||||
class MissingImageError(DocumentError):
|
class MissingSourceError(DocumentError):
|
||||||
"""Raised when a required image is missing."""
|
"""Raised when a document has no associated sources."""
|
||||||
|
|
||||||
|
|
||||||
class UploadError(DocumentError):
|
|
||||||
"""Raised when uploaded content cannot be persisted safely."""
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentAlreadyExistsError(DocumentError):
|
class DocumentAlreadyExistsError(DocumentError):
|
||||||
"""Raised when a document with the same filename already exists in the database."""
|
"""Raised when a document with the same name already exists in the database."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
class DocumentDeleteBlockedError(DocumentError):
|
||||||
class UploadJobResult:
|
"""Raised when a document delete is blocked by dependent records."""
|
||||||
"""Summary of created upload records."""
|
|
||||||
|
|
||||||
document_id: UUID
|
|
||||||
job_id: UUID
|
class DocumentTypeError(DocumentError):
|
||||||
stored_path: Path
|
"""Raised when Document Type maintenance fails."""
|
||||||
original_filename: str
|
|
||||||
|
|
||||||
|
class TagError(DocumentError):
|
||||||
|
"""Raised when Tag maintenance fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentTypeRegistry(RegistryService[DocumentType]):
|
||||||
|
"""Document Type registry maintenance."""
|
||||||
|
|
||||||
|
model = DocumentType
|
||||||
|
error = DocumentTypeError
|
||||||
|
noun = "Document Type"
|
||||||
|
short_noun = "type"
|
||||||
|
referenced_retainer = "historical Documents"
|
||||||
|
|
||||||
|
def reference_model(self) -> type[SQLModel]:
|
||||||
|
return Document
|
||||||
|
|
||||||
|
def reference_id_column(self) -> Any:
|
||||||
|
return col(Document.id)
|
||||||
|
|
||||||
|
def reference_key_column(self) -> Any:
|
||||||
|
return col(Document.document_type_id)
|
||||||
|
|
||||||
|
|
||||||
|
type DocumentTypeSummary = RegistrySummary
|
||||||
|
|
||||||
|
|
||||||
|
class TagRegistry(RegistryService[Tag]):
|
||||||
|
"""Tag registry maintenance."""
|
||||||
|
|
||||||
|
model = Tag
|
||||||
|
error = TagError
|
||||||
|
noun = "Tag"
|
||||||
|
short_noun = "tag"
|
||||||
|
referenced_retainer = "historical Documents"
|
||||||
|
|
||||||
|
def reference_model(self) -> type[SQLModel]:
|
||||||
|
return DocumentTag
|
||||||
|
|
||||||
|
def reference_id_column(self) -> Any:
|
||||||
|
return col(DocumentTag.id)
|
||||||
|
|
||||||
|
def reference_key_column(self) -> Any:
|
||||||
|
return col(DocumentTag.tag_id)
|
||||||
|
|
||||||
|
|
||||||
|
type TagSummary = RegistrySummary
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DocumentPrintSource:
|
||||||
|
id: UUID
|
||||||
|
page_number: int
|
||||||
|
media_type: str
|
||||||
|
current_text: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DocumentPrintJob:
|
||||||
|
id: UUID
|
||||||
|
date_created: datetime
|
||||||
|
provider: str | None
|
||||||
|
model: str | None
|
||||||
|
prompt_name: str | None
|
||||||
|
retry_count: int
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DocumentPrintProjection:
|
||||||
|
id: UUID
|
||||||
|
title: str
|
||||||
|
document_type: str | None
|
||||||
|
authors: tuple[str, ...]
|
||||||
|
document_date: date | None
|
||||||
|
document_date_raw: str | None
|
||||||
|
location_created: str | None
|
||||||
|
archive_identifier: str | None
|
||||||
|
notes: str | None
|
||||||
|
sources: tuple[DocumentPrintSource, ...]
|
||||||
|
jobs: tuple[DocumentPrintJob, ...]
|
||||||
|
|
||||||
|
|
||||||
class DocumentService(ServiceBase):
|
class DocumentService(ServiceBase):
|
||||||
"""Thin service class for managing documents in the database."""
|
"""Thin service class for managing documents in the database."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(session_factory, settings)
|
||||||
|
self._document_types = DocumentTypeRegistry(self.session_factory, self.settings)
|
||||||
|
self._tags = TagRegistry(self.session_factory, self.settings)
|
||||||
|
|
||||||
|
async def _validate_document_type(self, *, session: AsyncSession, document: Document) -> None:
|
||||||
|
"""Validate the UUID-backed Document Type reference."""
|
||||||
|
if document.document_type_id is None:
|
||||||
|
return
|
||||||
|
if await session.get(DocumentType, document.document_type_id) is None:
|
||||||
|
raise DocumentError(
|
||||||
|
f"Document type with id {document.document_type_id} not found",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Select a valid document type and retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _read_document(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
document_id: UUID,
|
||||||
|
options: Sequence[Any] = (),
|
||||||
|
suggestion: str = "Verify the document id and retry.",
|
||||||
|
) -> Document:
|
||||||
|
return await self._get_or_raise(
|
||||||
|
Document,
|
||||||
|
document_id,
|
||||||
|
session=session,
|
||||||
|
error=DocumentError,
|
||||||
|
noun="Document",
|
||||||
|
suggestion=suggestion,
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
|
||||||
#
|
#
|
||||||
# CRUD Operations
|
# CRUD Operations
|
||||||
#
|
#
|
||||||
@@ -58,6 +190,7 @@ class DocumentService(ServiceBase):
|
|||||||
) -> Document:
|
) -> Document:
|
||||||
"""Create a new document in the database."""
|
"""Create a new document in the database."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
|
await self._validate_document_type(session=_session, document=document)
|
||||||
_session.add(document)
|
_session.add(document)
|
||||||
try:
|
try:
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(document,))
|
await self._finalize(session=_session, caller_session=session, refresh=(document,))
|
||||||
@@ -72,56 +205,426 @@ class DocumentService(ServiceBase):
|
|||||||
async def read_document(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
|
async def read_document(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
|
||||||
"""Read an existing document from the database.
|
"""Read an existing document from the database.
|
||||||
|
|
||||||
The selectinload option is used to eagerly load related jobs for the document.
|
The selectinload option is used to eagerly load related jobs and sources.
|
||||||
"""
|
"""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
document = await _session.get(
|
document = await self._read_document(
|
||||||
Document,
|
session=_session,
|
||||||
document_id,
|
document_id=document_id,
|
||||||
options=(selectinload(Document.jobs),), # pyright: ignore[reportArgumentType]
|
options=(
|
||||||
)
|
selectinload(Document.jobs),
|
||||||
if document is None:
|
selectinload(Document.sources),
|
||||||
raise DocumentError(
|
),
|
||||||
f"Document with id {document_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Re-upload the source document and retry.",
|
suggestion="Re-upload the source document and retry.",
|
||||||
)
|
)
|
||||||
elif not Path(document.file_path).exists():
|
if not document.sources:
|
||||||
raise MissingImageError(
|
raise MissingSourceError(
|
||||||
f"Document with id {document_id} is missing its image file in {self.settings.upload_dir:!s}",
|
f"Document with id {document_id} has no associated source records",
|
||||||
category=ErrorCategory.NOT_FOUND,
|
category=ErrorCategory.NOT_FOUND,
|
||||||
suggestion="Re-upload the source document and retry.",
|
suggestion="Upload at least one source for this document and retry.",
|
||||||
)
|
)
|
||||||
return document
|
return document
|
||||||
|
|
||||||
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
|
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
|
||||||
"""Update an existing document in the database."""
|
"""Update an existing document in the database."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
|
await self._validate_document_type(session=_session, document=document)
|
||||||
merged = await _session.merge(document)
|
merged = await _session.merge(document)
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
|
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
|
||||||
"""Delete a document from the database."""
|
"""Delete a document from the database."""
|
||||||
|
document_id = document.id
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
await _session.delete(document)
|
existing = await self._read_document(
|
||||||
|
session=_session,
|
||||||
|
document_id=document.id,
|
||||||
|
options=(
|
||||||
|
selectinload(Document.jobs),
|
||||||
|
selectinload(Document.sources),
|
||||||
|
selectinload(Document.document_people),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
has_jobs = bool(existing.jobs)
|
||||||
|
has_sources = bool(existing.sources)
|
||||||
|
if has_jobs or has_sources:
|
||||||
|
blocked_by: list[str] = []
|
||||||
|
if has_sources:
|
||||||
|
blocked_by.append("Sources")
|
||||||
|
if has_jobs:
|
||||||
|
blocked_by.append("Jobs")
|
||||||
|
raise DocumentDeleteBlockedError(
|
||||||
|
f"Document delete blocked by related records: {', '.join(blocked_by)}",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Remove related Sources and Jobs first, then retry deletion.",
|
||||||
|
)
|
||||||
|
|
||||||
|
for link in list(existing.document_people):
|
||||||
|
await _session.delete(link)
|
||||||
|
|
||||||
|
await _session.delete(existing)
|
||||||
await self._finalize(session=_session, caller_session=session)
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
|
self._delete_document_storage_folder(document_id=document_id)
|
||||||
|
|
||||||
|
def _delete_document_storage_folder(self, *, document_id: UUID) -> None:
|
||||||
|
"""Best-effort cleanup for document-scoped source storage."""
|
||||||
|
document_dir = self.settings.upload_dir / "documents" / str(document_id)
|
||||||
|
if not document_dir.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.rmtree(document_dir)
|
||||||
|
logger.info("Deleted document storage folder: %s", document_dir)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Failed to delete document storage folder: %s", document_dir)
|
||||||
|
|
||||||
# Query Operations
|
# Query Operations
|
||||||
|
|
||||||
async def query_documents(
|
async def query_documents(
|
||||||
self, *, filename: str | None = None, session: AsyncSession | None = None
|
self, *, name: str | None = None, session: AsyncSession | None = None
|
||||||
) -> Sequence[Document]:
|
) -> Sequence[Document]:
|
||||||
"""Query documents from the database based on provided filters."""
|
"""Query documents from the database based on provided filters."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = select(Document)
|
query = select(Document)
|
||||||
if filename is not None:
|
if name is not None:
|
||||||
query = query.where(Document.filename == filename)
|
query = query.where(Document.name == name)
|
||||||
result = await _session.exec(query)
|
result = await _session.exec(query)
|
||||||
return result.all()
|
return result.all()
|
||||||
|
|
||||||
async def list_documents(self, *, session: AsyncSession | None = None) -> Sequence[Document]:
|
async def list_documents(self, *, session: AsyncSession | None = None) -> Sequence[Document]:
|
||||||
"""List all documents in the database."""
|
"""List documents with relations needed by the archival table."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
result = await _session.exec(select(Document))
|
query = select(Document).options(
|
||||||
|
selectinload(Document.document_people).selectinload(orm_attribute(DocumentPerson.person)),
|
||||||
|
selectinload(Document.document_people).selectinload(orm_attribute(DocumentPerson.role_ref)),
|
||||||
|
selectinload(Document.document_type_ref),
|
||||||
|
selectinload(Document.document_tags).selectinload(orm_attribute(DocumentTag.tag_ref)),
|
||||||
|
selectinload(Document.sources),
|
||||||
|
)
|
||||||
|
result = await _session.exec(query)
|
||||||
return result.all()
|
return result.all()
|
||||||
|
|
||||||
|
async def read_document_detail(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
|
||||||
|
"""Read a document with eagerly loaded relations for UI detail rendering."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = (
|
||||||
|
select(Document)
|
||||||
|
.options(
|
||||||
|
selectinload(Document.jobs),
|
||||||
|
selectinload(Document.sources),
|
||||||
|
selectinload(Document.document_people).selectinload(orm_attribute(DocumentPerson.person)),
|
||||||
|
selectinload(Document.document_people).selectinload(orm_attribute(DocumentPerson.role_ref)),
|
||||||
|
selectinload(Document.document_type_ref),
|
||||||
|
selectinload(Document.document_tags).selectinload(orm_attribute(DocumentTag.tag_ref)),
|
||||||
|
)
|
||||||
|
.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 read_document_print_projection(
|
||||||
|
self,
|
||||||
|
document_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> DocumentPrintProjection:
|
||||||
|
"""Build the safe, deterministic read model used by print previews."""
|
||||||
|
document = await self.read_document_detail(document_id, session=session)
|
||||||
|
authors = sorted(
|
||||||
|
(
|
||||||
|
link.person.full_name
|
||||||
|
for link in document.document_people
|
||||||
|
if link.person is not None
|
||||||
|
and link.role_ref is not None
|
||||||
|
and link.role_ref.semantic_key == AUTHOR_ROLE_SEMANTIC_KEY
|
||||||
|
),
|
||||||
|
key=str.casefold,
|
||||||
|
)
|
||||||
|
sources = tuple(
|
||||||
|
DocumentPrintSource(
|
||||||
|
id=source.id,
|
||||||
|
page_number=source.page_number,
|
||||||
|
media_type=_print_media_type(source.filename),
|
||||||
|
current_text=_current_print_text(source.revised_text, source.raw_transcription),
|
||||||
|
)
|
||||||
|
for source in sorted(document.sources, key=lambda item: (item.page_number, item.id))
|
||||||
|
)
|
||||||
|
jobs = tuple(
|
||||||
|
DocumentPrintJob(
|
||||||
|
id=job.id,
|
||||||
|
date_created=job.date_created,
|
||||||
|
provider=job.provider,
|
||||||
|
model=job.model,
|
||||||
|
prompt_name=job.prompt_name,
|
||||||
|
retry_count=job.retry_count,
|
||||||
|
status=getattr(job.status, "value", str(job.status)),
|
||||||
|
)
|
||||||
|
for job in sorted(document.jobs, key=lambda item: (item.date_created, item.id))
|
||||||
|
)
|
||||||
|
return DocumentPrintProjection(
|
||||||
|
id=document.id,
|
||||||
|
title=document.name,
|
||||||
|
document_type=document.document_type_ref.label if document.document_type_ref is not None else None,
|
||||||
|
authors=tuple(authors),
|
||||||
|
document_date=document.document_date,
|
||||||
|
document_date_raw=document.document_date_raw,
|
||||||
|
location_created=document.location_created,
|
||||||
|
archive_identifier=document.archive_identifier,
|
||||||
|
notes=document.notes,
|
||||||
|
sources=sources,
|
||||||
|
jobs=jobs,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_document_types(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
active_only: bool = True,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[DocumentType]:
|
||||||
|
"""List configured document types."""
|
||||||
|
return await self._document_types.list_entries(active_only=active_only, session=session)
|
||||||
|
|
||||||
|
async def list_tags(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
active_only: bool = True,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[Tag]:
|
||||||
|
"""List configured tags."""
|
||||||
|
return await self._tags.list_entries(active_only=active_only, session=session)
|
||||||
|
|
||||||
|
async def list_document_type_summaries(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[DocumentTypeSummary]:
|
||||||
|
"""List Document Types alphabetically with current usage counts."""
|
||||||
|
rows = await self._document_types.list_entries_with_counts(session=session)
|
||||||
|
return [
|
||||||
|
RegistrySummary(
|
||||||
|
id=document_type.id,
|
||||||
|
label=document_type.label,
|
||||||
|
is_active=document_type.is_active,
|
||||||
|
is_built_in=document_type.semantic_key is not None,
|
||||||
|
reference_count=document_count,
|
||||||
|
)
|
||||||
|
for document_type, document_count in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def list_tag_summaries(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[TagSummary]:
|
||||||
|
"""List Tags alphabetically with current usage counts."""
|
||||||
|
rows = await self._tags.list_entries_with_counts(session=session)
|
||||||
|
return [
|
||||||
|
RegistrySummary(
|
||||||
|
id=tag.id,
|
||||||
|
label=tag.label,
|
||||||
|
is_active=tag.is_active,
|
||||||
|
is_built_in=tag.semantic_key is not None,
|
||||||
|
reference_count=document_count,
|
||||||
|
)
|
||||||
|
for tag, document_count in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create_document_type(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
is_active: bool = True,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> DocumentType:
|
||||||
|
"""Create a UUID-identified Document Type with a unique label."""
|
||||||
|
return await self._document_types.create_entry(label=label, is_active=is_active, session=session)
|
||||||
|
|
||||||
|
async def create_tag(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
is_active: bool = True,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Tag:
|
||||||
|
"""Create a UUID-identified Tag with a unique label."""
|
||||||
|
return await self._tags.create_entry(label=label, is_active=is_active, session=session)
|
||||||
|
|
||||||
|
async def read_document_type(
|
||||||
|
self,
|
||||||
|
document_type_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> DocumentType:
|
||||||
|
"""Read a Document Type by id."""
|
||||||
|
return await self._document_types.read_entry(document_type_id, session=session)
|
||||||
|
|
||||||
|
async def read_tag(
|
||||||
|
self,
|
||||||
|
tag_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Tag:
|
||||||
|
"""Read a Tag by id."""
|
||||||
|
return await self._tags.read_entry(tag_id, session=session)
|
||||||
|
|
||||||
|
async def update_document_type(
|
||||||
|
self,
|
||||||
|
document_type_id: UUID,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
is_active: bool,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> DocumentType:
|
||||||
|
"""Update a Document Type label and active state."""
|
||||||
|
return await self._document_types.update_entry(
|
||||||
|
document_type_id,
|
||||||
|
label=label,
|
||||||
|
is_active=is_active,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_tag(
|
||||||
|
self,
|
||||||
|
tag_id: UUID,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
is_active: bool,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Tag:
|
||||||
|
"""Update a Tag label and active state."""
|
||||||
|
return await self._tags.update_entry(
|
||||||
|
tag_id,
|
||||||
|
label=label,
|
||||||
|
is_active=is_active,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_document_type(
|
||||||
|
self,
|
||||||
|
document_type_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Delete an unreferenced Document Type without cascade behavior."""
|
||||||
|
await self._document_types.delete_entry(document_type_id, session=session)
|
||||||
|
|
||||||
|
async def delete_tag(
|
||||||
|
self,
|
||||||
|
tag_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Delete an unreferenced Tag without cascade behavior."""
|
||||||
|
await self._tags.delete_entry(tag_id, session=session)
|
||||||
|
|
||||||
|
async def is_document_type_referenced(
|
||||||
|
self,
|
||||||
|
document_type_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether a Document references a Document Type."""
|
||||||
|
return await self._document_types.is_referenced(document_type_id, session=session)
|
||||||
|
|
||||||
|
async def is_tag_referenced(
|
||||||
|
self,
|
||||||
|
tag_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether a Document references a Tag."""
|
||||||
|
return await self._tags.is_referenced(tag_id, session=session)
|
||||||
|
|
||||||
|
async def set_document_type(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_id: UUID,
|
||||||
|
document_type_id: UUID,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Document:
|
||||||
|
"""Set a Document Type by UUID."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
document = await self._read_document(session=_session, document_id=document_id)
|
||||||
|
document.document_type_id = document_type_id
|
||||||
|
await self._validate_document_type(session=_session, document=document)
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(document,))
|
||||||
|
return document
|
||||||
|
|
||||||
|
async def sync_document_tags_by_labels(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_id: UUID,
|
||||||
|
labels: Sequence[str],
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Replace a Document's tag set using label-based assignment."""
|
||||||
|
normalized_labels = [self._tags.normalize_label(label) for label in labels]
|
||||||
|
deduplicated_labels = list(dict.fromkeys(normalized_labels))
|
||||||
|
label_keys = [self._tags.label_key(label) for label in deduplicated_labels]
|
||||||
|
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
existing_document = await _session.get(Document, document_id)
|
||||||
|
if existing_document is None:
|
||||||
|
raise DocumentError(
|
||||||
|
f"Document with id {document_id} not found",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Refresh and select an existing document.",
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_tags = (
|
||||||
|
(await _session.exec(select(Tag).where(col(Tag.normalized_label).in_(label_keys))))
|
||||||
|
.all()
|
||||||
|
if label_keys
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
tags_by_key = {tag.normalized_label: tag for tag in existing_tags}
|
||||||
|
selected_tag_ids: set[UUID] = set()
|
||||||
|
|
||||||
|
for label in deduplicated_labels:
|
||||||
|
key = self._tags.label_key(label)
|
||||||
|
tag = tags_by_key.get(key)
|
||||||
|
if tag is None:
|
||||||
|
tag = await self._tags.create_entry(label=label, is_active=True, session=_session)
|
||||||
|
tags_by_key[key] = tag
|
||||||
|
selected_tag_ids.add(tag.id)
|
||||||
|
|
||||||
|
links = (
|
||||||
|
await _session.exec(select(DocumentTag).where(DocumentTag.document_id == document_id))
|
||||||
|
).all()
|
||||||
|
existing_ids = {link.tag_id for link in links}
|
||||||
|
|
||||||
|
for link in links:
|
||||||
|
if link.tag_id not in selected_tag_ids:
|
||||||
|
await _session.delete(link)
|
||||||
|
|
||||||
|
for tag_id in selected_tag_ids - existing_ids:
|
||||||
|
_session.add(DocumentTag(document_id=document_id, tag_id=tag_id))
|
||||||
|
|
||||||
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_media_type(filename: str) -> str:
|
||||||
|
"""Resolve a stored Source filename to its MIME type for print rendering."""
|
||||||
|
mime_type = lookup_source_mime_type(filename)
|
||||||
|
if mime_type is None:
|
||||||
|
raise DocumentError(
|
||||||
|
f"Unsupported Source format: {Path(filename).suffix.lower() or '<none>'}",
|
||||||
|
category=ErrorCategory.USER_INPUT,
|
||||||
|
suggestion=f"Use one of the supported Source formats: {supported_source_formats()}.",
|
||||||
|
)
|
||||||
|
return mime_type
|
||||||
|
|
||||||
|
|
||||||
|
def _current_print_text(revised_text: str | None, raw_transcription: str | None) -> str | None:
|
||||||
|
selected = revised_text if revised_text is not None else raw_transcription
|
||||||
|
return selected if selected is not None and selected.strip() else None
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Error vocabulary shared across the source, evidence, and prompt services.
|
||||||
|
|
||||||
|
These live in a neutral module rather than in the service that raises them
|
||||||
|
because more than one service raises them, and ``services.instructions.md``
|
||||||
|
forbids a service module from importing a sibling. Orchestration modules and
|
||||||
|
the UI import from here, so the exception a caller catches does not change when
|
||||||
|
an operation moves between services.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from transcription.errors import AppError
|
||||||
|
|
||||||
|
|
||||||
|
class PromptLoadError(AppError):
|
||||||
|
"""Raised when prompt artifacts cannot be loaded safely."""
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptionError(AppError):
|
||||||
|
"""Raised when transcription execution fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptionNotFoundError(TranscriptionError):
|
||||||
|
"""Raised when a transcription-related resource is not found."""
|
||||||
|
|
||||||
|
|
||||||
|
class SourceDeleteBlockedError(TranscriptionError):
|
||||||
|
"""Raised when source deletion is blocked by dependency policy."""
|
||||||
|
|
||||||
|
|
||||||
|
class CandidatePromotionError(TranscriptionError):
|
||||||
|
"""Raised when a machine attempt cannot be selected for its Source."""
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Read and export the immutable execution evidence trail.
|
||||||
|
|
||||||
|
``ExecutionAttempt`` is append-only: one row per provider call, written once by
|
||||||
|
the transcription workflow and never updated. Everything here is therefore a
|
||||||
|
read, a projection, or an export, with one exception - ``promote_machine_attempt``
|
||||||
|
selects which attempt a ``Source`` presents, which is an evidence decision even
|
||||||
|
though the write lands on ``Source``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import JsonValue
|
||||||
|
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||||
|
from sqlmodel import col
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from transcription.db.models import ExecutionAttempt
|
||||||
|
from transcription.db.models import JobSourceStatus
|
||||||
|
from transcription.db.models import Source
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
|
||||||
|
from ..db.loading import defer
|
||||||
|
from .base import ServiceBase
|
||||||
|
from .errors import CandidatePromotionError
|
||||||
|
from .errors import TranscriptionNotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LatestExecutionAttempt:
|
||||||
|
"""One execution attempt plus the loader facts a caller needs to render it."""
|
||||||
|
|
||||||
|
attempt: ExecutionAttempt
|
||||||
|
transport_body_deferred: bool
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceService(ServiceBase):
|
||||||
|
"""Read, project, and export execution attempt evidence."""
|
||||||
|
|
||||||
|
async def read_latest_execution_attempt(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
job_source_id: UUID,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> LatestExecutionAttempt | None:
|
||||||
|
"""Read only the latest immutable attempt for one compatibility projection.
|
||||||
|
|
||||||
|
The transport body is deferred because it can be arbitrarily large; the
|
||||||
|
returned read model reports that as a plain flag so callers never have to
|
||||||
|
inspect ORM loader state.
|
||||||
|
"""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = (
|
||||||
|
select(ExecutionAttempt)
|
||||||
|
.options(defer(ExecutionAttempt.transport_body))
|
||||||
|
.where(ExecutionAttempt.job_source_id == job_source_id)
|
||||||
|
.order_by(
|
||||||
|
col(ExecutionAttempt.attempt_number).desc(),
|
||||||
|
col(ExecutionAttempt.id).desc(),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
attempt = (await _session.exec(query)).first()
|
||||||
|
if attempt is None:
|
||||||
|
return None
|
||||||
|
deferred = "transport_body" in sqlalchemy_inspect(attempt).unloaded
|
||||||
|
return LatestExecutionAttempt(attempt=attempt, transport_body_deferred=deferred)
|
||||||
|
|
||||||
|
async def list_execution_attempts(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_id: UUID | None = None,
|
||||||
|
job_id: UUID | None = None,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[ExecutionAttempt]:
|
||||||
|
"""List immutable execution evidence in stable attempt order."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = select(ExecutionAttempt)
|
||||||
|
if source_id is not None:
|
||||||
|
query = query.where(ExecutionAttempt.source_id == source_id)
|
||||||
|
if job_id is not None:
|
||||||
|
query = query.where(ExecutionAttempt.job_id == job_id)
|
||||||
|
query = query.order_by(
|
||||||
|
col(ExecutionAttempt.job_id),
|
||||||
|
col(ExecutionAttempt.source_id),
|
||||||
|
col(ExecutionAttempt.attempt_number),
|
||||||
|
col(ExecutionAttempt.id),
|
||||||
|
)
|
||||||
|
return (await _session.exec(query)).all()
|
||||||
|
|
||||||
|
async def promote_machine_attempt(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_id: UUID,
|
||||||
|
execution_attempt_id: UUID,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Source:
|
||||||
|
"""Atomically select one successful machine attempt as the Source projection."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
source = await self._read_source(
|
||||||
|
session=_session,
|
||||||
|
source_id=source_id,
|
||||||
|
suggestion="Refresh Source Detail and retry.",
|
||||||
|
)
|
||||||
|
attempt = await _session.get(ExecutionAttempt, execution_attempt_id)
|
||||||
|
if (
|
||||||
|
attempt is None
|
||||||
|
or attempt.source_id != source_id
|
||||||
|
or attempt.status != JobSourceStatus.TRANSCRIBED
|
||||||
|
or not attempt.raw_transcription
|
||||||
|
):
|
||||||
|
raise CandidatePromotionError(
|
||||||
|
"Only a successful transcription attempt belonging to this Source can be selected",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Select an available successful candidate from Source Detail.",
|
||||||
|
)
|
||||||
|
source.preferred_execution_attempt_id = attempt.id
|
||||||
|
source.raw_transcription = attempt.raw_transcription
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(source,))
|
||||||
|
return source
|
||||||
|
|
||||||
|
async def build_evidence_export(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_id: UUID,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> dict[str, JsonValue]:
|
||||||
|
"""Build a versioned, source-reference-only evidence export."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
source = await self._read_source(session=_session, source_id=source_id)
|
||||||
|
attempts = list(await self.list_execution_attempts(source_id=source_id, session=_session))
|
||||||
|
|
||||||
|
attempt_payloads = [
|
||||||
|
{
|
||||||
|
"id": str(attempt.id),
|
||||||
|
"job_id": str(attempt.job_id),
|
||||||
|
"source_id": str(attempt.source_id),
|
||||||
|
"attempt_number": attempt.attempt_number,
|
||||||
|
"status": attempt.status.value,
|
||||||
|
"provider": attempt.provider,
|
||||||
|
"model": attempt.model,
|
||||||
|
"request_manifest": attempt.request_manifest,
|
||||||
|
"request_manifest_sha256": attempt.request_manifest_sha256,
|
||||||
|
"request_manifest_schema_version": attempt.request_manifest_schema_version,
|
||||||
|
"transport": {
|
||||||
|
"response_received": attempt.response_received,
|
||||||
|
"status_code": attempt.transport_status_code,
|
||||||
|
"body_base64": (
|
||||||
|
base64.b64encode(attempt.transport_body).decode("ascii")
|
||||||
|
if attempt.transport_body is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"body_sha256": (
|
||||||
|
hashlib.sha256(attempt.transport_body).hexdigest()
|
||||||
|
if attempt.transport_body is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"content_type": attempt.transport_content_type,
|
||||||
|
"content_encoding": attempt.transport_content_encoding,
|
||||||
|
"safe_headers": attempt.transport_safe_headers,
|
||||||
|
"request_id": attempt.router_request_id,
|
||||||
|
"generation_id": attempt.router_generation_id,
|
||||||
|
},
|
||||||
|
"sdk_response_snapshot": attempt.sdk_response_snapshot,
|
||||||
|
"normalized_metadata": attempt.normalized_metadata,
|
||||||
|
"software_context": attempt.software_context,
|
||||||
|
"raw_transcription": attempt.raw_transcription,
|
||||||
|
"error_category": attempt.error_category,
|
||||||
|
"error_detail": attempt.error_detail,
|
||||||
|
"failure_phase": attempt.failure_phase,
|
||||||
|
"started_at": attempt.started_at.isoformat(),
|
||||||
|
"finished_at": attempt.finished_at.isoformat(),
|
||||||
|
"duration_ms": attempt.duration_ms,
|
||||||
|
}
|
||||||
|
for attempt in attempts
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"schema_name": "transcription.evidence-export",
|
||||||
|
"schema_version": "1",
|
||||||
|
"source": {
|
||||||
|
"id": str(source.id),
|
||||||
|
"digest_sha256": source.file_hash,
|
||||||
|
"byte_size": source.file_size_bytes,
|
||||||
|
"page_number": source.page_number,
|
||||||
|
"upload_name": source.upload_name,
|
||||||
|
},
|
||||||
|
"attempts": attempt_payloads,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _read_source(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
source_id: UUID,
|
||||||
|
suggestion: str = "Verify the source id and retry.",
|
||||||
|
) -> Source:
|
||||||
|
return await self._get_or_raise(
|
||||||
|
Source,
|
||||||
|
source_id,
|
||||||
|
session=session,
|
||||||
|
error=TranscriptionNotFoundError,
|
||||||
|
noun="Source",
|
||||||
|
suggestion=suggestion,
|
||||||
|
)
|
||||||
@@ -1,16 +1,46 @@
|
|||||||
|
import logging
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy import update
|
||||||
|
from sqlmodel import col
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from ..models import Job
|
from ..db.loading import orm_attribute
|
||||||
from ..models import JobStatus
|
from ..db.loading import selectinload
|
||||||
|
from ..db.models import ExecutionAttempt
|
||||||
|
from ..db.models import Document
|
||||||
|
from ..db.models import Job
|
||||||
|
from ..db.models import JobSource
|
||||||
|
from ..db.models import JobSourceStatus
|
||||||
|
from ..db.models import JobStatus
|
||||||
|
from ..db.models import Source
|
||||||
|
from ..errors import AppError
|
||||||
|
from ..errors import ErrorCategory
|
||||||
from .base import ServiceBase
|
from .base import ServiceBase
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class JobDeleteBlockedError(AppError):
|
||||||
|
"""Raised when a job delete operation is blocked by lifecycle policy."""
|
||||||
|
|
||||||
|
|
||||||
|
class JobCancelBlockedError(AppError):
|
||||||
|
"""Raised when a job cancel operation is blocked by lifecycle policy."""
|
||||||
|
|
||||||
|
|
||||||
|
class JobResubmitBlockedError(AppError):
|
||||||
|
"""Raised when a job resubmit operation is blocked by lifecycle policy."""
|
||||||
|
|
||||||
|
|
||||||
|
class JobNotFoundError(AppError):
|
||||||
|
"""Raised when a requested Job does not exist."""
|
||||||
|
|
||||||
|
|
||||||
class JobService(ServiceBase):
|
class JobService(ServiceBase):
|
||||||
"""Thin service class for managing jobs in the database."""
|
"""Thin service class for managing jobs in the database."""
|
||||||
@@ -36,15 +66,15 @@ class JobService(ServiceBase):
|
|||||||
query = (
|
query = (
|
||||||
select(Job)
|
select(Job)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
selectinload(Job.document).selectinload(orm_attribute(Document.sources)),
|
||||||
selectinload(Job.transcripts), # pyright: ignore[reportArgumentType]
|
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
|
||||||
)
|
)
|
||||||
.where(Job.id == job_id)
|
.where(Job.id == job_id)
|
||||||
.execution_options(populate_existing=True)
|
.execution_options(populate_existing=True)
|
||||||
)
|
)
|
||||||
job = (await _session.exec(query)).first()
|
job = (await _session.exec(query)).first()
|
||||||
if job is None:
|
if job is None:
|
||||||
raise ValueError(f"Job with id {job_id} not found")
|
raise self._not_found(job_id)
|
||||||
return job
|
return job
|
||||||
|
|
||||||
async def update_job(self, job: Job, session: AsyncSession | None = None) -> Job:
|
async def update_job(self, job: Job, session: AsyncSession | None = None) -> Job:
|
||||||
@@ -71,24 +101,30 @@ class JobService(ServiceBase):
|
|||||||
) -> Sequence[Job]:
|
) -> Sequence[Job]:
|
||||||
"""Query jobs from the database based on provided filters."""
|
"""Query jobs from the database based on provided filters."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = select(Job).options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
query = select(Job).options(
|
||||||
|
selectinload(Job.document),
|
||||||
|
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
|
||||||
|
)
|
||||||
if status is not None:
|
if status is not None:
|
||||||
query = query.where(Job.status == status)
|
query = query.where(Job.status == status)
|
||||||
if filename is not None:
|
if filename is not None:
|
||||||
query = query.where(Job.document.filename == filename)
|
query = query.where(
|
||||||
|
col(Job.job_sources).any(col(JobSource.source).has(col(Source.filename) == filename))
|
||||||
|
)
|
||||||
result = await _session.exec(query)
|
result = await _session.exec(query)
|
||||||
return result.all()
|
return result.all()
|
||||||
|
|
||||||
async def list_jobs(
|
async def list_jobs(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
load_docs: bool = False,
|
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> Sequence[Job]:
|
) -> Sequence[Job]:
|
||||||
"""List all jobs in the database with eagerly loaded documents."""
|
"""List all jobs in the database with eagerly loaded documents."""
|
||||||
_ = load_docs
|
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = select(Job).options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
query = select(Job).options(
|
||||||
|
selectinload(Job.document),
|
||||||
|
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
|
||||||
|
)
|
||||||
result = await _session.exec(query)
|
result = await _session.exec(query)
|
||||||
return result.all()
|
return result.all()
|
||||||
|
|
||||||
@@ -119,31 +155,269 @@ class JobService(ServiceBase):
|
|||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = (
|
query = (
|
||||||
select(Job)
|
select(Job)
|
||||||
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
.options(
|
||||||
|
selectinload(Job.document),
|
||||||
|
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
|
||||||
|
)
|
||||||
.where(Job.id == job_id)
|
.where(Job.id == job_id)
|
||||||
.execution_options(populate_existing=True)
|
.execution_options(populate_existing=True)
|
||||||
)
|
)
|
||||||
job = (await _session.exec(query)).first()
|
job = (await _session.exec(query)).first()
|
||||||
if job is None:
|
if job is None:
|
||||||
raise ValueError(f"Job with id {job_id} not found")
|
raise self._not_found(job_id)
|
||||||
job.status = status
|
job.status = status
|
||||||
if retry_count_increment:
|
if retry_count_increment:
|
||||||
job.retry_count += retry_count_increment
|
job.retry_count += retry_count_increment
|
||||||
job.updated_at = datetime.now(UTC)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
return job
|
return job
|
||||||
|
|
||||||
async def read_next_queued_job(
|
async def claim_next_queued_job(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> Job | None:
|
) -> Job | None:
|
||||||
"""Read the next queued job ordered by creation time."""
|
"""Atomically claim the oldest queued job by transitioning it to PROCESSING.
|
||||||
|
|
||||||
|
The selection is deliberately unadorned: no eager loads are applied to the
|
||||||
|
hot poll, because callers re-read the claimed job with the relationships
|
||||||
|
they actually need. On PostgreSQL the row is locked with ``SKIP LOCKED`` so
|
||||||
|
concurrent workers never contend for the same job.
|
||||||
|
"""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
dialect = _session.get_bind().dialect.name
|
||||||
|
if dialect == "postgresql":
|
||||||
|
query = (
|
||||||
|
select(Job)
|
||||||
|
.where(Job.status == JobStatus.QUEUED)
|
||||||
|
# Break ties by id so "next" is stable when two rows share close timestamps.
|
||||||
|
.order_by(col(Job.date_created), col(Job.id))
|
||||||
|
.limit(1)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
)
|
||||||
|
job = (await _session.exec(query)).first()
|
||||||
|
if job is None:
|
||||||
|
return None
|
||||||
|
job.status = JobStatus.PROCESSING
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
|
return job
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
queued_job_id = (
|
||||||
|
select(col(Job.id))
|
||||||
|
.where(col(Job.status) == JobStatus.QUEUED)
|
||||||
|
.order_by(col(Job.date_created), col(Job.id))
|
||||||
|
.limit(1)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
claim_statement = (
|
||||||
|
update(Job)
|
||||||
|
.where(col(Job.id) == queued_job_id)
|
||||||
|
.where(col(Job.status) == JobStatus.QUEUED)
|
||||||
|
.values(status=JobStatus.PROCESSING, date_updated=now)
|
||||||
|
.returning(col(Job.id))
|
||||||
|
)
|
||||||
|
claimed_row = (await _session.exec(claim_statement)).first()
|
||||||
|
if claimed_row is None:
|
||||||
|
return None
|
||||||
|
claimed_job_id = claimed_row if isinstance(claimed_row, UUID) else claimed_row[0]
|
||||||
|
|
||||||
|
job = (await _session.exec(select(Job).where(Job.id == claimed_job_id))).first()
|
||||||
|
if job is None:
|
||||||
|
return None
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
|
return job
|
||||||
|
|
||||||
|
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:
|
async with self._session_scope(session) as _session:
|
||||||
query = (
|
query = (
|
||||||
select(Job)
|
select(Job)
|
||||||
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
.options(selectinload(Job.job_sources))
|
||||||
.where(Job.status == JobStatus.QUEUED)
|
.where(Job.id == job_id)
|
||||||
.order_by(Job.created_at) # pyright: ignore[reportArgumentType]
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
job = (await _session.exec(query)).first()
|
||||||
|
if job is None:
|
||||||
|
raise self._not_found(job_id)
|
||||||
|
|
||||||
|
if job.status == JobStatus.PROCESSING:
|
||||||
|
raise JobDeleteBlockedError(
|
||||||
|
"Job delete blocked while status is processing",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Wait for processing to complete, or move the job out of processing before deleting.",
|
||||||
|
)
|
||||||
|
attempt_count = (
|
||||||
|
await _session.exec(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(ExecutionAttempt)
|
||||||
|
.where(ExecutionAttempt.job_id == job_id)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
if attempt_count:
|
||||||
|
raise JobDeleteBlockedError(
|
||||||
|
"Job delete blocked because immutable execution evidence exists",
|
||||||
|
category=ErrorCategory.CONFLICT,
|
||||||
|
suggestion=(
|
||||||
|
"Retain the Job as processing history. Evidence deletion requires "
|
||||||
|
"an explicit retention workflow."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for job_source in list(job.job_sources):
|
||||||
|
await _session.delete(job_source)
|
||||||
|
|
||||||
|
await _session.delete(job)
|
||||||
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
|
async def delete_job_and_evidence(self, *, job_id: UUID) -> None:
|
||||||
|
"""Explicitly delete a terminal job and all evidence owned by its attempts."""
|
||||||
|
async with self._session_scope() as session:
|
||||||
|
job = (
|
||||||
|
await session.exec(
|
||||||
|
select(Job)
|
||||||
|
.options(selectinload(Job.job_sources))
|
||||||
|
.where(Job.id == job_id)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if job is None:
|
||||||
|
raise self._not_found(job_id)
|
||||||
|
if job.status == JobStatus.PROCESSING:
|
||||||
|
raise JobDeleteBlockedError(
|
||||||
|
"Job delete blocked while status is processing",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Wait for processing to complete, or cancel it before deleting evidence.",
|
||||||
|
)
|
||||||
|
|
||||||
|
attempts = list(
|
||||||
|
(
|
||||||
|
await session.exec(
|
||||||
|
select(ExecutionAttempt).where(ExecutionAttempt.job_id == job_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
for attempt in attempts:
|
||||||
|
await session.delete(attempt)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
for job_source in list(job.job_sources):
|
||||||
|
await session.delete(job_source)
|
||||||
|
await session.flush()
|
||||||
|
await session.delete(job)
|
||||||
|
await self._finalize(session=session, caller_session=None)
|
||||||
|
|
||||||
|
async def cancel_job(self, *, job_id: UUID, session: AsyncSession | None = None) -> Job:
|
||||||
|
"""Cancel a queued/processing job and stop remaining source work."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = (
|
||||||
|
select(Job)
|
||||||
|
.options(
|
||||||
|
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
|
||||||
|
)
|
||||||
|
.where(Job.id == job_id)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
job = (await _session.exec(query)).first()
|
||||||
|
if job is None:
|
||||||
|
raise self._not_found(job_id)
|
||||||
|
|
||||||
|
if job.status == JobStatus.TRANSCRIBED:
|
||||||
|
raise JobCancelBlockedError(
|
||||||
|
"Job cancel is not allowed for transcribed jobs",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Use resubmit for reprocessing needs, or leave the terminal job unchanged.",
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
job.status = JobStatus.FAILED
|
||||||
|
job.date_updated = now
|
||||||
|
|
||||||
|
for job_source in job.job_sources:
|
||||||
|
if job_source.status == JobSourceStatus.TRANSCRIBED:
|
||||||
|
continue
|
||||||
|
job_source.status = JobSourceStatus.CANCELLED
|
||||||
|
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
|
return job
|
||||||
|
|
||||||
|
async def resubmit_failed_sources(self, *, job_id: UUID, session: AsyncSession | None = None) -> int:
|
||||||
|
"""Reset failed source executions and queue the job for reprocessing."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = (
|
||||||
|
select(Job)
|
||||||
|
.options(
|
||||||
|
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
|
||||||
|
)
|
||||||
|
.where(Job.id == job_id)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
job = (await _session.exec(query)).first()
|
||||||
|
if job is None:
|
||||||
|
raise self._not_found(job_id)
|
||||||
|
|
||||||
|
if job.status == JobStatus.PROCESSING:
|
||||||
|
raise JobResubmitBlockedError(
|
||||||
|
"Job resubmit is blocked while processing is active",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Cancel processing first, then resubmit remaining sources.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancelled pages are re-attemptable: older flows wrote FAILED,
|
||||||
|
# so resubmit already reset them. Excluding CANCELLED here would make
|
||||||
|
# cancelled work permanently unrecoverable.
|
||||||
|
resubmittable = {JobSourceStatus.FAILED, JobSourceStatus.CANCELLED}
|
||||||
|
candidates = [job_source for job_source in job.job_sources if job_source.status in resubmittable]
|
||||||
|
if not candidates:
|
||||||
|
raise JobResubmitBlockedError(
|
||||||
|
"Job has no failed or cancelled sources to resubmit",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Only failed or cancelled sources can be resubmitted.",
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for job_source in candidates:
|
||||||
|
job_source.status = JobSourceStatus.PENDING
|
||||||
|
|
||||||
|
job.status = JobStatus.QUEUED
|
||||||
|
job.date_updated = now
|
||||||
|
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
|
return len(candidates)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _not_found(job_id: UUID) -> JobNotFoundError:
|
||||||
|
return JobNotFoundError(
|
||||||
|
f"Job with id {job_id} not found",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Verify the Job id and retry.",
|
||||||
)
|
)
|
||||||
return (await _session.exec(query)).first()
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Single implementation for persisting uploaded media bytes to disk.
|
||||||
|
|
||||||
|
Source pages, Person portraits, and homepage images all follow the same
|
||||||
|
sequence: resolve a target directory, create it, write the bytes, and translate
|
||||||
|
an ``OSError`` into a domain error. The write itself runs on a worker thread so
|
||||||
|
it never blocks the event loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from ..errors import AppError
|
||||||
|
from ..errors import ErrorCategory
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_stored_filename(*, filename: str, filename_stem: str | None = None) -> str:
|
||||||
|
"""Return a safe stored filename preserving the submitted extension."""
|
||||||
|
safe_name = Path(filename).name
|
||||||
|
suffix = Path(safe_name).suffix.lower()
|
||||||
|
stem = filename_stem or str(uuid4())
|
||||||
|
return f"{stem}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
async def write_media_bytes(
|
||||||
|
*,
|
||||||
|
target_dir: Path,
|
||||||
|
stored_name: str,
|
||||||
|
file_bytes: bytes,
|
||||||
|
error: type[AppError],
|
||||||
|
failure_message: str,
|
||||||
|
failure_suggestion: str,
|
||||||
|
log_label: str,
|
||||||
|
) -> Path:
|
||||||
|
"""Create ``target_dir`` and write ``file_bytes`` into it off the event loop."""
|
||||||
|
stored_path = target_dir / stored_name
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_write, stored_path, file_bytes)
|
||||||
|
except OSError as exc:
|
||||||
|
raise error(
|
||||||
|
failure_message,
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion=failure_suggestion,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
logger.info("Stored %s: %s", log_label, stored_path)
|
||||||
|
return stored_path
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_named_media(
|
||||||
|
*,
|
||||||
|
root: Path,
|
||||||
|
filename: str,
|
||||||
|
file_bytes: bytes,
|
||||||
|
error: type[AppError],
|
||||||
|
failure_message: str,
|
||||||
|
failure_suggestion: str,
|
||||||
|
log_label: str,
|
||||||
|
namespace: Path | str | None = None,
|
||||||
|
filename_stem: str | None = None,
|
||||||
|
preserve_original_name: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
"""Resolve a target directory/name and persist media bytes safely."""
|
||||||
|
target_dir = root if namespace is None else root / Path(namespace)
|
||||||
|
stored_name = Path(filename).name if preserve_original_name else build_stored_filename(
|
||||||
|
filename=filename,
|
||||||
|
filename_stem=filename_stem,
|
||||||
|
)
|
||||||
|
return await write_media_bytes(
|
||||||
|
target_dir=target_dir,
|
||||||
|
stored_name=stored_name,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
error=error,
|
||||||
|
failure_message=failure_message,
|
||||||
|
failure_suggestion=failure_suggestion,
|
||||||
|
log_label=log_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write(stored_path: Path, file_bytes: bytes) -> None:
|
||||||
|
stored_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
stored_path.write_bytes(file_bytes)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Metadata-directed orientation normalization applied to image bytes at ingest.
|
||||||
|
|
||||||
|
Uploaded pages are stored upright, so nothing downstream has to derive a
|
||||||
|
rotated copy: every stored byte is already the byte the provider is sent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from PIL import JpegImagePlugin
|
||||||
|
from PIL import UnidentifiedImageError
|
||||||
|
from PIL.TiffImagePlugin import TiffImageFile
|
||||||
|
|
||||||
|
from transcription.errors import AppError
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ORIENTATION_TAG = 274
|
||||||
|
|
||||||
|
NORMALIZED_MEDIA_TYPES = frozenset({"image/jpeg", "image/png", "image/tiff"})
|
||||||
|
|
||||||
|
_TRANSPOSE_BY_ORIENTATION = {
|
||||||
|
3: (Image.Transpose.ROTATE_180, 180),
|
||||||
|
6: (Image.Transpose.ROTATE_270, 90),
|
||||||
|
8: (Image.Transpose.ROTATE_90, 270),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OrientationNormalizationError(AppError):
|
||||||
|
"""Raised when a supported raster image cannot be normalized safely."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OrientationNormalization:
|
||||||
|
"""Upright image bytes and the rotation that produced them."""
|
||||||
|
|
||||||
|
content: bytes
|
||||||
|
original_orientation: int
|
||||||
|
applied_rotation_degrees: int
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_orientation(content: bytes, *, media_type: str) -> OrientationNormalization | None:
|
||||||
|
"""Physically apply supported EXIF rotation, returning None for a safe no-op.
|
||||||
|
|
||||||
|
JPEG output reuses the source quantization tables and chroma subsampling
|
||||||
|
rather than re-quantizing at a fixed quality. Measured across the corpus
|
||||||
|
that is better on both axes at once - 51.5-55.0 dB PSNR against 50.0-53.5,
|
||||||
|
and slightly smaller output against 38% larger - and it imposes no
|
||||||
|
constraint on image dimensions.
|
||||||
|
|
||||||
|
Blocking. Async callers must use :func:`normalize_orientation_async`.
|
||||||
|
"""
|
||||||
|
if media_type not in NORMALIZED_MEDIA_TYPES:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
image_file = Image.open(io.BytesIO(content))
|
||||||
|
except (OSError, ValueError, UnidentifiedImageError):
|
||||||
|
# Undecodable content is not this function's business to reject. Ingest
|
||||||
|
# accepted such bytes before orientation moved here, and decision A
|
||||||
|
# forbids changing what an upload does.
|
||||||
|
logger.info("Skipped orientation normalization for undecodable content (%s)", media_type)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with image_file as image:
|
||||||
|
orientation = int(image.getexif().get(ORIENTATION_TAG, 1))
|
||||||
|
transformation = _TRANSPOSE_BY_ORIENTATION.get(orientation)
|
||||||
|
if transformation is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
transpose, rotation = transformation
|
||||||
|
# Pillow applies TIFF orientation while decoding; copying freezes those upright pixels.
|
||||||
|
normalized = image.copy() if isinstance(image, TiffImageFile) else image.transpose(transpose)
|
||||||
|
output = io.BytesIO()
|
||||||
|
exif = normalized.getexif()
|
||||||
|
if ORIENTATION_TAG in exif:
|
||||||
|
del exif[ORIENTATION_TAG]
|
||||||
|
save_kwargs: dict[str, object] = {"format": image.format}
|
||||||
|
if image.format in {"JPEG", "PNG"}:
|
||||||
|
save_kwargs["exif"] = exif.tobytes()
|
||||||
|
if isinstance(image, JpegImagePlugin.JpegImageFile):
|
||||||
|
# Reusing the source quantization tables and subsampling preserves fidelity
|
||||||
|
# at a smaller size than any re-encode quality setting.
|
||||||
|
save_kwargs.update(
|
||||||
|
{
|
||||||
|
"qtables": image.quantization,
|
||||||
|
"subsampling": JpegImagePlugin.get_sampling(image),
|
||||||
|
"optimize": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
normalized.save(output, **save_kwargs)
|
||||||
|
except (OSError, ValueError, UnidentifiedImageError) as exc:
|
||||||
|
raise OrientationNormalizationError(
|
||||||
|
"Source image orientation could not be normalized",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Verify that the uploaded Source is a valid supported raster image.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return OrientationNormalization(
|
||||||
|
content=output.getvalue(),
|
||||||
|
original_orientation=orientation,
|
||||||
|
applied_rotation_degrees=rotation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def normalize_orientation_async(content: bytes, *, media_type: str) -> OrientationNormalization | None:
|
||||||
|
"""Run :func:`normalize_orientation` off the event loop.
|
||||||
|
|
||||||
|
Pillow decode, transpose, and re-encode are CPU-bound and scale with page
|
||||||
|
size, so they must not run on the request or worker event loop ([MED-01]).
|
||||||
|
"""
|
||||||
|
return await asyncio.to_thread(normalize_orientation, content, media_type=media_type)
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
"""People, relationship role, and document-person link services."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
from sqlmodel import col
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ..config import Settings
|
||||||
|
from ..db.loading import orm_attribute
|
||||||
|
from ..db.loading import selectinload
|
||||||
|
from ..db.models import Document
|
||||||
|
from ..db.models import DocumentPerson
|
||||||
|
from ..db.models import Photo
|
||||||
|
from ..db.models import Person
|
||||||
|
from ..db.models import PersonRole
|
||||||
|
from ..errors import AppError
|
||||||
|
from ..errors import ErrorCategory
|
||||||
|
from .base import ServiceBase
|
||||||
|
from .registry import RegistryService
|
||||||
|
from .registry import RegistrySummary
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FAMILY_SEARCH_ID_PATTERN = re.compile(r"^[A-Z0-9]{4}-[A-Z0-9]{3}$")
|
||||||
|
|
||||||
|
|
||||||
|
class PeopleError(AppError):
|
||||||
|
"""Raised when a Person or document-person relationship operation fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class PersonRoleError(PeopleError):
|
||||||
|
"""Raised when Person Role maintenance fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class PersonRoleRegistry(RegistryService[PersonRole]):
|
||||||
|
"""Person Role registry maintenance."""
|
||||||
|
|
||||||
|
model = PersonRole
|
||||||
|
error = PersonRoleError
|
||||||
|
noun = "Person Role"
|
||||||
|
short_noun = "role"
|
||||||
|
referenced_retainer = "historical relationships"
|
||||||
|
|
||||||
|
def reference_model(self) -> type[SQLModel]:
|
||||||
|
return DocumentPerson
|
||||||
|
|
||||||
|
def reference_id_column(self) -> Any:
|
||||||
|
return col(DocumentPerson.id)
|
||||||
|
|
||||||
|
def reference_key_column(self) -> Any:
|
||||||
|
return col(DocumentPerson.role_id)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_family_search_id(value: str | None) -> str | None:
|
||||||
|
"""Normalize and validate a FamilySearch tree person identifier."""
|
||||||
|
normalized = (value or "").strip().upper()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
if not FAMILY_SEARCH_ID_PATTERN.fullmatch(normalized):
|
||||||
|
raise PeopleError(
|
||||||
|
"FamilySearch ID must use the format XXXX-XXX",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Enter the seven-character FamilySearch person ID, including its hyphen.",
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
type PersonRoleSummary = RegistrySummary
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DocumentPersonInput:
|
||||||
|
"""Complete desired relationship for one Person on a Document."""
|
||||||
|
|
||||||
|
person_id: UUID
|
||||||
|
role_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class PeopleService(ServiceBase):
|
||||||
|
"""Manage People, relationship roles, and document-person links."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(session_factory, settings)
|
||||||
|
self._person_roles = PersonRoleRegistry(self.session_factory, self.settings)
|
||||||
|
|
||||||
|
async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
person.family_search_id = normalize_family_search_id(person.family_search_id)
|
||||||
|
_session.add(person)
|
||||||
|
try:
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(person,))
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise self._family_search_conflict(person.family_search_id) from exc
|
||||||
|
return person
|
||||||
|
|
||||||
|
async def read_person(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
person = await _session.get(Person, person_id)
|
||||||
|
if person is None:
|
||||||
|
raise self._not_found(f"Person with id {person_id} not found")
|
||||||
|
return person
|
||||||
|
|
||||||
|
async def update_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
person.family_search_id = normalize_family_search_id(person.family_search_id)
|
||||||
|
merged = await _session.merge(person)
|
||||||
|
try:
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise self._family_search_conflict(person.family_search_id) from exc
|
||||||
|
return merged
|
||||||
|
|
||||||
|
async def delete_person(self, person: Person, *, session: AsyncSession | None = None) -> None:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
existing = await _session.get(
|
||||||
|
Person,
|
||||||
|
person.id,
|
||||||
|
options=(selectinload(Person.document_people), selectinload(Person.photos)),
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
raise self._not_found(f"Person with id {person.id} not found")
|
||||||
|
if existing.photos:
|
||||||
|
raise PeopleError(
|
||||||
|
"Person delete blocked by related records: Photos",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Delete or reassign Person photos before deleting this record.",
|
||||||
|
)
|
||||||
|
for link in list(existing.document_people):
|
||||||
|
await _session.delete(link)
|
||||||
|
await _session.delete(existing)
|
||||||
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
|
async def create_document_person(
|
||||||
|
self,
|
||||||
|
document_person: DocumentPerson,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> DocumentPerson:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
await self._validate_role(session=_session, role_id=document_person.role_id, require_active=True)
|
||||||
|
_session.add(document_person)
|
||||||
|
return await self._finalize_link(session=_session, caller_session=session, link=document_person)
|
||||||
|
|
||||||
|
async def read_document_person(
|
||||||
|
self,
|
||||||
|
document_person_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> DocumentPerson:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
link = await _session.get(DocumentPerson, document_person_id)
|
||||||
|
if link is None:
|
||||||
|
raise self._not_found(f"DocumentPerson with id {document_person_id} not found")
|
||||||
|
return link
|
||||||
|
|
||||||
|
async def update_document_person(
|
||||||
|
self,
|
||||||
|
document_person: DocumentPerson,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> DocumentPerson:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
existing = await _session.get(DocumentPerson, document_person.id)
|
||||||
|
if existing is None:
|
||||||
|
raise self._not_found(f"DocumentPerson with id {document_person.id} not found")
|
||||||
|
await self._validate_role(
|
||||||
|
session=_session,
|
||||||
|
role_id=document_person.role_id,
|
||||||
|
require_active=existing.role_id != document_person.role_id,
|
||||||
|
)
|
||||||
|
merged = await _session.merge(document_person)
|
||||||
|
return await self._finalize_link(session=_session, caller_session=session, link=merged)
|
||||||
|
|
||||||
|
async def delete_document_person(
|
||||||
|
self,
|
||||||
|
document_person: DocumentPerson,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
await _session.delete(document_person)
|
||||||
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
|
async def read_person_detail(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = (
|
||||||
|
select(Person)
|
||||||
|
.options(
|
||||||
|
selectinload(Person.document_people)
|
||||||
|
.selectinload(orm_attribute(DocumentPerson.document))
|
||||||
|
.selectinload(orm_attribute(Document.sources)),
|
||||||
|
selectinload(Person.document_people).selectinload(orm_attribute(DocumentPerson.role_ref)),
|
||||||
|
selectinload(Person.photos),
|
||||||
|
)
|
||||||
|
.where(Person.id == person_id)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
person = (await _session.exec(query)).first()
|
||||||
|
if person is None:
|
||||||
|
raise self._not_found(f"Person with id {person_id} not found")
|
||||||
|
return person
|
||||||
|
|
||||||
|
async def list_people(self, *, session: AsyncSession | None = None) -> Sequence[Person]:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = select(Person).options(selectinload(Person.document_people))
|
||||||
|
return (await _session.exec(query)).all()
|
||||||
|
|
||||||
|
async def list_person_roles(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
active_only: bool = True,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[PersonRole]:
|
||||||
|
return await self._person_roles.list_entries(active_only=active_only, session=session)
|
||||||
|
|
||||||
|
async def list_person_role_summaries(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[PersonRoleSummary]:
|
||||||
|
"""List Person Roles alphabetically with current link counts."""
|
||||||
|
rows = await self._person_roles.list_entries_with_counts(session=session)
|
||||||
|
return [
|
||||||
|
RegistrySummary(
|
||||||
|
id=role.id,
|
||||||
|
label=role.label,
|
||||||
|
is_active=role.is_active,
|
||||||
|
is_built_in=role.semantic_key is not None,
|
||||||
|
reference_count=link_count,
|
||||||
|
)
|
||||||
|
for role, link_count in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create_person_role(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
is_active: bool = True,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> PersonRole:
|
||||||
|
"""Create a custom Person Role with a unique label."""
|
||||||
|
return await self._person_roles.create_entry(label=label, is_active=is_active, session=session)
|
||||||
|
|
||||||
|
async def read_person_role(
|
||||||
|
self,
|
||||||
|
person_role_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> PersonRole:
|
||||||
|
"""Read a Person Role by id."""
|
||||||
|
return await self._person_roles.read_entry(person_role_id, session=session)
|
||||||
|
|
||||||
|
async def update_person_role(
|
||||||
|
self,
|
||||||
|
person_role_id: UUID,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
is_active: bool,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> PersonRole:
|
||||||
|
"""Update mutable Person Role fields without changing semantic identity."""
|
||||||
|
return await self._person_roles.update_entry(
|
||||||
|
person_role_id,
|
||||||
|
label=label,
|
||||||
|
is_active=is_active,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_person_role(
|
||||||
|
self,
|
||||||
|
person_role_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Delete an unreferenced Person Role without cascade behavior."""
|
||||||
|
await self._person_roles.delete_entry(person_role_id, session=session)
|
||||||
|
|
||||||
|
async def is_person_role_referenced(
|
||||||
|
self,
|
||||||
|
person_role_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether a document-person link references a Person Role."""
|
||||||
|
return await self._person_roles.is_referenced(person_role_id, session=session)
|
||||||
|
|
||||||
|
async def read_person_role_by_semantic_key(
|
||||||
|
self,
|
||||||
|
semantic_key: str,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> PersonRole:
|
||||||
|
"""Resolve one application-defined built-in role."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
role = (await _session.exec(select(PersonRole).where(PersonRole.semantic_key == semantic_key))).first()
|
||||||
|
if role is None:
|
||||||
|
raise PersonRoleError(
|
||||||
|
f"Built-in Person Role {semantic_key!r} is unavailable",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Recreate the built-in registry rows and retry.",
|
||||||
|
)
|
||||||
|
return role
|
||||||
|
|
||||||
|
async def list_document_people(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_id: UUID | None = None,
|
||||||
|
person_id: UUID | None = None,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[DocumentPerson]:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = select(DocumentPerson).options(
|
||||||
|
selectinload(DocumentPerson.document),
|
||||||
|
selectinload(DocumentPerson.person),
|
||||||
|
selectinload(DocumentPerson.role_ref),
|
||||||
|
)
|
||||||
|
if document_id is not None:
|
||||||
|
query = query.where(DocumentPerson.document_id == document_id)
|
||||||
|
if person_id is not None:
|
||||||
|
query = query.where(DocumentPerson.person_id == person_id)
|
||||||
|
return (await _session.exec(query)).all()
|
||||||
|
|
||||||
|
async def add_document_person_link(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_id: UUID,
|
||||||
|
person_id: UUID,
|
||||||
|
role_id: UUID,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> DocumentPerson:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
await self._require_document(session=_session, document_id=document_id)
|
||||||
|
if await _session.get(Person, person_id) is None:
|
||||||
|
raise self._not_found(f"Person with id {person_id} not found")
|
||||||
|
|
||||||
|
await self._validate_role(session=_session, role_id=role_id, require_active=True)
|
||||||
|
link = DocumentPerson(document_id=document_id, person_id=person_id, role_id=role_id)
|
||||||
|
_session.add(link)
|
||||||
|
return await self._finalize_link(session=_session, caller_session=session, link=link)
|
||||||
|
|
||||||
|
async def set_document_person_role(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_person_id: UUID,
|
||||||
|
role_id: UUID,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> DocumentPerson:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
link = await _session.get(DocumentPerson, document_person_id)
|
||||||
|
if link is None:
|
||||||
|
raise self._not_found(f"DocumentPerson with id {document_person_id} not found")
|
||||||
|
await self._validate_role(
|
||||||
|
session=_session,
|
||||||
|
role_id=role_id,
|
||||||
|
require_active=link.role_id != role_id,
|
||||||
|
)
|
||||||
|
link.role_id = role_id
|
||||||
|
return await self._finalize_link(session=_session, caller_session=session, link=link)
|
||||||
|
|
||||||
|
async def remove_document_person_link(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_person_id: UUID,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
link = await _session.get(DocumentPerson, document_person_id)
|
||||||
|
if link is None:
|
||||||
|
raise self._not_found(f"DocumentPerson with id {document_person_id} not found")
|
||||||
|
await _session.delete(link)
|
||||||
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
|
async def sync_document_people(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_id: UUID,
|
||||||
|
links: Sequence[DocumentPersonInput],
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[DocumentPerson]:
|
||||||
|
"""Synchronize one Document's complete Person link set."""
|
||||||
|
person_ids = [link.person_id for link in links]
|
||||||
|
if len(person_ids) != len(set(person_ids)):
|
||||||
|
raise PeopleError(
|
||||||
|
"A Person can be linked to a Document only once",
|
||||||
|
category=ErrorCategory.CONFLICT,
|
||||||
|
suggestion="Edit the existing Linked People row instead of adding another.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
await self._require_document(session=_session, document_id=document_id)
|
||||||
|
existing_links = (
|
||||||
|
await _session.exec(select(DocumentPerson).where(DocumentPerson.document_id == document_id))
|
||||||
|
).all()
|
||||||
|
existing_by_person = {link.person_id: link for link in existing_links}
|
||||||
|
desired_by_person = {link.person_id: link for link in links}
|
||||||
|
|
||||||
|
roles: dict[UUID, PersonRole] = {}
|
||||||
|
for desired in links:
|
||||||
|
if await _session.get(Person, desired.person_id) is None:
|
||||||
|
raise self._not_found(f"Person with id {desired.person_id} not found")
|
||||||
|
role = roles.get(desired.role_id)
|
||||||
|
if role is None:
|
||||||
|
role = await self._validate_role(session=_session, role_id=desired.role_id)
|
||||||
|
roles[desired.role_id] = role
|
||||||
|
current = existing_by_person.get(desired.person_id)
|
||||||
|
if (current is None or current.role_id != desired.role_id) and not role.is_active:
|
||||||
|
raise PeopleError(
|
||||||
|
f"Inactive Person Role {role.label!r} cannot be assigned",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Select an active Person Role and retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
for person_id, existing in existing_by_person.items():
|
||||||
|
if person_id not in desired_by_person:
|
||||||
|
await _session.delete(existing)
|
||||||
|
|
||||||
|
synchronized: list[DocumentPerson] = []
|
||||||
|
for desired in links:
|
||||||
|
existing = existing_by_person.get(desired.person_id)
|
||||||
|
if existing is None:
|
||||||
|
existing = DocumentPerson(
|
||||||
|
document_id=document_id,
|
||||||
|
person_id=desired.person_id,
|
||||||
|
role_id=desired.role_id,
|
||||||
|
)
|
||||||
|
_session.add(existing)
|
||||||
|
elif existing.role_id != desired.role_id:
|
||||||
|
existing.role_id = desired.role_id
|
||||||
|
synchronized.append(existing)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=synchronized)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise PeopleError(
|
||||||
|
"A Person can be linked to a Document only once",
|
||||||
|
category=ErrorCategory.CONFLICT,
|
||||||
|
suggestion="Edit the existing Linked People row instead of adding another.",
|
||||||
|
) from exc
|
||||||
|
return synchronized
|
||||||
|
|
||||||
|
async def _validate_role(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
role_id: UUID,
|
||||||
|
require_active: bool = False,
|
||||||
|
) -> PersonRole:
|
||||||
|
role = await session.get(PersonRole, role_id)
|
||||||
|
if role is None:
|
||||||
|
raise PeopleError(
|
||||||
|
f"Person role with id {role_id} not found",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Select a valid relationship role and retry.",
|
||||||
|
)
|
||||||
|
if require_active and not role.is_active:
|
||||||
|
raise PeopleError(
|
||||||
|
f"Inactive Person Role {role.label!r} cannot be assigned",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Select an active Person Role and retry.",
|
||||||
|
)
|
||||||
|
return role
|
||||||
|
|
||||||
|
async def _finalize_link(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
caller_session: AsyncSession | None,
|
||||||
|
link: DocumentPerson,
|
||||||
|
) -> DocumentPerson:
|
||||||
|
try:
|
||||||
|
await self._finalize(session=session, caller_session=caller_session, refresh=(link,))
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise PeopleError(
|
||||||
|
"This Person is already linked to the Document",
|
||||||
|
category=ErrorCategory.CONFLICT,
|
||||||
|
suggestion="Edit the existing relationship instead of adding another one.",
|
||||||
|
) from exc
|
||||||
|
# Relationships load explicitly; the models declare lazy="raise".
|
||||||
|
await session.refresh(link, attribute_names=["document", "person", "role_ref"])
|
||||||
|
return link
|
||||||
|
|
||||||
|
async def _require_document(self, *, session: AsyncSession, document_id: UUID) -> None:
|
||||||
|
if await session.get(Document, document_id) is None:
|
||||||
|
raise self._not_found(f"Document with id {document_id} not found")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _family_search_conflict(family_search_id: str | None) -> PeopleError:
|
||||||
|
return PeopleError(
|
||||||
|
f"FamilySearch ID {family_search_id} is already assigned to another person",
|
||||||
|
category=ErrorCategory.CONFLICT,
|
||||||
|
suggestion="Open the existing person record or enter a different FamilySearch ID.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _not_found(message: str) -> PeopleError:
|
||||||
|
return PeopleError(
|
||||||
|
message,
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Verify the requested Person or relationship id and retry.",
|
||||||
|
)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""Photo service for Person and homepage image records."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ..config import Settings
|
||||||
|
from ..db.models import Photo
|
||||||
|
from ..errors import AppError
|
||||||
|
from ..errors import ErrorCategory
|
||||||
|
from .base import ServiceBase
|
||||||
|
from .media_storage import persist_named_media
|
||||||
|
|
||||||
|
PHOTO_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"})
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoError(AppError):
|
||||||
|
"""Raised when photo operations fail."""
|
||||||
|
|
||||||
|
|
||||||
|
class PhotosService(ServiceBase):
|
||||||
|
"""Manage homepage and Person photos."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(session_factory, settings)
|
||||||
|
|
||||||
|
async def create_photo(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
filename: str,
|
||||||
|
file_bytes: bytes,
|
||||||
|
person_id: UUID | None,
|
||||||
|
description: str | None = None,
|
||||||
|
is_primary: bool | None = None,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Photo:
|
||||||
|
if not file_bytes:
|
||||||
|
raise PhotoError(
|
||||||
|
"Photo content is empty",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Select a non-empty image file and retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
suffix = Path(filename).suffix.lower()
|
||||||
|
if suffix not in PHOTO_EXTENSIONS:
|
||||||
|
raise PhotoError(
|
||||||
|
f"Unsupported photo format: {suffix or '<none>'}",
|
||||||
|
category=ErrorCategory.USER_INPUT,
|
||||||
|
suggestion="Use JPG, JPEG, PNG, GIF, WEBP, BMP, or TIFF image files.",
|
||||||
|
)
|
||||||
|
|
||||||
|
photo_id = uuid4()
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
existing = await self._list_owner_photos(session=_session, person_id=person_id)
|
||||||
|
should_be_primary = bool(is_primary) if is_primary is not None else len(existing) == 0
|
||||||
|
if should_be_primary:
|
||||||
|
await self._clear_owner_primary(session=_session, person_id=person_id)
|
||||||
|
|
||||||
|
stored_path = await persist_named_media(
|
||||||
|
root=self.settings.upload_dir,
|
||||||
|
namespace=Path("photos"),
|
||||||
|
filename=filename,
|
||||||
|
filename_stem=str(photo_id),
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
error=PhotoError,
|
||||||
|
failure_message="Failed to persist photo media",
|
||||||
|
failure_suggestion="Check media directory permissions and available disk space, then retry.",
|
||||||
|
log_label="photo media",
|
||||||
|
)
|
||||||
|
relative_path = self._relative_upload_path(stored_path)
|
||||||
|
|
||||||
|
photo = Photo(
|
||||||
|
id=photo_id,
|
||||||
|
person_id=person_id,
|
||||||
|
path=relative_path,
|
||||||
|
description=(description or "").strip() or None,
|
||||||
|
is_primary=should_be_primary,
|
||||||
|
)
|
||||||
|
_session.add(photo)
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(photo,))
|
||||||
|
return photo
|
||||||
|
|
||||||
|
async def list_photos(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
person_id: UUID | None,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> list[Photo]:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
photos = await self._list_owner_photos(session=_session, person_id=person_id)
|
||||||
|
|
||||||
|
primary = [photo for photo in photos if photo.is_primary]
|
||||||
|
non_primary = [photo for photo in photos if not photo.is_primary]
|
||||||
|
random.shuffle(non_primary)
|
||||||
|
return [*primary[:1], *non_primary]
|
||||||
|
|
||||||
|
async def set_primary(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
photo_id: UUID,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Photo:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
photo = await self._get_or_raise(
|
||||||
|
Photo,
|
||||||
|
photo_id,
|
||||||
|
session=_session,
|
||||||
|
error=PhotoError,
|
||||||
|
noun="Photo",
|
||||||
|
suggestion="Refresh and retry with a valid photo record.",
|
||||||
|
)
|
||||||
|
await self._clear_owner_primary(session=_session, person_id=photo.person_id)
|
||||||
|
photo.is_primary = True
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(photo,))
|
||||||
|
return photo
|
||||||
|
|
||||||
|
async def update_description(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
photo_id: UUID,
|
||||||
|
description: str | None,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Photo:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
photo = await self._get_or_raise(
|
||||||
|
Photo,
|
||||||
|
photo_id,
|
||||||
|
session=_session,
|
||||||
|
error=PhotoError,
|
||||||
|
noun="Photo",
|
||||||
|
suggestion="Refresh and retry with a valid photo record.",
|
||||||
|
)
|
||||||
|
photo.description = (description or "").strip() or None
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(photo,))
|
||||||
|
return photo
|
||||||
|
|
||||||
|
async def delete_photo(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
photo_id: UUID,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
photo = await self._get_or_raise(
|
||||||
|
Photo,
|
||||||
|
photo_id,
|
||||||
|
session=_session,
|
||||||
|
error=PhotoError,
|
||||||
|
noun="Photo",
|
||||||
|
suggestion="Refresh and retry with a valid photo record.",
|
||||||
|
)
|
||||||
|
owner_person_id = photo.person_id
|
||||||
|
deleted_primary = photo.is_primary
|
||||||
|
media_path = self.settings.upload_dir / Path(photo.path)
|
||||||
|
await _session.delete(photo)
|
||||||
|
|
||||||
|
if deleted_primary:
|
||||||
|
replacement = await self._owner_oldest_photo(session=_session, person_id=owner_person_id)
|
||||||
|
if replacement is not None:
|
||||||
|
replacement.is_primary = True
|
||||||
|
|
||||||
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
|
await asyncio.to_thread(media_path.unlink, missing_ok=True)
|
||||||
|
|
||||||
|
async def _list_owner_photos(self, *, session: AsyncSession, person_id: UUID | None) -> list[Photo]:
|
||||||
|
query = select(Photo)
|
||||||
|
if person_id is None:
|
||||||
|
query = query.where(Photo.person_id.is_(None))
|
||||||
|
else:
|
||||||
|
query = query.where(Photo.person_id == person_id)
|
||||||
|
query = query.order_by(Photo.created_at.asc(), Photo.id.asc())
|
||||||
|
return list((await session.exec(query)).all())
|
||||||
|
|
||||||
|
async def _owner_oldest_photo(self, *, session: AsyncSession, person_id: UUID | None) -> Photo | None:
|
||||||
|
query = select(Photo)
|
||||||
|
if person_id is None:
|
||||||
|
query = query.where(Photo.person_id.is_(None))
|
||||||
|
else:
|
||||||
|
query = query.where(Photo.person_id == person_id)
|
||||||
|
query = query.order_by(Photo.created_at.asc(), Photo.id.asc()).limit(1)
|
||||||
|
return (await session.exec(query)).first()
|
||||||
|
|
||||||
|
async def _clear_owner_primary(self, *, session: AsyncSession, person_id: UUID | None) -> None:
|
||||||
|
query = select(Photo).where(Photo.is_primary.is_(True))
|
||||||
|
if person_id is None:
|
||||||
|
query = query.where(Photo.person_id.is_(None))
|
||||||
|
else:
|
||||||
|
query = query.where(Photo.person_id == person_id)
|
||||||
|
for current in (await session.exec(query)).all():
|
||||||
|
current.is_primary = False
|
||||||
|
|
||||||
|
def _relative_upload_path(self, absolute_path: Path) -> str:
|
||||||
|
try:
|
||||||
|
return absolute_path.resolve().relative_to(self.settings.upload_dir.resolve()).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
return absolute_path.name
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""Constrained storage for mutable prompt Markdown artifacts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from ..config import Settings
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..errors import AppError
|
||||||
|
from ..errors import ErrorCategory
|
||||||
|
|
||||||
|
PROMPT_EXTENSION = ".md"
|
||||||
|
BACKUP_SUFFIX = ".bak"
|
||||||
|
|
||||||
|
|
||||||
|
class PromptStoreError(AppError):
|
||||||
|
"""Raised when prompt storage validation or persistence fails."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PromptSummary:
|
||||||
|
"""Read model for one editable prompt artifact."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
is_default: bool
|
||||||
|
has_backup: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PromptStore:
|
||||||
|
"""List, read, atomically update, and recover existing prompt files."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings | None = None) -> None:
|
||||||
|
self.settings = settings or get_settings()
|
||||||
|
|
||||||
|
def list_prompts(self) -> tuple[PromptSummary, ...]:
|
||||||
|
"""List editable direct-child Markdown prompts by filename."""
|
||||||
|
root = self._prompt_root()
|
||||||
|
try:
|
||||||
|
candidates = tuple(root.iterdir())
|
||||||
|
except OSError as exc:
|
||||||
|
raise self._filesystem_error("Prompt directory could not be read", exc) from exc
|
||||||
|
|
||||||
|
summaries: list[PromptSummary] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.suffix.lower() != PROMPT_EXTENSION or not candidate.is_file():
|
||||||
|
continue
|
||||||
|
resolved = candidate.resolve()
|
||||||
|
if resolved.parent != root:
|
||||||
|
continue
|
||||||
|
summaries.append(
|
||||||
|
PromptSummary(
|
||||||
|
name=candidate.name,
|
||||||
|
is_default=candidate.name == self.settings.default_prompt_name,
|
||||||
|
has_backup=self._backup_path(candidate).is_file(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(sorted(summaries, key=lambda item: item.name.casefold()))
|
||||||
|
|
||||||
|
def read_prompt(self, name: str) -> str:
|
||||||
|
"""Read one existing UTF-8 prompt."""
|
||||||
|
path = self._resolve_existing_prompt(name)
|
||||||
|
return self._read_nonempty_text(path, description="Prompt")
|
||||||
|
|
||||||
|
def write_prompt(self, name: str, content: str) -> None:
|
||||||
|
"""Atomically replace an existing prompt and retain one prior version."""
|
||||||
|
path = self._resolve_existing_prompt(name)
|
||||||
|
normalized_content = content.strip()
|
||||||
|
if not normalized_content:
|
||||||
|
raise PromptStoreError(
|
||||||
|
"Prompt content cannot be empty",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Enter prompt text before saving.",
|
||||||
|
)
|
||||||
|
self._atomic_write(path=path, content=f"{normalized_content}\n", preserve_current=True)
|
||||||
|
|
||||||
|
def recover_prompt(self, name: str) -> None:
|
||||||
|
"""Restore the sole previous-version backup as an explicit operation."""
|
||||||
|
path = self._resolve_existing_prompt(name)
|
||||||
|
backup_path = self._backup_path(path)
|
||||||
|
if not backup_path.is_file():
|
||||||
|
raise PromptStoreError(
|
||||||
|
f"No previous version is available for {path.name}",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Save a prompt edit before attempting recovery.",
|
||||||
|
)
|
||||||
|
backup_content = self._read_nonempty_text(backup_path, description="Prompt backup")
|
||||||
|
self._atomic_write(path=path, content=backup_content, preserve_current=True)
|
||||||
|
|
||||||
|
def _prompt_root(self) -> Path:
|
||||||
|
try:
|
||||||
|
root = self.settings.prompt_dir.resolve()
|
||||||
|
except OSError as exc:
|
||||||
|
raise self._filesystem_error("Prompt directory could not be resolved", exc) from exc
|
||||||
|
if not root.is_dir():
|
||||||
|
raise PromptStoreError(
|
||||||
|
f"Prompt directory is unavailable: {root}",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Restore the configured prompt directory and its permissions.",
|
||||||
|
)
|
||||||
|
return root
|
||||||
|
|
||||||
|
def _resolve_existing_prompt(self, name: str) -> Path:
|
||||||
|
normalized_name = name.strip()
|
||||||
|
# Path().name is platform-dependent: POSIX treats "\" as an ordinary filename
|
||||||
|
# character, so reject both separators explicitly to match config.PromptFilename.
|
||||||
|
if (
|
||||||
|
not normalized_name
|
||||||
|
or any(separator in normalized_name for separator in ("/", "\\"))
|
||||||
|
or Path(normalized_name).name != normalized_name
|
||||||
|
or Path(normalized_name).suffix.lower() != PROMPT_EXTENSION
|
||||||
|
):
|
||||||
|
raise PromptStoreError(
|
||||||
|
"Prompt name must be a direct-child Markdown filename",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Select an existing .md prompt from Settings.",
|
||||||
|
)
|
||||||
|
root = self._prompt_root()
|
||||||
|
try:
|
||||||
|
path = (root / normalized_name).resolve()
|
||||||
|
except OSError as exc:
|
||||||
|
raise self._filesystem_error("Prompt path could not be resolved", exc) from exc
|
||||||
|
if path.parent != root:
|
||||||
|
raise PromptStoreError(
|
||||||
|
"Prompt path must remain inside the configured prompt directory",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Select an existing prompt from Settings.",
|
||||||
|
)
|
||||||
|
if not path.is_file():
|
||||||
|
raise PromptStoreError(
|
||||||
|
f"Prompt file not found: {normalized_name}",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Refresh Settings and select an existing prompt.",
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _read_nonempty_text(self, path: Path, *, description: str) -> str:
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
except UnicodeError as exc:
|
||||||
|
raise PromptStoreError(
|
||||||
|
f"{description} is not valid UTF-8: {path.name}",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Restore a valid UTF-8 Markdown prompt.",
|
||||||
|
) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise self._filesystem_error(f"{description} could not be read", exc) from exc
|
||||||
|
if not content.strip():
|
||||||
|
raise PromptStoreError(
|
||||||
|
f"{description} is empty: {path.name}",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Restore non-empty prompt content.",
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
|
||||||
|
def _atomic_write(self, *, path: Path, content: str, preserve_current: bool) -> None:
|
||||||
|
temporary_path = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
|
||||||
|
backup_path = self._backup_path(path)
|
||||||
|
backup_temporary_path = backup_path.with_name(f".{backup_path.name}.{uuid4().hex}.tmp")
|
||||||
|
try:
|
||||||
|
self._write_synced(temporary_path, content.encode("utf-8"))
|
||||||
|
if preserve_current:
|
||||||
|
self._write_synced(backup_temporary_path, path.read_bytes())
|
||||||
|
backup_temporary_path.replace(backup_path)
|
||||||
|
temporary_path.replace(path)
|
||||||
|
except (OSError, UnicodeError) as exc:
|
||||||
|
raise self._filesystem_error(f"Prompt {path.name} could not be saved", exc) from exc
|
||||||
|
finally:
|
||||||
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
backup_temporary_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_synced(path: Path, content: bytes) -> None:
|
||||||
|
with path.open("wb") as stream:
|
||||||
|
stream.write(content)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _backup_path(path: Path) -> Path:
|
||||||
|
return path.with_name(f"{path.name}{BACKUP_SUFFIX}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _filesystem_error(message: str, exc: Exception) -> PromptStoreError:
|
||||||
|
return PromptStoreError(
|
||||||
|
f"{message}: {exc}",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Check prompt directory permissions and available disk space, then retry.",
|
||||||
|
)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Deterministic, provider-neutral transcription quality warnings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic import ConfigDict
|
||||||
|
|
||||||
|
QUALITY_ANALYSIS_SCHEMA = "transcription.quality-warnings"
|
||||||
|
QUALITY_ANALYSIS_VERSION = "1"
|
||||||
|
QUALITY_ANALYSIS_PRODUCER = "transcription.quality"
|
||||||
|
QUALITY_ANALYSIS_PRODUCER_VERSION = "1"
|
||||||
|
|
||||||
|
_BODY_MARKER_RE = re.compile(
|
||||||
|
r"\[document body (?:handwritten|typewritten|typeset|mixed)\]",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_HANDWRITTEN_LINE_RE = re.compile(r"(?m)^\s*\[handwritten:\s*.+\]\s*$", flags=re.IGNORECASE)
|
||||||
|
_HTML_ENTITY_RE = re.compile(r"&(?:#[0-9]{1,7}|#x[0-9a-f]{1,6}|[a-z][a-z0-9]{1,31});", flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
class QualityWarningCode(StrEnum):
|
||||||
|
"""Stable identifiers for output warning rules."""
|
||||||
|
|
||||||
|
REPLACEMENT_CHARACTER = "replacement_character"
|
||||||
|
MULTIPLE_BODY_MARKERS = "multiple_body_markers"
|
||||||
|
REDUNDANT_HANDWRITING_WRAPPERS = "redundant_handwriting_wrappers"
|
||||||
|
UNRESOLVED_HTML_ENTITY = "unresolved_html_entity"
|
||||||
|
|
||||||
|
|
||||||
|
class QualityWarning(BaseModel):
|
||||||
|
"""One immutable warning produced without changing transcription text."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
|
code: QualityWarningCode
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_transcription_quality(text: str) -> tuple[QualityWarning, ...]:
|
||||||
|
"""Return deterministic warnings in stable rule order."""
|
||||||
|
warnings: list[QualityWarning] = []
|
||||||
|
if "\ufffd" in text:
|
||||||
|
warnings.append(
|
||||||
|
QualityWarning(
|
||||||
|
code=QualityWarningCode.REPLACEMENT_CHARACTER,
|
||||||
|
detail="Transcription contains one or more Unicode replacement characters.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
body_markers = _BODY_MARKER_RE.findall(text)
|
||||||
|
if len(body_markers) > 1:
|
||||||
|
warnings.append(
|
||||||
|
QualityWarning(
|
||||||
|
code=QualityWarningCode.MULTIPLE_BODY_MARKERS,
|
||||||
|
detail=f"Transcription contains {len(body_markers)} document-body markers; exactly one is expected.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if re.search(r"\[document body handwritten\]", text, flags=re.IGNORECASE):
|
||||||
|
wrappers = _HANDWRITTEN_LINE_RE.findall(text)
|
||||||
|
if len(wrappers) > 1:
|
||||||
|
warnings.append(
|
||||||
|
QualityWarning(
|
||||||
|
code=QualityWarningCode.REDUNDANT_HANDWRITING_WRAPPERS,
|
||||||
|
detail=(
|
||||||
|
"A wholly handwritten document also uses repeated whole-line handwriting wrappers."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if _HTML_ENTITY_RE.search(text):
|
||||||
|
warnings.append(
|
||||||
|
QualityWarning(
|
||||||
|
code=QualityWarningCode.UNRESOLVED_HTML_ENTITY,
|
||||||
|
detail="Transcription contains a likely unresolved HTML entity.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def quality_warning_payload(warnings: tuple[QualityWarning, ...]) -> dict:
|
||||||
|
"""Build the versioned JSON artifact payload."""
|
||||||
|
return {
|
||||||
|
"schema_name": QUALITY_ANALYSIS_SCHEMA,
|
||||||
|
"schema_version": QUALITY_ANALYSIS_VERSION,
|
||||||
|
"warnings": [warning.model_dump(mode="json") for warning in warnings],
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""Shared implementation for label-keyed registry tables.
|
||||||
|
|
||||||
|
Document Types and Person Roles are the same shape: a UUID-identified row with a
|
||||||
|
user-facing ``label``, a casefolded ``normalized_label`` uniqueness key, an
|
||||||
|
``is_active`` flag, and an optional ``semantic_key`` marking built-in entries
|
||||||
|
that may be deactivated but never deleted. This module owns that behavior once
|
||||||
|
so the two registries cannot drift apart.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import abstractmethod
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from typing import Protocol
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
from sqlmodel import col
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ..errors import AppError
|
||||||
|
from ..errors import ErrorCategory
|
||||||
|
from .base import ServiceBase
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryEntry(Protocol):
|
||||||
|
"""Structural contract every registry table row satisfies.
|
||||||
|
|
||||||
|
Bounding ``RegistryService`` by this protocol rather than by bare ``SQLModel``
|
||||||
|
lets the shared implementation read ``id``/``label``/``normalized_label``/
|
||||||
|
``is_active`` off the model class without suppressions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
label: str
|
||||||
|
normalized_label: str
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
def __init__(self, /, **data: Any) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RegistrySummary:
|
||||||
|
"""Shared settings read model for label-keyed registries and usage counts."""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
label: str
|
||||||
|
is_active: bool
|
||||||
|
is_built_in: bool
|
||||||
|
reference_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryService[ModelT: RegistryEntry](ServiceBase):
|
||||||
|
"""Generic create/read/update/delete behavior for a registry table.
|
||||||
|
|
||||||
|
Subclasses declare the model, the error type, the user-facing noun, and the
|
||||||
|
reference query used to decide whether an entry may be deleted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Registry table this service maintains.
|
||||||
|
model: type[ModelT]
|
||||||
|
#: Error raised for every failure mode of this registry.
|
||||||
|
error: type[AppError]
|
||||||
|
#: User-facing singular noun, e.g. ``"Document Type"``.
|
||||||
|
noun: str
|
||||||
|
#: Lowercase noun used inside remediation suggestions, e.g. ``"type"``.
|
||||||
|
short_noun: str
|
||||||
|
#: Subject that retains a referenced entry, e.g. ``"historical Documents"``.
|
||||||
|
referenced_retainer: str
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def reference_model(self) -> type[SQLModel]:
|
||||||
|
"""Return the table whose rows reference this registry."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def reference_id_column(self) -> Any:
|
||||||
|
"""Return the primary key column of the referencing table."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def reference_key_column(self) -> Any:
|
||||||
|
"""Return the foreign key column pointing at this registry.
|
||||||
|
|
||||||
|
Declared as methods rather than class attributes because a mapped
|
||||||
|
column stored on a plain class would be re-invoked as a descriptor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#
|
||||||
|
# Message templates
|
||||||
|
#
|
||||||
|
|
||||||
|
def _not_found(self, entry_id: UUID) -> AppError:
|
||||||
|
return self.error(
|
||||||
|
f"{self.noun} with id {entry_id} not found",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion=f"Refresh Settings and select an available {self.noun}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _duplicate_label(self, label: str) -> AppError:
|
||||||
|
return self.error(
|
||||||
|
f"{self.noun} label {label!r} already exists",
|
||||||
|
category=ErrorCategory.CONFLICT,
|
||||||
|
suggestion=f"Choose a different label or edit the existing {self.short_noun}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def normalize_label(self, label: str) -> str:
|
||||||
|
"""Strip a submitted label, rejecting blank input."""
|
||||||
|
normalized = label.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise self.error(
|
||||||
|
f"{self.noun} label is required",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Enter a user-facing label and retry.",
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def label_key(self, label: str) -> str:
|
||||||
|
"""Return the casefolded uniqueness key for a submitted label."""
|
||||||
|
return self.normalize_label(label).casefold()
|
||||||
|
|
||||||
|
#
|
||||||
|
# Reads
|
||||||
|
#
|
||||||
|
|
||||||
|
async def list_entries(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
active_only: bool = True,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[ModelT]:
|
||||||
|
"""List registry entries alphabetically by normalized label."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = select(self.model)
|
||||||
|
if active_only:
|
||||||
|
query = query.where(col(self.model.is_active).is_(True))
|
||||||
|
query = query.order_by(col(self.model.normalized_label), col(self.model.id))
|
||||||
|
return (await _session.exec(query)).all()
|
||||||
|
|
||||||
|
async def list_entries_with_counts(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[tuple[ModelT, int]]:
|
||||||
|
"""List every entry alphabetically with its current reference count."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = (
|
||||||
|
select(self.model, func.count(self.reference_id_column()))
|
||||||
|
.outerjoin(self.reference_model(), self.reference_key_column() == col(self.model.id))
|
||||||
|
.group_by(col(self.model.id))
|
||||||
|
.order_by(col(self.model.normalized_label), col(self.model.id))
|
||||||
|
)
|
||||||
|
rows = (await _session.exec(query)).all()
|
||||||
|
return [(entry, int(count)) for entry, count in rows]
|
||||||
|
|
||||||
|
async def read_entry(
|
||||||
|
self,
|
||||||
|
entry_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> ModelT:
|
||||||
|
"""Read a registry entry by id."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
entry = await _session.get(self.model, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
raise self._not_found(entry_id)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
async def is_referenced(
|
||||||
|
self,
|
||||||
|
entry_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether any row references the registry entry."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
entry = await _session.get(self.model, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
raise self._not_found(entry_id)
|
||||||
|
return await self._is_referenced(session=_session, entry=entry)
|
||||||
|
|
||||||
|
async def _is_referenced(self, *, session: AsyncSession, entry: ModelT) -> bool:
|
||||||
|
query = select(self.reference_id_column()).where(self.reference_key_column() == entry.id)
|
||||||
|
return (await session.exec(query)).first() is not None
|
||||||
|
|
||||||
|
#
|
||||||
|
# Writes
|
||||||
|
#
|
||||||
|
|
||||||
|
async def create_entry(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
is_active: bool = True,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> ModelT:
|
||||||
|
"""Create a UUID-identified entry with a unique label."""
|
||||||
|
entry = self.model(
|
||||||
|
label=self.normalize_label(label),
|
||||||
|
normalized_label=self.label_key(label),
|
||||||
|
is_active=is_active,
|
||||||
|
)
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
_session.add(entry)
|
||||||
|
try:
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(entry,))
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise self._duplicate_label(entry.label) from exc
|
||||||
|
return entry
|
||||||
|
|
||||||
|
async def update_entry(
|
||||||
|
self,
|
||||||
|
entry_id: UUID,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
is_active: bool,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> ModelT:
|
||||||
|
"""Update mutable fields without changing semantic identity."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
entry = await _session.get(self.model, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
raise self._not_found(entry_id)
|
||||||
|
entry.label = self.normalize_label(label)
|
||||||
|
entry.normalized_label = self.label_key(label)
|
||||||
|
entry.is_active = is_active
|
||||||
|
try:
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(entry,))
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise self._duplicate_label(entry.label) from exc
|
||||||
|
return entry
|
||||||
|
|
||||||
|
async def delete_entry(
|
||||||
|
self,
|
||||||
|
entry_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Delete an unreferenced, non-built-in entry without cascade behavior."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
entry = await _session.get(self.model, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
raise self._not_found(entry_id)
|
||||||
|
if entry.semantic_key is not None:
|
||||||
|
raise self.error(
|
||||||
|
f"Built-in {self.noun} {entry.label!r} cannot be deleted",
|
||||||
|
category=ErrorCategory.CONFLICT,
|
||||||
|
suggestion=(
|
||||||
|
f"Deactivate the {self.short_noun} instead; "
|
||||||
|
"its built-in meaning must remain available."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if await self._is_referenced(session=_session, entry=entry):
|
||||||
|
raise self.error(
|
||||||
|
f"{self.noun} {entry.label!r} is referenced and cannot be deleted",
|
||||||
|
category=ErrorCategory.CONFLICT,
|
||||||
|
suggestion=(
|
||||||
|
f"Deactivate the {self.short_noun} instead; "
|
||||||
|
f"{self.referenced_retainer} will retain it."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await _session.delete(entry)
|
||||||
|
await self._finalize(session=_session, caller_session=session)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Canonical Source media format policy.
|
||||||
|
|
||||||
|
Shared by every layer that needs to know which Source formats exist and what
|
||||||
|
MIME type each maps to. Kept free of service classes and of any service-specific
|
||||||
|
error type so no service module has to import a sibling service to use it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SOURCE_MIME_TYPES = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".tif": "image/tiff",
|
||||||
|
".tiff": "image/tiff",
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
}
|
||||||
|
SOURCE_EXTENSIONS = frozenset(SOURCE_MIME_TYPES)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_source_mime_type(filename: str | Path) -> str | None:
|
||||||
|
"""Return the canonical MIME type for a filename, or ``None`` if unsupported."""
|
||||||
|
return SOURCE_MIME_TYPES.get(Path(filename).suffix.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def supported_source_formats() -> str:
|
||||||
|
"""Return the supported Source extensions as a sorted display string."""
|
||||||
|
return ", ".join(sorted(SOURCE_EXTENSIONS))
|
||||||
@@ -0,0 +1,911 @@
|
|||||||
|
"""Source persistence, media policy, revisions, and transcription execution."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic import ConfigDict
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic import JsonValue
|
||||||
|
from pydantic import TypeAdapter
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy import literal
|
||||||
|
from sqlalchemy import tuple_
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
from sqlmodel import col
|
||||||
|
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 ExecutionAttempt
|
||||||
|
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 ErrorCategory
|
||||||
|
from transcription.providers import ProviderAuthError
|
||||||
|
from transcription.providers import ProviderError
|
||||||
|
from transcription.providers import ProviderResponseError
|
||||||
|
from transcription.providers import RequestManifest
|
||||||
|
from transcription.providers import SourceEvidenceReference
|
||||||
|
from transcription.providers import TranscriptionMetadata
|
||||||
|
from transcription.providers import TranscriptionProvider
|
||||||
|
from transcription.providers import TranscriptionResult
|
||||||
|
from transcription.providers import TransportEvidence
|
||||||
|
from transcription.providers import get_transcription_provider
|
||||||
|
|
||||||
|
from ..db.loading import orm_attribute
|
||||||
|
from ..db.loading import selectinload
|
||||||
|
from .base import ServiceBase
|
||||||
|
from .errors import PromptLoadError
|
||||||
|
from .errors import SourceDeleteBlockedError
|
||||||
|
from .errors import TranscriptionError
|
||||||
|
from .errors import TranscriptionNotFoundError
|
||||||
|
from .source_media import lookup_source_mime_type
|
||||||
|
from .source_media import supported_source_formats
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_PROMPT_FILE = "transcribe_document.md"
|
||||||
|
JSON_OBJECT_ADAPTER = TypeAdapter(dict[str, JsonValue])
|
||||||
|
|
||||||
|
|
||||||
|
class PromptExecution(BaseModel):
|
||||||
|
"""Resolved prompt inputs captured for one page execution."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True)
|
||||||
|
|
||||||
|
prompt_name: str = Field(min_length=1, pattern=r"^[^/\\]+$")
|
||||||
|
prompt_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||||
|
system_prompt: str | None
|
||||||
|
user_prompt: str = Field(min_length=1)
|
||||||
|
temperature: float | None = Field(ge=0.0, le=2.0)
|
||||||
|
top_p: float | None = Field(ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SourceNavigation:
|
||||||
|
"""Adjacent Source identifiers within one ordered Document."""
|
||||||
|
|
||||||
|
previous_id: UUID | None
|
||||||
|
next_id: UUID | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ProviderInput:
|
||||||
|
"""Resolved immutable bytes and evidence identity for one provider request."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
digest_sha256: str
|
||||||
|
byte_size: int
|
||||||
|
media_type: str
|
||||||
|
|
||||||
|
|
||||||
|
def build_provider_input(source: Source, *, upload_dir: Path) -> ProviderInput:
|
||||||
|
"""Describe the stored Source bytes that a provider request will carry.
|
||||||
|
|
||||||
|
Stored pages are normalized upright at ingest, so the file on disk is the
|
||||||
|
exact payload sent to the provider and ``file_hash`` already identifies it.
|
||||||
|
"""
|
||||||
|
return ProviderInput(
|
||||||
|
path=(upload_dir / Path(source.file_path)).resolve(),
|
||||||
|
digest_sha256=source.file_hash.lower(),
|
||||||
|
byte_size=source.file_size_bytes,
|
||||||
|
media_type=source_mime_type(source.file_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceService(ServiceBase):
|
||||||
|
"""Manage source records, media payloads, revisions, and page execution output."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(session_factory=session_factory, settings=settings)
|
||||||
|
self._provider: TranscriptionProvider | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def provider(self) -> TranscriptionProvider:
|
||||||
|
if self._provider is None:
|
||||||
|
self._provider = get_transcription_provider(settings=self.settings)
|
||||||
|
return self._provider
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Close provider-owned network resources when they were initialized."""
|
||||||
|
if self._provider is None:
|
||||||
|
return
|
||||||
|
close = getattr(self._provider, "aclose", None)
|
||||||
|
if close is not None:
|
||||||
|
await close()
|
||||||
|
self._provider = None
|
||||||
|
|
||||||
|
async def _read_source(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
source_id: UUID,
|
||||||
|
options: Sequence[Any] = (),
|
||||||
|
suggestion: str = "Verify the source id and retry.",
|
||||||
|
) -> Source:
|
||||||
|
return await self._get_or_raise(
|
||||||
|
Source,
|
||||||
|
source_id,
|
||||||
|
session=session,
|
||||||
|
error=TranscriptionNotFoundError,
|
||||||
|
noun="Source",
|
||||||
|
suggestion=suggestion,
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
return await self._read_source(session=_session, source_id=source_id)
|
||||||
|
|
||||||
|
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.document),
|
||||||
|
selectinload(Source.job_sources).selectinload(orm_attribute(JobSource.job)),
|
||||||
|
)
|
||||||
|
.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 read_source_navigation(
|
||||||
|
self,
|
||||||
|
source_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> SourceNavigation:
|
||||||
|
"""Return adjacent Sources ordered within the current Document."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
source = await self._read_source(session=_session, source_id=source_id)
|
||||||
|
position = (col(Source.page_number), col(Source.id))
|
||||||
|
current = (literal(source.page_number), literal(source_id))
|
||||||
|
|
||||||
|
previous_query = (
|
||||||
|
select(col(Source.id))
|
||||||
|
.where(col(Source.document_id) == source.document_id)
|
||||||
|
.where(tuple_(*position) < tuple_(*current))
|
||||||
|
.order_by(col(Source.page_number).desc(), col(Source.id).desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
next_query = (
|
||||||
|
select(col(Source.id))
|
||||||
|
.where(col(Source.document_id) == source.document_id)
|
||||||
|
.where(tuple_(*position) > tuple_(*current))
|
||||||
|
.order_by(col(Source.page_number), col(Source.id))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SourceNavigation(
|
||||||
|
previous_id=(await _session.exec(previous_query)).first(),
|
||||||
|
next_id=(await _session.exec(next_query)).first(),
|
||||||
|
)
|
||||||
|
|
||||||
|
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 only when it has no retained execution evidence."""
|
||||||
|
await self.delete_unlinked_source(source_id=source.id, session=session)
|
||||||
|
|
||||||
|
async def delete_unlinked_source(self, *, source_id: UUID, session: AsyncSession | None = None) -> None:
|
||||||
|
"""Delete a source only when no JobSource links exist."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
source = await self._read_source(
|
||||||
|
session=_session,
|
||||||
|
source_id=source_id,
|
||||||
|
options=(selectinload(Source.job_sources),),
|
||||||
|
)
|
||||||
|
|
||||||
|
if source.job_sources:
|
||||||
|
raise SourceDeleteBlockedError(
|
||||||
|
"Source delete blocked because retained execution evidence exists",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Preserve the source or use an explicit evidence-retention workflow.",
|
||||||
|
)
|
||||||
|
|
||||||
|
source_file_path = source.file_path
|
||||||
|
await _session.delete(source)
|
||||||
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
|
self._delete_source_file(source_file_path=source_file_path)
|
||||||
|
|
||||||
|
async def list_sources(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_id: UUID | None = None,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[Source]:
|
||||||
|
"""List source pages, optionally filtered by document."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = select(Source)
|
||||||
|
if document_id is not None:
|
||||||
|
query = query.where(Source.document_id == document_id)
|
||||||
|
result = await _session.exec(query)
|
||||||
|
return result.all()
|
||||||
|
|
||||||
|
async def query_sources(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_id: UUID | None = None,
|
||||||
|
page_number: int | None = None,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[Source]:
|
||||||
|
"""Query source pages using the provided filters."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = select(Source)
|
||||||
|
if document_id is not None:
|
||||||
|
query = query.where(Source.document_id == document_id)
|
||||||
|
if page_number is not None:
|
||||||
|
query = query.where(Source.page_number == page_number)
|
||||||
|
result = await _session.exec(query)
|
||||||
|
return result.all()
|
||||||
|
|
||||||
|
async def list_sources_detail(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document_id: UUID | None = None,
|
||||||
|
job_id: UUID | None = None,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[Source]:
|
||||||
|
"""List source pages with document/job link context for UI rendering."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = select(Source).options(
|
||||||
|
selectinload(Source.document),
|
||||||
|
# Both are needed by Source.latest_job_source and
|
||||||
|
# latest_error_detail: recency comes from the parent job, and
|
||||||
|
# failure detail lives on the attempt, not the junction row.
|
||||||
|
selectinload(Source.job_sources).selectinload(orm_attribute(JobSource.job)),
|
||||||
|
selectinload(Source.job_sources).selectinload(orm_attribute(JobSource.execution_attempts)),
|
||||||
|
)
|
||||||
|
if document_id is not None:
|
||||||
|
query = query.where(Source.document_id == document_id)
|
||||||
|
if job_id is not None:
|
||||||
|
query = query.join(JobSource, col(JobSource.source_id) == col(Source.id)).where(
|
||||||
|
col(JobSource.job_id) == job_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return list((await _session.exec(query)).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)
|
||||||
|
try:
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(job_source,))
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise self._job_source_conflict(job_id=job_source.job_id, source_id=job_source.source_id) from exc
|
||||||
|
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),),
|
||||||
|
)
|
||||||
|
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 read_job_source_for_job(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
job_id: UUID,
|
||||||
|
source_id: UUID,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> JobSource:
|
||||||
|
"""Read the unique JobSource association for one job and Source."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
job_source = (
|
||||||
|
await _session.exec(
|
||||||
|
select(JobSource)
|
||||||
|
.where(JobSource.job_id == job_id)
|
||||||
|
.where(JobSource.source_id == source_id)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if job_source is None:
|
||||||
|
raise TranscriptionNotFoundError(
|
||||||
|
f"Source {source_id} is not linked to Job {job_id}",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Refresh the Job 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 self._read_source(
|
||||||
|
session=_session,
|
||||||
|
source_id=source_id,
|
||||||
|
options=(selectinload(Source.job_sources),),
|
||||||
|
)
|
||||||
|
|
||||||
|
linked_job_sources = list(source.job_sources)
|
||||||
|
attempt_count = (
|
||||||
|
await _session.exec(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(ExecutionAttempt)
|
||||||
|
.where(ExecutionAttempt.source_id == source_id)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
if attempt_count:
|
||||||
|
raise SourceDeleteBlockedError(
|
||||||
|
"Source delete blocked because immutable evidence exists",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Preserve the source or use an explicit evidence-retention workflow.",
|
||||||
|
)
|
||||||
|
|
||||||
|
matching_links = [job_source for job_source in linked_job_sources if job_source.job_id == job_id]
|
||||||
|
if not matching_links:
|
||||||
|
raise TranscriptionNotFoundError(
|
||||||
|
f"Source {source_id} is not linked to job {job_id}",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Open the source from its linked job context and retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(linked_job_sources) > len(matching_links):
|
||||||
|
raise SourceDeleteBlockedError(
|
||||||
|
"Source delete blocked by related job history",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Remove additional JobSource links first, then retry deletion.",
|
||||||
|
)
|
||||||
|
|
||||||
|
for job_source in matching_links:
|
||||||
|
await _session.delete(job_source)
|
||||||
|
|
||||||
|
source_file_path = source.file_path
|
||||||
|
await _session.delete(source)
|
||||||
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
|
self._delete_source_file(source_file_path=source_file_path)
|
||||||
|
|
||||||
|
def _delete_source_file(self, *, source_file_path: str) -> None:
|
||||||
|
"""Best-effort cleanup for source media files."""
|
||||||
|
candidate_path = Path(source_file_path)
|
||||||
|
resolved_path = candidate_path if candidate_path.is_absolute() else self.settings.upload_dir / candidate_path
|
||||||
|
|
||||||
|
if not resolved_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved_path.unlink()
|
||||||
|
logger.info("Deleted source file: %s", resolved_path)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Failed to delete source file: %s", resolved_path)
|
||||||
|
|
||||||
|
async def list_job_sources(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
job_id: UUID | None = None,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[JobSource]:
|
||||||
|
"""List job-source records, optionally filtered by job."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = select(JobSource).options(
|
||||||
|
selectinload(JobSource.job),
|
||||||
|
selectinload(JobSource.source),
|
||||||
|
)
|
||||||
|
if job_id is not None:
|
||||||
|
query = query.where(JobSource.job_id == job_id)
|
||||||
|
result = await _session.exec(query)
|
||||||
|
return result.all()
|
||||||
|
|
||||||
|
async def update_job_source_transcription(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
job_id: UUID,
|
||||||
|
source_id: UUID,
|
||||||
|
text: str | None,
|
||||||
|
error_detail: str | None = None,
|
||||||
|
ai_metadata: TranscriptionMetadata | dict[str, JsonValue] | None = None,
|
||||||
|
raw_api_response: dict[str, JsonValue] | None = None,
|
||||||
|
provider: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
request_manifest: RequestManifest | None = None,
|
||||||
|
quality_warnings: dict[str, JsonValue] | None = None,
|
||||||
|
timing_breakdown: dict[str, JsonValue] | None = None,
|
||||||
|
transport_evidence: TransportEvidence | None = None,
|
||||||
|
failure_phase: str | None = None,
|
||||||
|
error_category: str | None = None,
|
||||||
|
started_at: datetime | None = None,
|
||||||
|
finished_at: datetime | None = None,
|
||||||
|
duration_ms: int | None = None,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> JobSource:
|
||||||
|
"""Persist transcription fields for one source within a specific job."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
job = await self._get_or_raise(
|
||||||
|
Job,
|
||||||
|
job_id,
|
||||||
|
session=_session,
|
||||||
|
error=TranscriptionNotFoundError,
|
||||||
|
noun="Job",
|
||||||
|
suggestion="Verify the job id and retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
source = await self._read_source(session=_session, source_id=source_id)
|
||||||
|
|
||||||
|
if source.document_id != job.document_id:
|
||||||
|
raise TranscriptionError(
|
||||||
|
f"Source {source_id} does not belong to job {job_id}",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Link the source to the same document as the job and retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
job.provider = provider or job.provider or self.settings.provider.value
|
||||||
|
job.model = model or job.model or _resolve_transcript_model(provider=self.provider, settings=self.settings)
|
||||||
|
metadata_payload = _validate_transcription_metadata(ai_metadata)
|
||||||
|
raw_response_payload = _validate_json_object(raw_api_response, field_name="raw_api_response")
|
||||||
|
timing_payload = _validate_json_object(timing_breakdown, field_name="timing_breakdown")
|
||||||
|
attempt_metadata = _merge_attempt_metadata(
|
||||||
|
metadata=metadata_payload,
|
||||||
|
quality_warnings=quality_warnings,
|
||||||
|
timing_breakdown=timing_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_job_source = await _session.exec(
|
||||||
|
select(JobSource).where(JobSource.job_id == job_id).where(JobSource.source_id == source_id)
|
||||||
|
)
|
||||||
|
job_source = existing_job_source.first()
|
||||||
|
outcome = JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED
|
||||||
|
if job_source is None:
|
||||||
|
job_source = JobSource(job_id=job_id, source_id=source_id, status=outcome)
|
||||||
|
_session.add(job_source)
|
||||||
|
try:
|
||||||
|
await _session.flush()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise self._job_source_conflict(job_id=job_id, source_id=source_id) from exc
|
||||||
|
else:
|
||||||
|
job_source.status = outcome
|
||||||
|
|
||||||
|
finish_time = finished_at or datetime.now(UTC)
|
||||||
|
start_time = started_at or finish_time
|
||||||
|
attempt_number = (
|
||||||
|
await _session.exec(
|
||||||
|
select(func.max(ExecutionAttempt.attempt_number))
|
||||||
|
.where(ExecutionAttempt.job_id == job_id)
|
||||||
|
.where(ExecutionAttempt.source_id == source_id)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
transport = transport_evidence or TransportEvidence(response_received=False)
|
||||||
|
manifest_payload = request_manifest.model_dump(mode="json") if request_manifest is not None else None
|
||||||
|
software_payload = (
|
||||||
|
request_manifest.software.model_dump(mode="json") if request_manifest is not None else None
|
||||||
|
)
|
||||||
|
attempt = ExecutionAttempt(
|
||||||
|
job_source_id=job_source.id,
|
||||||
|
job_id=job_id,
|
||||||
|
source_id=source_id,
|
||||||
|
attempt_number=(attempt_number or 0) + 1,
|
||||||
|
status=outcome,
|
||||||
|
provider=provider or job.provider or self.settings.provider.value,
|
||||||
|
model=model or job.model,
|
||||||
|
request_manifest=manifest_payload,
|
||||||
|
request_manifest_sha256=request_manifest.digest() if request_manifest is not None else None,
|
||||||
|
request_manifest_schema_version=(
|
||||||
|
request_manifest.schema_version if request_manifest is not None else None
|
||||||
|
),
|
||||||
|
response_received=transport.response_received,
|
||||||
|
transport_status_code=transport.status_code,
|
||||||
|
transport_body=transport.body,
|
||||||
|
transport_content_type=transport.content_type,
|
||||||
|
transport_content_encoding=transport.content_encoding,
|
||||||
|
transport_safe_headers=transport.safe_headers or None,
|
||||||
|
router_request_id=transport.request_id,
|
||||||
|
router_generation_id=transport.generation_id,
|
||||||
|
sdk_response_snapshot=raw_response_payload,
|
||||||
|
normalized_metadata=attempt_metadata,
|
||||||
|
software_context=software_payload,
|
||||||
|
raw_transcription=text,
|
||||||
|
error_category=error_category,
|
||||||
|
error_detail=error_detail,
|
||||||
|
failure_phase=failure_phase,
|
||||||
|
started_at=start_time,
|
||||||
|
finished_at=finish_time,
|
||||||
|
duration_ms=duration_ms
|
||||||
|
if duration_ms is not None
|
||||||
|
else max(0, int((finish_time - start_time).total_seconds() * 1000)),
|
||||||
|
)
|
||||||
|
_session.add(attempt)
|
||||||
|
await _session.flush()
|
||||||
|
|
||||||
|
if (
|
||||||
|
text is not None
|
||||||
|
and source.raw_transcription is None
|
||||||
|
and source.preferred_execution_attempt_id is None
|
||||||
|
):
|
||||||
|
source.raw_transcription = text
|
||||||
|
source.preferred_execution_attempt_id = attempt.id
|
||||||
|
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(job, source, job_source, attempt))
|
||||||
|
return job_source
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _job_source_conflict(*, job_id: UUID, source_id: UUID) -> TranscriptionError:
|
||||||
|
return TranscriptionError(
|
||||||
|
f"Source {source_id} is already linked to Job {job_id}",
|
||||||
|
category=ErrorCategory.CONFLICT,
|
||||||
|
suggestion="Use the existing job-source link instead of creating a duplicate.",
|
||||||
|
)
|
||||||
|
|
||||||
|
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 self._read_source(session=_session, source_id=source_id)
|
||||||
|
|
||||||
|
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, col(JobSource.source_id) == col(Source.id))
|
||||||
|
.where(col(JobSource.job_id) == job_id)
|
||||||
|
.where(col(Source.revised_text).is_not(None))
|
||||||
|
.order_by(col(Source.date_revised))
|
||||||
|
)
|
||||||
|
result = await _session.exec(query)
|
||||||
|
return result.all()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_transcript_model(*, provider: TranscriptionProvider, settings: Settings) -> str:
|
||||||
|
provider_model = provider.model
|
||||||
|
if provider_model and provider_model.strip():
|
||||||
|
return provider_model
|
||||||
|
|
||||||
|
if settings.provider_model and settings.provider_model.strip():
|
||||||
|
return settings.provider_model
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_transcription_metadata(
|
||||||
|
metadata: TranscriptionMetadata | dict[str, JsonValue] | None,
|
||||||
|
) -> dict[str, JsonValue] | None:
|
||||||
|
if metadata is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
validated = (
|
||||||
|
metadata if isinstance(metadata, TranscriptionMetadata) else TranscriptionMetadata.model_validate(metadata)
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise TranscriptionError(
|
||||||
|
"Transcription metadata failed validation",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Persist only normalized provider execution metadata.",
|
||||||
|
) from exc
|
||||||
|
return validated.as_json_object()
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_attempt_metadata(
|
||||||
|
metadata: dict[str, JsonValue] | None,
|
||||||
|
*,
|
||||||
|
quality_warnings: dict[str, JsonValue] | None,
|
||||||
|
timing_breakdown: dict[str, JsonValue] | None,
|
||||||
|
) -> dict[str, JsonValue] | None:
|
||||||
|
"""Attach app-computed metadata to provider-normalized metadata.
|
||||||
|
|
||||||
|
App-computed values (quality warnings and timing) are namespaced so provider
|
||||||
|
metadata remains semantically distinct.
|
||||||
|
"""
|
||||||
|
if quality_warnings is None and timing_breakdown is None:
|
||||||
|
return metadata
|
||||||
|
merged: dict[str, JsonValue] = dict(metadata or {})
|
||||||
|
if quality_warnings is not None:
|
||||||
|
merged["transcription_quality_warnings"] = quality_warnings
|
||||||
|
if timing_breakdown is not None:
|
||||||
|
merged["processing_timing"] = timing_breakdown
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_json_object(
|
||||||
|
payload: dict[str, JsonValue] | None,
|
||||||
|
*,
|
||||||
|
field_name: str,
|
||||||
|
) -> dict[str, JsonValue] | None:
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return JSON_OBJECT_ADAPTER.validate_python(payload)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise TranscriptionError(
|
||||||
|
f"{field_name} must be a JSON-compatible object",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Remove non-JSON values before persisting provider diagnostics.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def hash_prompt_text(prompt_text: str) -> str:
|
||||||
|
"""Return the canonical SHA-256 provenance hash for prompt text."""
|
||||||
|
return hashlib.sha256(prompt_text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def transcribe_document_image(
|
||||||
|
image_path: str | Path,
|
||||||
|
*,
|
||||||
|
prompt_name: str | None = None,
|
||||||
|
prompt_text: str | None = None,
|
||||||
|
temperature: float | None = None,
|
||||||
|
top_p: float | None = None,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
provider: TranscriptionProvider | None = None,
|
||||||
|
source_reference: SourceEvidenceReference | None = None,
|
||||||
|
requested_model: str | None = None,
|
||||||
|
) -> TranscriptionResult:
|
||||||
|
"""Transcribe a local image using the configured prompt and provider."""
|
||||||
|
runtime_settings = settings or get_settings()
|
||||||
|
if prompt_text is None:
|
||||||
|
prompt_execution = await asyncio.to_thread(
|
||||||
|
build_prompt_execution, prompt_name=prompt_name, settings=runtime_settings
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
effective_prompt_name = (prompt_name or runtime_settings.default_prompt_name or DEFAULT_PROMPT_FILE).strip()
|
||||||
|
prompt_execution = PromptExecution(
|
||||||
|
prompt_name=effective_prompt_name,
|
||||||
|
prompt_hash=hash_prompt_text(prompt_text),
|
||||||
|
system_prompt=None,
|
||||||
|
user_prompt=prompt_text,
|
||||||
|
temperature=temperature if temperature is not None else runtime_settings.transcription_temperature,
|
||||||
|
top_p=top_p if top_p is not None else runtime_settings.transcription_top_p,
|
||||||
|
)
|
||||||
|
image_bytes, mime_type = await asyncio.to_thread(load_source_payload, image_path)
|
||||||
|
|
||||||
|
owns_adapter = provider is None
|
||||||
|
adapter = provider or get_transcription_provider(settings=runtime_settings)
|
||||||
|
logger.info("Starting transcription for image=%s mime_type=%s", image_path, mime_type)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with handle_transcription_errors():
|
||||||
|
result = await adapter.transcribe(
|
||||||
|
prompt_text=prompt_execution.user_prompt,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
mime_type=mime_type,
|
||||||
|
temperature=prompt_execution.temperature,
|
||||||
|
top_p=prompt_execution.top_p,
|
||||||
|
source_reference=source_reference,
|
||||||
|
requested_model=requested_model,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if owns_adapter:
|
||||||
|
await adapter.aclose()
|
||||||
|
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
|
||||||
|
return TranscriptionResult(
|
||||||
|
text=result.text,
|
||||||
|
provider=result.provider,
|
||||||
|
prompt_name=prompt_execution.prompt_name,
|
||||||
|
prompt_hash=prompt_execution.prompt_hash,
|
||||||
|
system_prompt=prompt_execution.system_prompt,
|
||||||
|
user_prompt=result.user_prompt or prompt_execution.user_prompt,
|
||||||
|
temperature=result.temperature if result.temperature is not None else prompt_execution.temperature,
|
||||||
|
top_p=result.top_p if result.top_p is not None else prompt_execution.top_p,
|
||||||
|
model=result.model,
|
||||||
|
metadata=result.metadata,
|
||||||
|
raw_api_response=result.raw_api_response,
|
||||||
|
request_manifest=result.request_manifest,
|
||||||
|
transport_evidence=result.transport_evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt_execution(*, prompt_name: str | None = None, settings: Settings | None = None) -> PromptExecution:
|
||||||
|
"""Resolve the exact prompt payload and provenance for one execution."""
|
||||||
|
runtime_settings = settings or get_settings()
|
||||||
|
effective_prompt_name = (prompt_name or runtime_settings.default_prompt_name or DEFAULT_PROMPT_FILE).strip()
|
||||||
|
user_prompt = load_prompt_text(prompt_name=effective_prompt_name, settings=runtime_settings)
|
||||||
|
return PromptExecution(
|
||||||
|
prompt_name=effective_prompt_name,
|
||||||
|
prompt_hash=hash_prompt_text(user_prompt),
|
||||||
|
system_prompt=None,
|
||||||
|
user_prompt=user_prompt,
|
||||||
|
temperature=runtime_settings.transcription_temperature,
|
||||||
|
top_p=runtime_settings.transcription_top_p,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settings | None = None) -> str:
|
||||||
|
"""Load and validate prompt text from PROMPT_DIR."""
|
||||||
|
runtime_settings = settings or get_settings()
|
||||||
|
prompt_root = runtime_settings.prompt_dir.resolve()
|
||||||
|
prompt_path = (prompt_root / prompt_name).resolve()
|
||||||
|
if prompt_path.parent != prompt_root:
|
||||||
|
raise PromptLoadError(
|
||||||
|
f"Prompt file must be directly inside PROMPT_DIR: {prompt_name}",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Configure a prompt filename without directory components.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not prompt_path.exists() or not prompt_path.is_file():
|
||||||
|
raise PromptLoadError(
|
||||||
|
f"Prompt file not found: {prompt_path}",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Verify PROMPT_DIR and prompt file configuration, then retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt_text = prompt_path.read_text(encoding="utf-8").strip()
|
||||||
|
if not prompt_text:
|
||||||
|
raise PromptLoadError(
|
||||||
|
f"Prompt file is empty: {prompt_path}",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Populate the prompt file with valid instructions and retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Loaded prompt artifact: %s", prompt_path)
|
||||||
|
return prompt_text
|
||||||
|
|
||||||
|
|
||||||
|
def source_mime_type(filename: str | Path) -> str:
|
||||||
|
"""Return the canonical MIME type for a supported Source filename."""
|
||||||
|
mime_type = lookup_source_mime_type(filename)
|
||||||
|
if mime_type is None:
|
||||||
|
suffix = Path(filename).suffix.lower()
|
||||||
|
raise TranscriptionError(
|
||||||
|
f"Unsupported Source format: {suffix or '<none>'}",
|
||||||
|
category=ErrorCategory.USER_INPUT,
|
||||||
|
suggestion=f"Use one of the supported Source formats: {supported_source_formats()}.",
|
||||||
|
)
|
||||||
|
return mime_type
|
||||||
|
|
||||||
|
|
||||||
|
def validate_source_content(*, filename: str | Path, content: bytes) -> str:
|
||||||
|
"""Validate Source content and return its canonical MIME type."""
|
||||||
|
if not content:
|
||||||
|
raise TranscriptionError(
|
||||||
|
"Source content is empty",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Select a non-empty Source file and try again.",
|
||||||
|
)
|
||||||
|
|
||||||
|
safe_name = Path(filename).name
|
||||||
|
if not safe_name:
|
||||||
|
raise TranscriptionError(
|
||||||
|
"Source filename is required",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Choose a Source file with a valid filename and retry.",
|
||||||
|
)
|
||||||
|
return source_mime_type(safe_name)
|
||||||
|
|
||||||
|
|
||||||
|
def load_source_payload(source_path: str | Path) -> tuple[bytes, str]:
|
||||||
|
"""Read Source bytes and resolve MIME type from the canonical format policy.
|
||||||
|
|
||||||
|
Blocking. Async callers must dispatch this through ``asyncio.to_thread``.
|
||||||
|
"""
|
||||||
|
path = Path(source_path)
|
||||||
|
|
||||||
|
if not path.exists() or not path.is_file():
|
||||||
|
raise TranscriptionError(
|
||||||
|
f"Source file not found: {path}",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Verify the Source file exists and retry from the jobs page.",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = path.read_bytes()
|
||||||
|
return content, validate_source_content(filename=path.name, content=content)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def handle_transcription_errors():
|
||||||
|
"""Context manager to handle transcription errors."""
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except ProviderAuthError as exc:
|
||||||
|
raise TranscriptionError(
|
||||||
|
"Provider authentication failed",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Verify provider API credentials and retry.",
|
||||||
|
) from exc
|
||||||
|
except ProviderResponseError as exc:
|
||||||
|
raise TranscriptionError(
|
||||||
|
"Provider returned an invalid response",
|
||||||
|
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||||
|
suggestion="Retry once. If it persists, try a different provider model or inspect provider status.",
|
||||||
|
retriable=True,
|
||||||
|
) from exc
|
||||||
|
except ProviderError as exc:
|
||||||
|
raise TranscriptionError(
|
||||||
|
f"Provider transcription failed: {exc}",
|
||||||
|
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||||
|
suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
|
||||||
|
retriable=True,
|
||||||
|
) from exc
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
@@ -11,50 +16,118 @@ from transcription.config import get_settings
|
|||||||
from transcription.errors import AppError
|
from transcription.errors import AppError
|
||||||
from transcription.errors import ErrorCategory
|
from transcription.errors import ErrorCategory
|
||||||
|
|
||||||
from ..models import Document
|
from ..db.models import Document
|
||||||
from ..models import Job
|
from ..db.models import Job
|
||||||
from .documents import UploadJobResult
|
from ..db.models import JobSource
|
||||||
|
from ..db.models import JobSourceStatus
|
||||||
|
from ..db.models import Source
|
||||||
|
from ..db.session import SessionFactory
|
||||||
|
from ..db.session import session_scope
|
||||||
|
from .errors import TranscriptionError
|
||||||
|
from .media_storage import persist_named_media
|
||||||
|
from .normalization import normalize_orientation_async
|
||||||
|
from .sources import build_prompt_execution
|
||||||
|
from .sources import source_mime_type
|
||||||
|
from .sources import validate_source_content
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
|
|
||||||
|
class SourceStorageError(AppError):
|
||||||
|
"""Raised when Source content cannot be validated or persisted safely."""
|
||||||
|
|
||||||
|
|
||||||
class UploadError(AppError):
|
@dataclass(frozen=True)
|
||||||
"""Raised when uploaded content cannot be persisted safely."""
|
class JobCreateResult:
|
||||||
|
"""Summary of explicit Job create records."""
|
||||||
|
|
||||||
|
document_id: UUID
|
||||||
|
job_id: UUID
|
||||||
|
source_ids: tuple[UUID, ...]
|
||||||
|
|
||||||
|
|
||||||
async def create_upload_job(
|
@dataclass(frozen=True)
|
||||||
|
class DocumentJobResult:
|
||||||
|
"""Summary of a Document, Source, and Job created together."""
|
||||||
|
|
||||||
|
document_id: UUID
|
||||||
|
job_id: UUID
|
||||||
|
stored_path: Path
|
||||||
|
original_filename: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StoredSourceFile:
|
||||||
|
"""A persisted Source file and the identity of the bytes actually stored."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
file_hash: str
|
||||||
|
file_size_bytes: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PendingStoredSource:
|
||||||
|
"""Pre-staged Source artifact tied to a Source id."""
|
||||||
|
|
||||||
|
source_id: UUID
|
||||||
|
original_filename: str
|
||||||
|
stored_path: Path
|
||||||
|
file_hash: str
|
||||||
|
file_size_bytes: int
|
||||||
|
|
||||||
|
|
||||||
|
async def create_document_job(
|
||||||
*,
|
*,
|
||||||
filename: str,
|
filename: str,
|
||||||
file_bytes: bytes,
|
file_bytes: bytes,
|
||||||
session: AsyncSession,
|
session: AsyncSession | None = None,
|
||||||
|
session_factory: SessionFactory | None = None,
|
||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
) -> UploadJobResult:
|
) -> DocumentJobResult:
|
||||||
"""Create upload-backed document and queued job records."""
|
"""Create a Document, its first Source, and a queued Job.
|
||||||
|
|
||||||
|
Owns its own session when the caller does not supply one, so UI callers
|
||||||
|
never have to import a session scope.
|
||||||
|
"""
|
||||||
runtime_settings = settings or get_settings()
|
runtime_settings = settings or get_settings()
|
||||||
stored_path = store_file(
|
prompt_execution = build_prompt_execution(settings=runtime_settings)
|
||||||
|
document_id = uuid4()
|
||||||
|
source_id = uuid4()
|
||||||
|
stored = await store_source_file(
|
||||||
filename=filename,
|
filename=filename,
|
||||||
file_bytes=file_bytes,
|
file_bytes=file_bytes,
|
||||||
settings=runtime_settings,
|
settings=runtime_settings,
|
||||||
|
relative_directory=Path("documents") / str(document_id),
|
||||||
|
filename_stem=str(source_id),
|
||||||
)
|
)
|
||||||
|
stored_path = stored.path
|
||||||
try:
|
try:
|
||||||
document, job = await _create_upload_records(
|
async with session_scope(
|
||||||
|
session_factory=session_factory,
|
||||||
session=session,
|
session=session,
|
||||||
|
settings=runtime_settings,
|
||||||
|
) as _session:
|
||||||
|
document, job = await _create_document_job_records(
|
||||||
|
session=_session,
|
||||||
|
document_id=document_id,
|
||||||
|
source_id=source_id,
|
||||||
original_filename=filename,
|
original_filename=filename,
|
||||||
stored_path=stored_path,
|
stored_path=stored_path,
|
||||||
|
file_hash=stored.file_hash,
|
||||||
|
file_size_bytes=stored.file_size_bytes,
|
||||||
|
upload_dir=runtime_settings.upload_dir,
|
||||||
|
prompt_execution=prompt_execution,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_best_effort_delete(stored_path)
|
_best_effort_delete(stored_path)
|
||||||
raise UploadError(
|
raise SourceStorageError(
|
||||||
"Failed to create upload database records",
|
"Failed to create Document, Source, and Job records",
|
||||||
category=ErrorCategory.INFRA_TRANSIENT,
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
suggestion="Retry upload. If this keeps happening, verify database availability.",
|
suggestion="Retry upload. If this keeps happening, verify database availability.",
|
||||||
retriable=True,
|
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
logger.info("Created upload job document_id=%s job_id=%s", document.id, job.id)
|
logger.info("Created document job document_id=%s job_id=%s", document.id, job.id)
|
||||||
return UploadJobResult(
|
return DocumentJobResult(
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
stored_path=stored_path,
|
stored_path=stored_path,
|
||||||
@@ -62,84 +135,269 @@ async def create_upload_job(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _create_upload_records(
|
async def create_job_for_document(
|
||||||
|
*,
|
||||||
|
document_id: UUID,
|
||||||
|
source_files: Sequence[tuple[str, bytes]],
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
session_factory: SessionFactory | None = None,
|
||||||
|
provider: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
) -> JobCreateResult:
|
||||||
|
"""Create a queued Job for an existing Document with one or more Sources.
|
||||||
|
|
||||||
|
Owns its own session when the caller does not supply one, so UI callers
|
||||||
|
never have to import a session scope.
|
||||||
|
"""
|
||||||
|
if not source_files:
|
||||||
|
raise SourceStorageError(
|
||||||
|
"At least one Source file is required to create a Job",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Upload one or more files and try again.",
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime_settings = settings or get_settings()
|
||||||
|
prompt_execution = build_prompt_execution(settings=runtime_settings)
|
||||||
|
sorted_source_files = sorted(source_files, key=lambda item: Path(item[0]).name.casefold())
|
||||||
|
stored_sources: list[PendingStoredSource] = []
|
||||||
|
for filename, file_bytes in sorted_source_files:
|
||||||
|
source_id = uuid4()
|
||||||
|
stored = await store_source_file(
|
||||||
|
filename=filename,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
settings=runtime_settings,
|
||||||
|
relative_directory=Path("documents") / str(document_id),
|
||||||
|
filename_stem=str(source_id),
|
||||||
|
)
|
||||||
|
stored_sources.append(
|
||||||
|
PendingStoredSource(
|
||||||
|
source_id=source_id,
|
||||||
|
original_filename=filename,
|
||||||
|
stored_path=stored.path,
|
||||||
|
file_hash=stored.file_hash,
|
||||||
|
file_size_bytes=stored.file_size_bytes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session_scope(
|
||||||
|
session_factory=session_factory,
|
||||||
|
session=session,
|
||||||
|
settings=runtime_settings,
|
||||||
|
) as _session:
|
||||||
|
job, source_ids = await _create_job_for_document_records(
|
||||||
|
session=_session,
|
||||||
|
document_id=document_id,
|
||||||
|
stored_sources=stored_sources,
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
upload_dir=runtime_settings.upload_dir,
|
||||||
|
prompt_execution=prompt_execution,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
for source in stored_sources:
|
||||||
|
_best_effort_delete(source.stored_path)
|
||||||
|
raise SourceStorageError(
|
||||||
|
"Failed to create Job records from Source files",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Retry creation. If this keeps happening, verify database availability.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
logger.info("Created explicit job document_id=%s job_id=%s sources=%s", document_id, job.id, len(source_ids))
|
||||||
|
return JobCreateResult(
|
||||||
|
document_id=document_id,
|
||||||
|
job_id=job.id,
|
||||||
|
source_ids=tuple(source_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_document_job_records(
|
||||||
*,
|
*,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
document_id: UUID,
|
||||||
|
source_id: UUID,
|
||||||
original_filename: str,
|
original_filename: str,
|
||||||
stored_path: Path,
|
stored_path: Path,
|
||||||
|
file_hash: str,
|
||||||
|
file_size_bytes: int,
|
||||||
|
upload_dir: Path,
|
||||||
|
prompt_execution,
|
||||||
) -> tuple[Document, Job]:
|
) -> tuple[Document, Job]:
|
||||||
document = Document(
|
document = Document(
|
||||||
filename=Path(original_filename).name,
|
id=document_id,
|
||||||
file_path=str(stored_path),
|
name=Path(original_filename).name,
|
||||||
)
|
)
|
||||||
session.add(document)
|
session.add(document)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
job = Job(document_id=document.id)
|
job = Job(
|
||||||
|
document_id=document.id,
|
||||||
|
prompt_name=prompt_execution.prompt_name,
|
||||||
|
prompt_hash=prompt_execution.prompt_hash,
|
||||||
|
system_prompt=prompt_execution.system_prompt,
|
||||||
|
user_prompt=prompt_execution.user_prompt,
|
||||||
|
temperature=prompt_execution.temperature,
|
||||||
|
top_p=prompt_execution.top_p,
|
||||||
|
)
|
||||||
session.add(job)
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
source = Source(
|
||||||
|
id=source_id,
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name=Path(original_filename).name,
|
||||||
|
filename=stored_path.name,
|
||||||
|
file_path=_upload_relative_path(stored_path=stored_path, upload_dir=upload_dir),
|
||||||
|
file_hash=file_hash,
|
||||||
|
file_size_bytes=file_size_bytes,
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
JobSource(
|
||||||
|
job_id=job.id,
|
||||||
|
source_id=source.id,
|
||||||
|
status=JobSourceStatus.PENDING,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(document)
|
await session.refresh(document)
|
||||||
await session.refresh(job)
|
await session.refresh(job)
|
||||||
return document, job
|
return document, job
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_job_for_document_records(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
document_id: UUID,
|
||||||
|
stored_sources: Sequence[PendingStoredSource],
|
||||||
|
provider: str | None,
|
||||||
|
model: str | None,
|
||||||
|
upload_dir: Path,
|
||||||
|
prompt_execution,
|
||||||
|
) -> tuple[Job, list[UUID]]:
|
||||||
|
document = await session.get(Document, document_id)
|
||||||
|
if document is None:
|
||||||
|
raise SourceStorageError(
|
||||||
|
f"Document with id {document_id} not found",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Select an existing document and retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_sources = (await session.exec(select(Source).where(Source.document_id == document_id))).all()
|
||||||
|
next_page_number = max((source.page_number for source in existing_sources), default=0) + 1
|
||||||
|
|
||||||
|
job = Job(
|
||||||
|
document_id=document_id,
|
||||||
|
provider=(provider or None),
|
||||||
|
model=(model or None),
|
||||||
|
prompt_name=prompt_execution.prompt_name,
|
||||||
|
prompt_hash=prompt_execution.prompt_hash,
|
||||||
|
system_prompt=prompt_execution.system_prompt,
|
||||||
|
user_prompt=prompt_execution.user_prompt,
|
||||||
|
temperature=prompt_execution.temperature,
|
||||||
|
top_p=prompt_execution.top_p,
|
||||||
|
)
|
||||||
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
source_ids: list[UUID] = []
|
||||||
|
for page_offset, stored_source in enumerate(stored_sources):
|
||||||
|
source = Source(
|
||||||
|
id=stored_source.source_id,
|
||||||
|
document_id=document_id,
|
||||||
|
page_number=next_page_number + page_offset,
|
||||||
|
upload_name=Path(stored_source.original_filename).name,
|
||||||
|
filename=stored_source.stored_path.name,
|
||||||
|
file_path=_upload_relative_path(
|
||||||
|
stored_path=stored_source.stored_path,
|
||||||
|
upload_dir=upload_dir,
|
||||||
|
),
|
||||||
|
file_hash=stored_source.file_hash,
|
||||||
|
file_size_bytes=stored_source.file_size_bytes,
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
source_ids.append(source.id)
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
JobSource(
|
||||||
|
job_id=job.id,
|
||||||
|
source_id=source.id,
|
||||||
|
status=JobSourceStatus.PENDING,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(job)
|
||||||
|
return job, source_ids
|
||||||
|
|
||||||
|
|
||||||
def _best_effort_delete(path: Path) -> None:
|
def _best_effort_delete(path: Path) -> None:
|
||||||
try:
|
try:
|
||||||
if path.exists():
|
if path.exists():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
except OSError:
|
except OSError:
|
||||||
logger.warning("Failed to clean up upload file after DB error: %s", path)
|
logger.warning("Failed to clean up Source file after database error: %s", path)
|
||||||
|
|
||||||
|
|
||||||
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
|
def _upload_relative_path(*, stored_path: Path, upload_dir: Path) -> str:
|
||||||
"""Persist an uploaded file to the configured upload directory."""
|
return stored_path.resolve().relative_to(upload_dir.resolve()).as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
async def store_source_file(
|
||||||
|
*,
|
||||||
|
filename: str,
|
||||||
|
file_bytes: bytes,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
relative_directory: Path | None = None,
|
||||||
|
filename_stem: str | None = None,
|
||||||
|
) -> StoredSourceFile:
|
||||||
|
"""Validate, orient, and persist a Source file to configured media storage.
|
||||||
|
|
||||||
|
Orientation is applied here, at the ingest boundary, so the stored bytes are
|
||||||
|
already upright and the hash and byte size recorded on the ``Source`` row
|
||||||
|
describe exactly what is on disk and exactly what a provider is later sent.
|
||||||
|
"""
|
||||||
runtime_settings = settings or get_settings()
|
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:
|
try:
|
||||||
stored_path.write_bytes(file_bytes)
|
validate_source_content(filename=filename, content=file_bytes)
|
||||||
except OSError as exc:
|
except TranscriptionError as exc:
|
||||||
raise UploadError(
|
raise SourceStorageError(
|
||||||
"Failed to persist upload file",
|
exc.message,
|
||||||
category=ErrorCategory.INFRA_PERSISTENT,
|
category=exc.category,
|
||||||
suggestion="Check upload directory permissions and available disk space, then retry.",
|
suggestion=exc.suggestion,
|
||||||
|
retriable=exc.retriable,
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
logger.info("Stored uploaded file: %s", stored_path)
|
normalized = await normalize_orientation_async(file_bytes, media_type=source_mime_type(filename))
|
||||||
return stored_path
|
if normalized is not None:
|
||||||
|
logger.info(
|
||||||
|
"Normalized Source orientation on ingest: %s (orientation=%s, rotation=%s)",
|
||||||
def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
|
Path(filename).name,
|
||||||
if not file_bytes:
|
normalized.original_orientation,
|
||||||
raise UploadError(
|
normalized.applied_rotation_degrees,
|
||||||
"Upload payload is empty",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Select a non-empty file and try again.",
|
|
||||||
)
|
)
|
||||||
|
file_bytes = normalized.content
|
||||||
|
|
||||||
safe_name = Path(filename).name
|
upload_dir = runtime_settings.upload_dir
|
||||||
if not safe_name:
|
stored_path = await persist_named_media(
|
||||||
raise UploadError(
|
root=upload_dir,
|
||||||
"Upload filename is required",
|
namespace=relative_directory,
|
||||||
category=ErrorCategory.VALIDATION,
|
filename=filename,
|
||||||
suggestion="Choose a file with a valid filename and retry.",
|
filename_stem=filename_stem,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
error=SourceStorageError,
|
||||||
|
failure_message="Failed to persist Source file",
|
||||||
|
failure_suggestion="Check upload directory permissions and available disk space, then retry.",
|
||||||
|
log_label="Source file",
|
||||||
)
|
)
|
||||||
|
return StoredSourceFile(
|
||||||
suffix = Path(safe_name).suffix.lower()
|
path=stored_path,
|
||||||
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS:
|
file_hash=hashlib.sha256(file_bytes).hexdigest(),
|
||||||
raise UploadError(
|
file_size_bytes=len(file_bytes),
|
||||||
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}"
|
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
"""Prompt loading and provider-backed transcription service."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import mimetypes
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from sqlmodel import select
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from transcription.config import Settings
|
|
||||||
from transcription.config import get_settings
|
|
||||||
from transcription.errors import AppError
|
|
||||||
from transcription.errors import ErrorCategory
|
|
||||||
from transcription.models import Transcript
|
|
||||||
from transcription.providers import ProviderAuthError
|
|
||||||
from transcription.providers import ProviderError
|
|
||||||
from transcription.providers import ProviderResponseError
|
|
||||||
from transcription.providers import TranscriptionProvider
|
|
||||||
from transcription.providers import TranscriptionResult
|
|
||||||
from transcription.providers import get_transcription_provider
|
|
||||||
|
|
||||||
from .base import ServiceBase
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
DEFAULT_PROMPT_FILE = "transcribe_document.md"
|
|
||||||
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
|
|
||||||
|
|
||||||
|
|
||||||
class PromptLoadError(AppError):
|
|
||||||
"""Raised when prompt artifacts cannot be loaded safely."""
|
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionError(AppError):
|
|
||||||
"""Raised when transcription execution fails."""
|
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionNotFoundError(TranscriptionError):
|
|
||||||
"""Raised when a transcription is not found in the database."""
|
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionService(ServiceBase):
|
|
||||||
"""Service class for managing transcription operations.
|
|
||||||
|
|
||||||
This is the top-level service that composes functionality from the other services."""
|
|
||||||
|
|
||||||
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_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> Transcript:
|
|
||||||
"""Create a new transcript in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
_session.add(transcript)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
|
|
||||||
return transcript
|
|
||||||
|
|
||||||
async def read_transcript(self, transcript_id: UUID, *, session: AsyncSession | None = None) -> Transcript:
|
|
||||||
"""Read an existing transcript from the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
transcript = await _session.get(
|
|
||||||
Transcript,
|
|
||||||
transcript_id,
|
|
||||||
# Makes the full Job model object available in the return Transcript object
|
|
||||||
options=(selectinload(Transcript.job),), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
if transcript is None:
|
|
||||||
raise TranscriptionNotFoundError(
|
|
||||||
f"Transcript with id {transcript_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the transcript id and retry.",
|
|
||||||
)
|
|
||||||
return transcript
|
|
||||||
|
|
||||||
async def update_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> Transcript:
|
|
||||||
"""Update an existing transcript in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
merged = await _session.merge(transcript)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
|
||||||
return merged
|
|
||||||
|
|
||||||
async def delete_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> None:
|
|
||||||
"""Delete a transcript from the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
await _session.delete(transcript)
|
|
||||||
await self._finalize(session=_session, caller_session=session)
|
|
||||||
|
|
||||||
async def transcribe_document(
|
|
||||||
self,
|
|
||||||
image_path: str | Path,
|
|
||||||
job_id: UUID,
|
|
||||||
*,
|
|
||||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
|
||||||
session: AsyncSession | 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.create_transcript_for_job(
|
|
||||||
job_id=job_id,
|
|
||||||
text=result.text,
|
|
||||||
provider=result.provider,
|
|
||||||
model=result.model,
|
|
||||||
prompt_name=result.prompt_name,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def create_transcript_for_job(
|
|
||||||
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,
|
|
||||||
) -> Transcript:
|
|
||||||
"""Create a new transcript revision for a job id."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
rev_query = select(func.max(Transcript.revision)).where(Transcript.job_id == job_id)
|
|
||||||
rev_result = await _session.exec(rev_query)
|
|
||||||
max_revision = -1 if (rev := rev_result.one_or_none()) is None else rev
|
|
||||||
next_revision = max_revision + 1
|
|
||||||
|
|
||||||
transcript = Transcript(
|
|
||||||
job_id=job_id,
|
|
||||||
revision=next_revision,
|
|
||||||
provider=provider or self.settings.provider.value,
|
|
||||||
model=model or _resolve_transcript_model(provider=self.provider, settings=self.settings),
|
|
||||||
prompt_name=prompt_name,
|
|
||||||
text=text,
|
|
||||||
error_detail=error_detail,
|
|
||||||
)
|
|
||||||
_session.add(transcript)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
|
|
||||||
return transcript
|
|
||||||
|
|
||||||
async def read_latest_transcript_by_job(
|
|
||||||
self,
|
|
||||||
job_id: UUID,
|
|
||||||
*,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Transcript | None:
|
|
||||||
"""Read the latest transcript revision for a job id."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = _transcript_job_query(job_id=job_id).limit(1)
|
|
||||||
result = await _session.exec(query)
|
|
||||||
return result.one_or_none()
|
|
||||||
|
|
||||||
async def list_transcripts_by_job(
|
|
||||||
self,
|
|
||||||
job_id: UUID,
|
|
||||||
*,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Sequence[Transcript]:
|
|
||||||
"""List transcript revisions for a job id in ascending revision order."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = _transcript_job_query(job_id=job_id)
|
|
||||||
result = await _session.exec(query)
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
|
|
||||||
def _transcript_job_query(job_id: UUID):
|
|
||||||
return (
|
|
||||||
select(Transcript)
|
|
||||||
.where(Transcript.job_id == job_id)
|
|
||||||
.options(selectinload(Transcript.job)) # pyright: ignore[reportArgumentType]
|
|
||||||
.order_by(Transcript.revision) # pyright: ignore[reportArgumentType]
|
|
||||||
) # fmt: skip
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_transcript_model(*, provider: TranscriptionProvider, settings: Settings) -> str:
|
|
||||||
provider_model = getattr(provider, "model", None)
|
|
||||||
if isinstance(provider_model, str) and provider_model.strip():
|
|
||||||
return provider_model
|
|
||||||
|
|
||||||
if settings.provider_model and settings.provider_model.strip():
|
|
||||||
return settings.provider_model
|
|
||||||
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
async def transcribe_document_image(
|
|
||||||
image_path: str | Path,
|
|
||||||
*,
|
|
||||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
provider: TranscriptionProvider | None = None,
|
|
||||||
) -> TranscriptionResult:
|
|
||||||
"""Transcribe a local image using the configured prompt and provider."""
|
|
||||||
runtime_settings = settings or get_settings()
|
|
||||||
prompt_text = load_prompt_text(prompt_name=prompt_name, settings=runtime_settings)
|
|
||||||
image_bytes, mime_type = load_image_payload(image_path)
|
|
||||||
|
|
||||||
adapter = provider or get_transcription_provider(settings=runtime_settings)
|
|
||||||
logger.info("Starting transcription for image=%s mime_type=%s", image_path, mime_type)
|
|
||||||
|
|
||||||
with handle_transcription_errors():
|
|
||||||
result = await adapter.transcribe(
|
|
||||||
prompt_text=prompt_text,
|
|
||||||
image_bytes=image_bytes,
|
|
||||||
mime_type=mime_type,
|
|
||||||
)
|
|
||||||
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settings | None = None) -> str:
|
|
||||||
"""Load and validate prompt text from PROMPT_DIR."""
|
|
||||||
runtime_settings = settings or get_settings()
|
|
||||||
prompt_path = runtime_settings.prompt_dir / prompt_name
|
|
||||||
|
|
||||||
if not prompt_path.exists() or not prompt_path.is_file():
|
|
||||||
raise PromptLoadError(
|
|
||||||
f"Prompt file not found: {prompt_path}",
|
|
||||||
category=ErrorCategory.INFRA_PERSISTENT,
|
|
||||||
suggestion="Verify PROMPT_DIR and prompt file configuration, then retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt_text = prompt_path.read_text(encoding="utf-8").strip()
|
|
||||||
if not prompt_text:
|
|
||||||
raise PromptLoadError(
|
|
||||||
f"Prompt file is empty: {prompt_path}",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Populate the prompt file with valid instructions and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Loaded prompt artifact: %s", prompt_path)
|
|
||||||
return prompt_text
|
|
||||||
|
|
||||||
|
|
||||||
def load_image_payload(image_path: str | Path) -> tuple[bytes, str]:
|
|
||||||
"""Read image bytes and detect mime type for supported uploads."""
|
|
||||||
path = Path(image_path)
|
|
||||||
|
|
||||||
if not path.exists() or not path.is_file():
|
|
||||||
raise TranscriptionError(
|
|
||||||
f"Image file not found: {path}",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the uploaded file exists and retry from the jobs page.",
|
|
||||||
)
|
|
||||||
|
|
||||||
suffix = path.suffix.lower()
|
|
||||||
if suffix not in SUPPORTED_EXTENSIONS:
|
|
||||||
raise TranscriptionError(
|
|
||||||
f"Unsupported file type: {suffix}",
|
|
||||||
category=ErrorCategory.USER_INPUT,
|
|
||||||
suggestion="Use JPG, JPEG, PNG, TIFF, or PDF files.",
|
|
||||||
)
|
|
||||||
|
|
||||||
mime_type, _ = mimetypes.guess_type(path.name)
|
|
||||||
if suffix in {".tif", ".tiff"}:
|
|
||||||
mime_type = "image/tiff"
|
|
||||||
if not mime_type:
|
|
||||||
raise TranscriptionError(
|
|
||||||
f"Unable to determine MIME type for: {path}",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Re-save the file in a supported format and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return path.read_bytes(), mime_type
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def handle_transcription_errors():
|
|
||||||
"""Context manager to handle transcription errors."""
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
except ProviderAuthError as exc:
|
|
||||||
raise TranscriptionError(
|
|
||||||
"Provider authentication failed",
|
|
||||||
category=ErrorCategory.INFRA_PERSISTENT,
|
|
||||||
suggestion="Verify provider API credentials and retry.",
|
|
||||||
) from exc
|
|
||||||
except ProviderResponseError as exc:
|
|
||||||
raise TranscriptionError(
|
|
||||||
"Provider returned an invalid response",
|
|
||||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
|
||||||
suggestion="Retry once. If it persists, try a different provider model or inspect provider status.",
|
|
||||||
retriable=True,
|
|
||||||
) from exc
|
|
||||||
except ProviderError as exc:
|
|
||||||
raise TranscriptionError(
|
|
||||||
"Provider transcription failed",
|
|
||||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
|
||||||
suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
|
|
||||||
retriable=True,
|
|
||||||
) from exc
|
|
||||||
@@ -1,23 +1,144 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from ..config import Settings
|
from ..config import Settings
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
|
from ..db.models import Document
|
||||||
|
from ..db.models import Job
|
||||||
|
from ..db.models import JobPurpose
|
||||||
|
from ..db.models import JobSource
|
||||||
|
from ..db.models import JobSourceStatus
|
||||||
|
from ..db.models import JobStatus
|
||||||
|
from ..db.models import Source
|
||||||
|
from ..db.session import transaction_scope
|
||||||
from ..errors import AppError
|
from ..errors import AppError
|
||||||
|
from ..errors import ErrorCategory
|
||||||
from ..errors import classify_unexpected_error
|
from ..errors import classify_unexpected_error
|
||||||
from ..errors import format_error_detail
|
from ..errors import format_error_detail
|
||||||
from ..models import Job
|
from ..providers import ProviderError
|
||||||
from ..models import JobStatus
|
from ..providers import RequestManifest
|
||||||
|
from ..providers import SourceEvidenceReference
|
||||||
from ..providers import TranscriptionResult
|
from ..providers import TranscriptionResult
|
||||||
|
from ..providers import TransportEvidence
|
||||||
from . import ServiceBundle
|
from . import ServiceBundle
|
||||||
from .transcription import DEFAULT_PROMPT_FILE
|
from .documents import DocumentService
|
||||||
from .transcription import transcribe_document_image
|
from .people import DocumentPersonInput
|
||||||
|
from .people import PeopleService
|
||||||
|
from .quality import analyze_transcription_quality
|
||||||
|
from .quality import quality_warning_payload
|
||||||
|
from .sources import PromptExecution
|
||||||
|
from .sources import build_prompt_execution
|
||||||
|
from .sources import build_provider_input
|
||||||
|
from .sources import hash_prompt_text
|
||||||
|
from .sources import transcribe_document_image
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_document_with_people(
|
||||||
|
*,
|
||||||
|
document: Document,
|
||||||
|
links: list[DocumentPersonInput],
|
||||||
|
tag_labels: list[str],
|
||||||
|
documents: DocumentService,
|
||||||
|
people: PeopleService,
|
||||||
|
) -> Document:
|
||||||
|
"""Create a Document and its complete Linked People set atomically."""
|
||||||
|
async with transaction_scope(session_factory=documents.session_factory) as session:
|
||||||
|
created = await documents.create_document(document, session=session)
|
||||||
|
await people.sync_document_people(document_id=created.id, links=links, session=session)
|
||||||
|
await documents.sync_document_tags_by_labels(document_id=created.id, labels=tag_labels, session=session)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
async def update_document_with_people(
|
||||||
|
*,
|
||||||
|
document: Document,
|
||||||
|
links: list[DocumentPersonInput],
|
||||||
|
tag_labels: list[str],
|
||||||
|
documents: DocumentService,
|
||||||
|
people: PeopleService,
|
||||||
|
) -> Document:
|
||||||
|
"""Update a Document and its complete Linked People set atomically."""
|
||||||
|
async with transaction_scope(session_factory=documents.session_factory) as session:
|
||||||
|
updated = await documents.update_document(document, session=session)
|
||||||
|
await people.sync_document_people(document_id=updated.id, links=links, session=session)
|
||||||
|
await documents.sync_document_tags_by_labels(document_id=updated.id, labels=tag_labels, session=session)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
async def create_source_retranscription_job(
|
||||||
|
*,
|
||||||
|
source_id,
|
||||||
|
model: str,
|
||||||
|
services: ServiceBundle,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
) -> Job:
|
||||||
|
"""Create one immutable queued retranscription Job for an existing Source."""
|
||||||
|
runtime_settings = settings or get_settings()
|
||||||
|
if model not in runtime_settings.provider_models:
|
||||||
|
raise AppError(
|
||||||
|
f"Model is not configured for retranscription: {model}",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Select one of the configured provider models.",
|
||||||
|
)
|
||||||
|
prompt = build_prompt_execution(settings=runtime_settings)
|
||||||
|
async with transaction_scope(session_factory=services.jobs.session_factory) as session:
|
||||||
|
source = await services.sources.read_source(source_id, session=session)
|
||||||
|
job = await services.jobs.create_job(
|
||||||
|
Job(
|
||||||
|
document_id=source.document_id,
|
||||||
|
purpose=JobPurpose.RETRANSCRIPTION,
|
||||||
|
provider=runtime_settings.provider.value,
|
||||||
|
model=model,
|
||||||
|
prompt_name=prompt.prompt_name,
|
||||||
|
prompt_hash=prompt.prompt_hash,
|
||||||
|
system_prompt=prompt.system_prompt,
|
||||||
|
user_prompt=prompt.user_prompt,
|
||||||
|
temperature=prompt.temperature,
|
||||||
|
top_p=prompt.top_p,
|
||||||
|
),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
await services.sources.create_job_source(
|
||||||
|
JobSource(job_id=job.id, source_id=source.id),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _SuccessfulPage:
|
||||||
|
source: Source
|
||||||
|
result: TranscriptionResult
|
||||||
|
started_at: datetime
|
||||||
|
finished_at: datetime
|
||||||
|
provider_duration_ms: int
|
||||||
|
processing_duration_ms: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _FailedPage:
|
||||||
|
source: Source
|
||||||
|
error: AppError
|
||||||
|
started_at: datetime
|
||||||
|
finished_at: datetime
|
||||||
|
provider_duration_ms: int
|
||||||
|
processing_duration_ms: int
|
||||||
|
request_manifest: RequestManifest | None = None
|
||||||
|
transport_evidence: TransportEvidence | None = None
|
||||||
|
failure_phase: str | None = None
|
||||||
|
sdk_response_snapshot: dict | None = None
|
||||||
|
normalized_metadata: dict | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
async def advance_job(
|
async def advance_job(
|
||||||
job: Job,
|
job: Job,
|
||||||
services: ServiceBundle,
|
services: ServiceBundle,
|
||||||
@@ -26,9 +147,13 @@ async def advance_job(
|
|||||||
) -> Job | None:
|
) -> Job | None:
|
||||||
"""Advance a single job by lifecycle status."""
|
"""Advance a single job by lifecycle status."""
|
||||||
settings = settings or get_settings()
|
settings = settings or get_settings()
|
||||||
match job.status:
|
current_status = _coerce_job_status(job.status)
|
||||||
|
match current_status:
|
||||||
case JobStatus.QUEUED:
|
case JobStatus.QUEUED:
|
||||||
return await process_queued_job(job=job, services=services, session=session)
|
return await process_queued_job(job=job, services=services, settings=settings, session=session)
|
||||||
|
case JobStatus.PROCESSING:
|
||||||
|
# Recover mid-flight jobs by continuing the queued processing path.
|
||||||
|
return await process_queued_job(job=job, services=services, settings=settings, session=session)
|
||||||
case JobStatus.FAILED:
|
case JobStatus.FAILED:
|
||||||
if job.retry_count < settings.worker_max_retries:
|
if job.retry_count < settings.worker_max_retries:
|
||||||
return await services.jobs.update_job_state(
|
return await services.jobs.update_job_state(
|
||||||
@@ -44,39 +169,158 @@ async def advance_job(
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
async def process_queued_job(
|
async def process_queued_job( # noqa: PLR0915
|
||||||
*,
|
*,
|
||||||
job: Job,
|
job: Job,
|
||||||
services: ServiceBundle,
|
services: ServiceBundle,
|
||||||
|
settings: Settings | None = None,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> Job | None:
|
) -> Job | None:
|
||||||
"""Process one complete transcription attempt for a queued job."""
|
"""Process one complete transcription attempt for a queued job."""
|
||||||
if job.status != JobStatus.QUEUED:
|
runtime_settings = settings or get_settings()
|
||||||
|
current_status = _coerce_job_status(job.status)
|
||||||
|
if current_status not in {JobStatus.QUEUED, JobStatus.PROCESSING}:
|
||||||
logger.warning(f"Job {job.id} is not queued. Current status: {job.status}")
|
logger.warning(f"Job {job.id} is not queued. Current status: {job.status}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Transaction A: claim job for processing.
|
# Transaction A: claim job for processing. Reached only when a caller hands us a
|
||||||
|
# still-QUEUED job directly; the worker path already claimed it atomically in
|
||||||
|
# JobService.claim_next_queued_job.
|
||||||
|
if current_status == JobStatus.QUEUED:
|
||||||
if session is None:
|
if session is None:
|
||||||
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING)
|
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING)
|
||||||
else:
|
else:
|
||||||
# If we're sharing the session, need to make sure setting the Job to PROCESSING is committed before we start
|
# Commit the PROCESSING transition before transcription starts so the claim
|
||||||
# the transcription, otherwise other workers may see the job as still QUEUED and try to process it.
|
# is durable and visible to any other worker before the long provider call.
|
||||||
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING, session=session)
|
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING, session=session)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
document = job.document
|
source_job = await services.jobs.read_job(job_id=job.id, session=session)
|
||||||
assert document is not None, (
|
sources = _resolve_job_sources(source_job)
|
||||||
f"Job {job.id} has no associated document or the document failed to be loaded by the job service."
|
if not sources and not source_job.job_sources:
|
||||||
|
candidate_sources = await services.sources.list_sources(document_id=job.document_id, session=session)
|
||||||
|
sources = sorted(candidate_sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
|
||||||
|
|
||||||
|
if not sources:
|
||||||
|
return await services.jobs.mark_job_status(job.id, JobStatus.TRANSCRIBED, session=session)
|
||||||
|
|
||||||
|
successful_pages: list[_SuccessfulPage] = []
|
||||||
|
failed_pages: list[_FailedPage] = []
|
||||||
|
externally_stopped = False
|
||||||
|
|
||||||
|
prompt_execution = _resolve_job_prompt_execution(source_job=source_job, settings=runtime_settings)
|
||||||
|
# Resolve the lazy provider property once, outside the timed region. First access
|
||||||
|
# constructs the HTTP client (~0.5s), which would otherwise be booked as provider
|
||||||
|
# latency on the first attempt of every worker process (review log [55]).
|
||||||
|
provider = services.sources.provider
|
||||||
|
|
||||||
|
for source in sources:
|
||||||
|
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
|
||||||
|
externally_stopped = True
|
||||||
|
break
|
||||||
|
|
||||||
|
started_at = datetime.now(UTC)
|
||||||
|
# Fallback start for failures raised before the provider call; reset to the
|
||||||
|
# true call boundary immediately before the wait_for below.
|
||||||
|
monotonic_started_at = asyncio.get_running_loop().time()
|
||||||
|
result: TranscriptionResult | None = None
|
||||||
|
provider_input = None
|
||||||
|
page_outcome: _SuccessfulPage | _FailedPage
|
||||||
|
try:
|
||||||
|
provider_input = build_provider_input(source, upload_dir=runtime_settings.upload_dir)
|
||||||
|
source_reference = SourceEvidenceReference(
|
||||||
|
source_id=source.id,
|
||||||
|
digest_sha256=provider_input.digest_sha256,
|
||||||
|
byte_size=provider_input.byte_size,
|
||||||
|
media_type=provider_input.media_type,
|
||||||
|
page_number=source.page_number,
|
||||||
|
)
|
||||||
|
# Restart the clock so duration_ms covers only what the wait_for below
|
||||||
|
# governs. The pre-loop assignment stays as the fallback for failures
|
||||||
|
# raised before this point, which would otherwise leave it unbound.
|
||||||
|
monotonic_started_at = asyncio.get_running_loop().time()
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
transcribe_document_image(
|
||||||
|
provider_input.path,
|
||||||
|
prompt_name=prompt_execution.prompt_name,
|
||||||
|
prompt_text=prompt_execution.user_prompt,
|
||||||
|
temperature=prompt_execution.temperature,
|
||||||
|
top_p=prompt_execution.top_p,
|
||||||
|
settings=runtime_settings,
|
||||||
|
provider=provider,
|
||||||
|
source_reference=source_reference,
|
||||||
|
requested_model=source_job.model,
|
||||||
|
),
|
||||||
|
timeout=runtime_settings.worker_provider_timeout_seconds,
|
||||||
|
)
|
||||||
|
elapsed_seconds = asyncio.get_running_loop().time() - monotonic_started_at
|
||||||
|
logger.info(
|
||||||
|
"Provider response diagnostics operation=worker.provider_response "
|
||||||
|
"job_id=%s document_id=%s source_id=%s provider=%s model=%s "
|
||||||
|
"finish_reason=%s usage_input_tokens=%s usage_output_tokens=%s usage_total_tokens=%s "
|
||||||
|
"latency_seconds=%.3f text_chars=%s text_lines=%s",
|
||||||
|
job.id,
|
||||||
|
job.document_id,
|
||||||
|
source.id,
|
||||||
|
result.provider,
|
||||||
|
result.model,
|
||||||
|
result.finish_reason or "unknown",
|
||||||
|
result.usage_input_tokens,
|
||||||
|
result.usage_output_tokens,
|
||||||
|
result.usage_total_tokens,
|
||||||
|
elapsed_seconds,
|
||||||
|
len(result.text),
|
||||||
|
_line_count(result.text),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
_validate_transcription_quality(result=result, settings=runtime_settings)
|
||||||
result = await transcribe_document_image(document.file_path)
|
finished_at = datetime.now(UTC)
|
||||||
job = await _finalize_transcribed(job=job, services=services, result=result, session=session)
|
page_outcome = _SuccessfulPage(
|
||||||
logger.info(
|
source=source,
|
||||||
"Job transcribed operation=worker.process_job job_id=%s document_id=%s provider=%s",
|
result=result,
|
||||||
|
started_at=started_at,
|
||||||
|
finished_at=finished_at,
|
||||||
|
provider_duration_ms=max(0, int(elapsed_seconds * 1000)),
|
||||||
|
processing_duration_ms=max(
|
||||||
|
_duration_ms_between(started_at, finished_at),
|
||||||
|
max(0, int(elapsed_seconds * 1000)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
successful_pages.append(page_outcome)
|
||||||
|
except TimeoutError:
|
||||||
|
error = AppError(
|
||||||
|
f"Provider call timed out after {runtime_settings.worker_provider_timeout_seconds:.1f}s",
|
||||||
|
category=ErrorCategory.EXTERNAL_TIMEOUT,
|
||||||
|
suggestion="Retry the job. If this repeats, verify provider latency and request payload size.",
|
||||||
|
retriable=True,
|
||||||
|
)
|
||||||
|
finished_at = datetime.now(UTC)
|
||||||
|
page_outcome = _FailedPage(
|
||||||
|
source=source,
|
||||||
|
error=error,
|
||||||
|
started_at=started_at,
|
||||||
|
finished_at=finished_at,
|
||||||
|
provider_duration_ms=max(
|
||||||
|
0,
|
||||||
|
int((asyncio.get_running_loop().time() - monotonic_started_at) * 1000),
|
||||||
|
),
|
||||||
|
processing_duration_ms=max(
|
||||||
|
_duration_ms_between(started_at, finished_at),
|
||||||
|
max(0, int((asyncio.get_running_loop().time() - monotonic_started_at) * 1000)),
|
||||||
|
),
|
||||||
|
request_manifest=provider.current_request_manifest,
|
||||||
|
transport_evidence=provider.current_transport_evidence,
|
||||||
|
failure_phase="local_timeout",
|
||||||
|
)
|
||||||
|
failed_pages.append(page_outcome)
|
||||||
|
logger.error(
|
||||||
|
"Source failed operation=worker.process_job job_id=%s document_id=%s "
|
||||||
|
"source_id=%s error_id=%s category=%s",
|
||||||
job.id,
|
job.id,
|
||||||
document.id,
|
job.document_id,
|
||||||
result.provider,
|
source.id,
|
||||||
|
error.error_id,
|
||||||
|
error.category.value,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
match exc:
|
match exc:
|
||||||
@@ -85,15 +329,93 @@ async def process_queued_job(
|
|||||||
case _:
|
case _:
|
||||||
error = classify_unexpected_error(exc, operation="worker.process_job")
|
error = classify_unexpected_error(exc, operation="worker.process_job")
|
||||||
|
|
||||||
job = await _finalize_failed(job=job, services=services, error=error, session=session)
|
finished_at = datetime.now(UTC)
|
||||||
|
provider_error = _find_provider_error(exc)
|
||||||
|
page_outcome = _FailedPage(
|
||||||
|
source=source,
|
||||||
|
error=error,
|
||||||
|
started_at=started_at,
|
||||||
|
finished_at=finished_at,
|
||||||
|
provider_duration_ms=max(
|
||||||
|
0,
|
||||||
|
int((asyncio.get_running_loop().time() - monotonic_started_at) * 1000),
|
||||||
|
),
|
||||||
|
processing_duration_ms=max(
|
||||||
|
_duration_ms_between(started_at, finished_at),
|
||||||
|
max(0, int((asyncio.get_running_loop().time() - monotonic_started_at) * 1000)),
|
||||||
|
),
|
||||||
|
request_manifest=(
|
||||||
|
result.request_manifest
|
||||||
|
if result is not None
|
||||||
|
else provider_error.request_manifest
|
||||||
|
if provider_error is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
transport_evidence=(
|
||||||
|
result.transport_evidence
|
||||||
|
if result is not None
|
||||||
|
else provider_error.transport_evidence
|
||||||
|
if provider_error is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
failure_phase=(
|
||||||
|
"transcription_quality"
|
||||||
|
if result is not None
|
||||||
|
else provider_error.failure_phase
|
||||||
|
if provider_error is not None
|
||||||
|
else "application"
|
||||||
|
),
|
||||||
|
sdk_response_snapshot=result.raw_api_response if result is not None else None,
|
||||||
|
normalized_metadata=result.metadata_payload() if result is not None else None,
|
||||||
|
provider=result.provider if result is not None else None,
|
||||||
|
model=result.model if result is not None else None,
|
||||||
|
)
|
||||||
|
failed_pages.append(page_outcome)
|
||||||
logger.error(
|
logger.error(
|
||||||
"Job failed operation=worker.process_job job_id=%s document_id=%s error_id=%s category=%s",
|
"Source failed operation=worker.process_job job_id=%s document_id=%s "
|
||||||
|
"source_id=%s error_id=%s category=%s",
|
||||||
job.id,
|
job.id,
|
||||||
document.id,
|
job.document_id,
|
||||||
|
source.id,
|
||||||
error.error_id,
|
error.error_id,
|
||||||
error.category.value,
|
error.category.value,
|
||||||
)
|
)
|
||||||
return job
|
|
||||||
|
await _persist_page_outcome_durably(
|
||||||
|
job=job,
|
||||||
|
services=services,
|
||||||
|
page=page_outcome,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
|
||||||
|
externally_stopped = True
|
||||||
|
break
|
||||||
|
|
||||||
|
terminal_status = JobStatus.TRANSCRIBED
|
||||||
|
if externally_stopped:
|
||||||
|
terminal_status = JobStatus.FAILED
|
||||||
|
elif failed_pages and successful_pages:
|
||||||
|
terminal_status = JobStatus.PARTIAL_SUCCESS
|
||||||
|
elif failed_pages and not successful_pages:
|
||||||
|
terminal_status = JobStatus.FAILED
|
||||||
|
|
||||||
|
updated_job = await _finalize_batch_outcome(
|
||||||
|
job=job,
|
||||||
|
services=services,
|
||||||
|
status=terminal_status,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Job finished operation=worker.process_job job_id=%s document_id=%s status=%s success_pages=%s failed_pages=%s",
|
||||||
|
updated_job.id,
|
||||||
|
updated_job.document_id,
|
||||||
|
updated_job.status.value,
|
||||||
|
len(successful_pages),
|
||||||
|
len(failed_pages),
|
||||||
|
)
|
||||||
|
return updated_job
|
||||||
|
|
||||||
|
|
||||||
async def process_next_queued_job(
|
async def process_next_queued_job(
|
||||||
@@ -103,141 +425,280 @@ async def process_next_queued_job(
|
|||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Process the next queued job if one exists."""
|
"""Process the next queued job if one exists."""
|
||||||
job = await services.jobs.read_next_queued_job(session=session)
|
job = await services.jobs.claim_next_queued_job(session=session)
|
||||||
|
|
||||||
if job is None:
|
if job is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
await advance_job(job=job, services=services, settings=settings, session=session)
|
# The claim must be durable before the provider call starts, otherwise another
|
||||||
|
# worker could observe the job as still QUEUED and process it a second time.
|
||||||
|
if session is not None:
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await _advance_job_with_containment(job=job, services=services, settings=settings, session=session)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _finalize_transcribed(
|
async def _advance_job_with_containment(
|
||||||
*,
|
*,
|
||||||
job: Job,
|
job: Job,
|
||||||
services: ServiceBundle,
|
services: ServiceBundle,
|
||||||
result: TranscriptionResult,
|
settings: Settings | None,
|
||||||
|
session: AsyncSession | None,
|
||||||
|
) -> None:
|
||||||
|
"""Advance a claimed job, guaranteeing it never stays stuck in PROCESSING.
|
||||||
|
|
||||||
|
The job has already been committed as PROCESSING at this point, and
|
||||||
|
``claim_next_queued_job`` only ever selects QUEUED rows. So an exception
|
||||||
|
escaping ``advance_job`` used to strand the job in PROCESSING permanently,
|
||||||
|
with a single swallowed log line and no recovery path (review log [8]).
|
||||||
|
|
||||||
|
Any escaping exception is therefore classified and the job driven to the
|
||||||
|
terminal FAILED state, which is visible in the UI and resubmittable. The
|
||||||
|
terminal write runs in its own transaction so it is atomic even when the
|
||||||
|
caller's session was left dirty by the failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await advance_job(job=job, services=services, settings=settings, session=session)
|
||||||
|
except Exception as exc:
|
||||||
|
error = exc if isinstance(exc, AppError) else classify_unexpected_error(exc, operation="worker.advance_job")
|
||||||
|
logger.exception(
|
||||||
|
"Job processing failed outside page handling operation=worker.advance_job "
|
||||||
|
"job_id=%s error_id=%s category=%s retriable=%s",
|
||||||
|
job.id,
|
||||||
|
error.error_id,
|
||||||
|
error.category.value,
|
||||||
|
error.retriable,
|
||||||
|
)
|
||||||
|
if session is not None:
|
||||||
|
await session.rollback()
|
||||||
|
await services.jobs.update_job_state(job_id=job.id, status=JobStatus.FAILED)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_job_sources(job: Job) -> list[Source]:
|
||||||
|
"""Resolve pending linked sources for a job in deterministic page order.
|
||||||
|
|
||||||
|
A page is work if it has not already succeeded. CANCELLED is included
|
||||||
|
deliberately: resubmit resets cancelled pages to PENDING, so they are
|
||||||
|
re-attemptable, and a cancelled page that somehow reaches a running job is
|
||||||
|
unfinished work rather than a terminal outcome.
|
||||||
|
"""
|
||||||
|
if not job.job_sources:
|
||||||
|
return []
|
||||||
|
|
||||||
|
sources = [
|
||||||
|
job_source.source
|
||||||
|
for job_source in job.job_sources
|
||||||
|
if job_source.source is not None and job_source.status != JobSourceStatus.TRANSCRIBED
|
||||||
|
]
|
||||||
|
return sorted(sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_job_prompt_execution(*, source_job: Job, settings: Settings) -> PromptExecution:
|
||||||
|
if source_job.user_prompt and source_job.prompt_name:
|
||||||
|
return PromptExecution(
|
||||||
|
prompt_name=source_job.prompt_name,
|
||||||
|
prompt_hash=source_job.prompt_hash or hash_prompt_text(source_job.user_prompt),
|
||||||
|
system_prompt=source_job.system_prompt,
|
||||||
|
user_prompt=source_job.user_prompt,
|
||||||
|
temperature=source_job.temperature,
|
||||||
|
top_p=source_job.top_p,
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_prompt_execution(settings=settings)
|
||||||
|
|
||||||
|
|
||||||
|
async def _job_no_longer_processing(
|
||||||
|
*,
|
||||||
|
job_id,
|
||||||
|
services: ServiceBundle,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return True when job status changed externally from PROCESSING."""
|
||||||
|
latest_job = await services.jobs.read_job(job_id=job_id, session=session)
|
||||||
|
return latest_job.status != JobStatus.PROCESSING
|
||||||
|
|
||||||
|
|
||||||
|
async def _finalize_batch_outcome(
|
||||||
|
*,
|
||||||
|
job: Job,
|
||||||
|
services: ServiceBundle,
|
||||||
|
status: JobStatus,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> Job:
|
) -> Job:
|
||||||
"""Transaction B: transcript + TRANSCRIBED in one commit."""
|
"""Persist the terminal aggregate status after all page outcomes are durable."""
|
||||||
if session is None:
|
if session is None:
|
||||||
async with services.jobs._session_scope() as local_session:
|
async with services.jobs._session_scope() as local_session:
|
||||||
await services.transcriptions.create_transcript_for_job(
|
updated_job = await services.jobs.mark_job_status(job.id, status, session=local_session)
|
||||||
|
await local_session.commit()
|
||||||
|
return updated_job
|
||||||
|
|
||||||
|
updated_job = await services.jobs.mark_job_status(job.id, status, session=session)
|
||||||
|
await session.commit()
|
||||||
|
return updated_job
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_page_outcome_durably(
|
||||||
|
*,
|
||||||
|
job: Job,
|
||||||
|
services: ServiceBundle,
|
||||||
|
page: _SuccessfulPage | _FailedPage,
|
||||||
|
session: AsyncSession | None,
|
||||||
|
) -> None:
|
||||||
|
"""Commit one completed provider call before processing the next source."""
|
||||||
|
task = asyncio.create_task(_persist_page_outcome(job=job, services=services, page=page, session=session))
|
||||||
|
try:
|
||||||
|
await asyncio.shield(task)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await task
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_page_outcome(
|
||||||
|
*,
|
||||||
|
job: Job,
|
||||||
|
services: ServiceBundle,
|
||||||
|
page: _SuccessfulPage | _FailedPage,
|
||||||
|
session: AsyncSession | None,
|
||||||
|
) -> None:
|
||||||
|
if session is None:
|
||||||
|
async with services.sources._session_scope() as local_session:
|
||||||
|
await _write_page_outcome(job=job, services=services, page=page, session=local_session)
|
||||||
|
await local_session.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
await _write_page_outcome(job=job, services=services, page=page, session=session)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_page_outcome(
|
||||||
|
*,
|
||||||
|
job: Job,
|
||||||
|
services: ServiceBundle,
|
||||||
|
page: _SuccessfulPage | _FailedPage,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
if isinstance(page, _SuccessfulPage):
|
||||||
|
source = page.source
|
||||||
|
result = page.result
|
||||||
|
await services.sources.update_job_source_transcription(
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
|
source_id=source.id,
|
||||||
text=result.text,
|
text=result.text,
|
||||||
error_detail=None,
|
error_detail=None,
|
||||||
|
ai_metadata=result.metadata_payload(),
|
||||||
|
raw_api_response=result.raw_api_response,
|
||||||
provider=result.provider,
|
provider=result.provider,
|
||||||
model=result.model,
|
model=result.model,
|
||||||
prompt_name=result.prompt_name,
|
request_manifest=result.request_manifest,
|
||||||
session=local_session,
|
quality_warnings=quality_warning_payload(analyze_transcription_quality(result.text)),
|
||||||
)
|
timing_breakdown={
|
||||||
updated_job = await services.jobs.mark_job_status(
|
"provider_call_duration_ms": page.provider_duration_ms,
|
||||||
job.id,
|
"processing_duration_ms": page.processing_duration_ms,
|
||||||
JobStatus.TRANSCRIBED,
|
},
|
||||||
session=local_session,
|
transport_evidence=result.transport_evidence,
|
||||||
)
|
started_at=page.started_at,
|
||||||
await local_session.commit()
|
finished_at=page.finished_at,
|
||||||
return updated_job
|
duration_ms=page.provider_duration_ms,
|
||||||
|
|
||||||
await services.transcriptions.create_transcript_for_job(
|
|
||||||
job_id=job.id,
|
|
||||||
text=result.text,
|
|
||||||
error_detail=None,
|
|
||||||
provider=result.provider,
|
|
||||||
model=result.model,
|
|
||||||
prompt_name=result.prompt_name,
|
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
updated_job = await services.jobs.mark_job_status(
|
return
|
||||||
job.id,
|
|
||||||
JobStatus.TRANSCRIBED,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return updated_job
|
|
||||||
|
|
||||||
|
await services.sources.update_job_source_transcription(
|
||||||
async def _finalize_retry(
|
|
||||||
*,
|
|
||||||
job: Job,
|
|
||||||
services: ServiceBundle,
|
|
||||||
error: AppError,
|
|
||||||
settings: Settings,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Job:
|
|
||||||
"""Transaction C: transcript error + QUEUED + retry increment in one commit."""
|
|
||||||
if session is None:
|
|
||||||
async with services.jobs._session_scope() as local_session:
|
|
||||||
await services.transcriptions.create_transcript_for_job(
|
|
||||||
job_id=job.id,
|
job_id=job.id,
|
||||||
|
source_id=page.source.id,
|
||||||
text=None,
|
text=None,
|
||||||
error_detail=format_error_detail(error),
|
error_detail=format_error_detail(page.error),
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
ai_metadata=page.normalized_metadata,
|
||||||
session=local_session,
|
raw_api_response=page.sdk_response_snapshot,
|
||||||
)
|
provider=page.provider,
|
||||||
updated_job = await services.jobs.update_job_state(
|
model=page.model,
|
||||||
job_id=job.id,
|
request_manifest=page.request_manifest,
|
||||||
status=JobStatus.QUEUED,
|
timing_breakdown={
|
||||||
retry_count_increment=1,
|
"provider_call_duration_ms": page.provider_duration_ms,
|
||||||
session=local_session,
|
"processing_duration_ms": page.processing_duration_ms,
|
||||||
)
|
},
|
||||||
await local_session.commit()
|
transport_evidence=page.transport_evidence,
|
||||||
else:
|
failure_phase=page.failure_phase,
|
||||||
await services.transcriptions.create_transcript_for_job(
|
error_category=page.error.category.value,
|
||||||
job_id=job.id,
|
started_at=page.started_at,
|
||||||
text=None,
|
finished_at=page.finished_at,
|
||||||
error_detail=format_error_detail(error),
|
duration_ms=page.provider_duration_ms,
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
|
||||||
session=session,
|
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(
|
def _duration_ms_between(started_at: datetime, finished_at: datetime) -> int:
|
||||||
*,
|
return max(0, int((finished_at - started_at).total_seconds() * 1000))
|
||||||
job: Job,
|
|
||||||
services: ServiceBundle,
|
|
||||||
error: AppError,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Job:
|
|
||||||
"""Transaction B: transcript error + FAILED in one commit."""
|
|
||||||
if session is None:
|
|
||||||
async with services.jobs._session_scope() as local_session:
|
|
||||||
await services.transcriptions.create_transcript_for_job(
|
|
||||||
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.create_transcript_for_job(
|
|
||||||
job_id=job.id,
|
def _validate_transcription_quality(*, result: TranscriptionResult, settings: Settings) -> None:
|
||||||
text=None,
|
text_chars = len(result.text)
|
||||||
error_detail=format_error_detail(error),
|
text_lines = _line_count(result.text)
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
|
||||||
session=session,
|
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,
|
||||||
)
|
)
|
||||||
updated_job = await services.jobs.mark_job_status(
|
|
||||||
job.id,
|
if settings.worker_min_transcription_chars > 0 and text_chars < settings.worker_min_transcription_chars:
|
||||||
JobStatus.FAILED,
|
raise AppError(
|
||||||
session=session,
|
(
|
||||||
|
"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,
|
||||||
)
|
)
|
||||||
await session.commit()
|
|
||||||
return updated_job
|
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())
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_job_status(value: object) -> JobStatus | None:
|
||||||
|
if isinstance(value, JobStatus):
|
||||||
|
return value
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
lowered = value.strip().lower()
|
||||||
|
for member in JobStatus:
|
||||||
|
if lowered in {member.value.lower(), member.name.lower()}:
|
||||||
|
return member
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_provider_error(exc: BaseException) -> ProviderError | None:
|
||||||
|
"""Find provider evidence carried through application error translation."""
|
||||||
|
current: BaseException | None = exc
|
||||||
|
while current is not None:
|
||||||
|
if isinstance(current, ProviderError):
|
||||||
|
return current
|
||||||
|
current = current.__cause__ or current.__context__
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,38 +1,34 @@
|
|||||||
"""UI page registration exports."""
|
"""UI page registration exports."""
|
||||||
|
|
||||||
from pathlib import Path
|
from contextlib import suppress
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from nicegui import app as nicegui_app
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.config import get_settings
|
||||||
|
from transcription.ui.pages.documents_page import register_page as register_documents_page
|
||||||
|
from transcription.ui.pages.home_page import register_page as register_home_page
|
||||||
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
||||||
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
from transcription.ui.pages.people_page import register_page as register_people_page
|
||||||
|
from transcription.ui.pages.print_preview_page import register_page as register_print_preview_page
|
||||||
|
from transcription.ui.pages.settings_page import register_page as register_settings_page
|
||||||
|
from transcription.ui.pages.sources_page import register_page as register_sources_page
|
||||||
|
from transcription.ui.pages.tags_page import register_page as register_tags_page
|
||||||
|
from transcription.ui.resources import read_css
|
||||||
|
from transcription.ui.theme import apply_archival_theme
|
||||||
|
|
||||||
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
|
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
|
||||||
|
|
||||||
_THEME_COLORS: dict[str, str] = {
|
|
||||||
"primary": "#6f97e8",
|
|
||||||
"secondary": "#92b5f5",
|
|
||||||
"accent": "#7fc0de",
|
|
||||||
"dark": "#22304a",
|
|
||||||
"dark_page": "#1a2538",
|
|
||||||
"positive": "#86c8ad",
|
|
||||||
"negative": "#d98a9a",
|
|
||||||
"info": "#7ebdda",
|
|
||||||
"warning": "#e2c083",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _register_global_styles(app: FastAPI) -> None:
|
def _register_global_styles(app: FastAPI) -> None:
|
||||||
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
|
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
|
||||||
return
|
return
|
||||||
|
|
||||||
nicegui_app.colors(**_THEME_COLORS)
|
apply_archival_theme()
|
||||||
|
with suppress(RuntimeError):
|
||||||
css_path = Path(__file__).resolve().parent / "static" / "colors.css"
|
ui.add_css(read_css("theme.css"), shared=True)
|
||||||
if css_path.exists():
|
# NiceGUI shared style registration can raise when the app is constructed
|
||||||
ui.add_css(css_path, shared=True)
|
# outside an active client context, which happens in unit tests.
|
||||||
|
|
||||||
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
|
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
|
||||||
|
|
||||||
@@ -40,6 +36,12 @@ def _register_global_styles(app: FastAPI) -> None:
|
|||||||
def register_pages(app: FastAPI) -> None:
|
def register_pages(app: FastAPI) -> None:
|
||||||
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
||||||
_register_global_styles(app)
|
_register_global_styles(app)
|
||||||
register_upload_page()
|
register_home_page()
|
||||||
|
register_documents_page()
|
||||||
|
register_tags_page()
|
||||||
|
register_people_page()
|
||||||
|
register_print_preview_page()
|
||||||
|
register_sources_page()
|
||||||
register_jobs_page()
|
register_jobs_page()
|
||||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=True)
|
register_settings_page(settings=getattr(app.state, "settings", None) or get_settings())
|
||||||
|
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
"""Reusable UI component exports."""
|
"""Reusable UI component exports."""
|
||||||
|
|
||||||
from transcription.ui.components.app_shell import NAV_ITEMS
|
from transcription.ui.components.app_shell import NAV_ITEMS
|
||||||
|
from transcription.ui.components.app_shell import render_app_shell
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
from transcription.ui.components.primitives import destructive_button
|
||||||
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
|
from transcription.ui.components.primitives import section_header_row
|
||||||
|
|
||||||
__all__ = ["NAV_ITEMS", "render_document_panzoom", "render_navigation_header"]
|
__all__ = [
|
||||||
|
"NAV_ITEMS",
|
||||||
|
"destructive_button",
|
||||||
|
"render_app_shell",
|
||||||
|
"render_empty_state",
|
||||||
|
"render_navigation_header",
|
||||||
|
"section_header_row",
|
||||||
|
]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user