11 Commits
90 changed files with 7288 additions and 3770 deletions
-13
View File
@@ -1,13 +0,0 @@
.git
.gitignore
.vscode
.venv
.pytest_cache
.ruff_cache
__pycache__/
*.py[cod]
*.db
.env
tests/
docs/
uploads/
@@ -1,77 +0,0 @@
---
description: Follow these guidelines when editing the services
applyTo: 'src/transcription/services/*.py'
---
# Services
## Structure
- Project core data models defined in [models](../../src/transcription/models.py)
- 1 service class per data model
- Only services directly interact with the database, and only through async methods
- Services are completely independent of one another. Any operation that needs to use more than a single service, which is most of them, needs to have a separate orchestration function.
## Error Handling
- Service-specific errors defined at the top of the respective module and inherit from `AppError`
- Use a context manager for large `try/except` blocks like in [transcription](../../src/transcription/services/transcription.py)
## Checklist
- [ ] Uses `ServiceBase` for common logic
- [ ] CRUD methods created at the top
- [ ] Session kwarg for `AsyncSession` to pass in a session object to each method
- [ ] Services use `self._session_scope` in their methods to pass the session thru.
- Multiple operations on the same object(s) require sharing a session between all the methods used.
## CRUD Methods
- Create, read, update, and delete, created in that order
- Name format `<operation>_<model >`, for example `create_document` or `update_job`
- All services must define these 4 methods first, and in that order
## Transaction Finalization
When a service method accepts an optional `session` kwarg, write methods must use `self._finalize` to finalize the transaction properly according to whether or not they are sharing a session.
- If `session` is `None`: the method owns the transaction and should `commit()`.
- If `session` is provided: the method must **not** commit; it should `flush()` so IDs and FK values are available to the caller's transaction.
- Use `refresh()` on returned ORM objects when the caller needs DB-populated values (defaults, triggers, merged state).
Recommended helper behavior:
- Inputs: active session object, original `session` arg (or a boolean ownership flag), and an optional list of objects to refresh.
- Logic: `commit` when service-owned session, `flush` when caller-owned session, then refresh requested objects.
This keeps orchestration functions atomic: they can pass one shared session across multiple services and commit exactly once at the workflow boundary.
## Workflow Transaction Boundaries
For multi-step job lifecycles (for example queued transcription jobs), orchestration functions must use explicit transaction phases.
Required boundary model:
- **Transaction A (claim):** transition `JobStatus.QUEUED -> JobStatus.PROCESSING` and commit immediately.
- Perform provider/network work **outside** database transactions.
- **Transaction B (terminal success):** write transcript content and set `JobStatus.TRANSCRIBED` in the same shared-session commit.
- **Transaction B (terminal failure):** write transcript error detail and set `JobStatus.FAILED` in the same shared-session commit.
- **Transaction C (retry path):** write transcript error detail, increment retry count, and set `JobStatus.QUEUED` in one shared-session commit.
Atomicity rules:
- Never commit transcript updates separately from the paired terminal/retry job status change.
- Terminal state (`TRANSCRIBED` or `FAILED`) and transcript row changes must succeed or roll back together.
- Retry persistence (`QUEUED` + retry increment + error detail) must succeed or roll back together.
Separation of concerns:
- Worker modules should stay lightweight and delegate lifecycle transitions to service/workflow orchestration functions.
- In `workflows.py`, `process_queued_job` should own one complete attempt lifecycle: `QUEUED -> PROCESSING -> TRANSCRIBED|FAILED`.
- In `workflows.py`, `advance_job` should coordinate broader status progression around attempts (for example retry scheduling from `FAILED -> QUEUED`).
- Services should expose session-aware write helpers (flush on caller-owned session) so orchestration controls commit boundaries.
- Backoff/sleep behavior must run outside transactional scopes.
# Service Composition
Some operations, like uploading a picutre, require modifications to multiple tables, which can be done by composing methods from the service object into a separate function.
-26
View File
@@ -1,26 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Debug transcription app",
"type": "debugpy",
"request": "launch",
"module": "debugpy",
"args": [
"-m",
"uvicorn",
"transcription.app:create_app",
"--factory",
"--host",
"127.0.0.1",
"--port",
"8080"
],
"justMyCode": true,
"console": "integratedTerminal",
"env": {
"PYTHONPATH": "${workspaceFolder}/src"
}
}
]
}
-47
View File
@@ -1,47 +0,0 @@
FROM python:3.12-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_LINK_MODE=copy
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:0.5.24 /uv /uvx /bin/
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-dev --no-install-project
COPY src ./src
COPY prompts ./prompts
RUN uv sync --frozen --no-dev
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/app/.venv/bin:$PATH" \
PYTHONPATH="/app/src" \
UPLOAD_DIR="/app/uploads" \
PROMPT_DIR="/app/prompts"
WORKDIR /app
RUN groupadd --system --gid 1001 appgroup \
&& useradd --system --uid 1001 --gid appgroup --create-home appuser
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src /app/src
COPY --from=builder /app/prompts /app/prompts
RUN mkdir -p /app/uploads /app/data \
&& chown -R appuser:appgroup /app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=3s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"
CMD ["uvicorn", "transcription.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
+44 -1
View File
@@ -34,19 +34,62 @@ Optional settings (defaults shown):
DATABASE_URL=sqlite:///./transcription.db DATABASE_URL=sqlite:///./transcription.db
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
PROMPT_DIR=./prompts PROMPT_DIR=./prompts
MAX_UPLOAD_BYTES=15728640
OPERATOR_ACCESS_ENABLED=false
OPERATOR_USERNAME=operator
# OPERATOR_PASSWORD=replace_with_secure_value
``` ```
### 3) Run the app ### 3) Run the app
```bash ```bash
uv run uvicorn transcription.app:create_app --factory --reload uv run uvicorn transcription.app:create_app --factory --reload
``` ```
### 4) Open in browser ### 4) (Optional) Run explicit migrations/checks
Use the migration runner for Step 4 schema safety workflows:
```bash
uv run python -m transcription.migration_runner --list
uv run python -m transcription.migration_runner --apply
uv run python -m transcription.migration_runner --check
```
### 5) Open in browser
- GUI: [http://[IP_ADDRESS]:8000/ui](http://[IP_ADDRESS]:8000/ui) - GUI: [http://[IP_ADDRESS]:8000/ui](http://[IP_ADDRESS]:8000/ui)
- Health check: [http://[IP_ADDRESS]:8000/healthz](http://[IP_ADDRESS]:8000/healthz) - Health check: [http://[IP_ADDRESS]:8000/healthz](http://[IP_ADDRESS]:8000/healthz)
### Schema safety settings
Optional environment settings (defaults shown):
```env
MIGRATION_AUTO_APPLY_ON_STARTUP=false
VALIDATE_SCHEMA_ON_STARTUP=true
```
### Step 5 security settings
Use this baseline for trusted private-network operation:
```env
OPERATOR_ACCESS_ENABLED=true
OPERATOR_USERNAME=operator
OPERATOR_PASSWORD=replace_with_strong_local_secret
MAX_UPLOAD_BYTES=15728640
```
Notes:
- `/healthz` remains unauthenticated for operational checks.
- `/ui` and `/api` require HTTP Basic credentials when operator access is enabled.
- Keep `OPERATOR_PASSWORD` in environment variables only (never commit secrets).
## How to navigate the GUI ## How to navigate the GUI
- **Upload page** (`/ui`) - **Upload page** (`/ui`)
-21
View File
@@ -1,21 +0,0 @@
services:
transcription:
build:
context: .
dockerfile: Dockerfile
container_name: transcription-app
env_file:
- .env
environment:
DATABASE_URL: sqlite:////app/data/transcription.db
UPLOAD_DIR: /app/uploads
PROMPT_DIR: /app/prompts
ports:
- "8002:8000"
volumes:
- ./uploads:/app/uploads
- transcription_data:/app/data
restart: unless-stopped
volumes:
transcription_data:
@@ -0,0 +1,35 @@
# ADR-0001: Lifespan-owned runtime resources
- **Status:** accepted
- **Date:** 2026-06-25
## Context
MVP initialized core runtime resources (database engine and worker dependencies) through module-level globals and startup side effects. `REQ-7` requires lifespan-owned runtime resources with explicit ownership and cleanup.
## Decision
Adopt lifespan-owned runtime resource initialization in `transcription.app`:
1. Initialize database runtime during app lifespan startup.
2. Store runtime handles on `app.state`.
3. Pass runtime-owned dependencies (engine) to worker startup.
4. Dispose runtime resources explicitly during lifespan shutdown.
## Consequences
### Positive
- Explicit startup and shutdown ownership.
- Predictable cleanup ordering.
- Reduced hidden global side effects.
### Tradeoffs
- Minor wiring complexity in app startup.
- Some call-sites still support fallback lazy initialization for compatibility.
## Alternatives Considered
1. **Keep module-level global ownership**
- Rejected: conflicts with `REQ-7` and increases ambiguity.
2. **Introduce full async DB stack immediately**
- Rejected for Step 1: too broad for architecture-consolidation scope.
@@ -0,0 +1,36 @@
# ADR-0002: Explicit schema bootstrap policy
- **Status:** accepted
- **Date:** 2026-06-25
## Context
MVP called schema bootstrap (`create_all`) on every startup. `REQ-10` requires explicit, opt-in schema bootstrap behavior so normal production startup does not mutate schema.
## Decision
Add environment-aware bootstrap policy:
1. New settings:
- `environment`: `development` | `test` | `production`
- `bootstrap_schema_on_startup`: optional explicit override
2. Default behavior:
- Development/test: bootstrap enabled
- Production: bootstrap disabled
3. App startup calls `create_all` only when policy evaluates true.
## Consequences
### Positive
- Production startup behavior is safer and policy-driven.
- Local development remains simple by default.
### Tradeoffs
- Deployments now require explicit schema management in production.
## Alternatives Considered
1. **Always bootstrap in all environments**
- Rejected: violates `REQ-10` intent.
2. **Disable bootstrap everywhere immediately**
- Rejected: hurts local developer workflow without migration tool replacement yet.
@@ -0,0 +1,31 @@
# ADR-0003: Persistence baseline and transition path
- **Status:** accepted
- **Date:** 2026-06-25
## Context
Architecture targets PostgreSQL baseline (optional MongoDB), while MVP currently runs on SQLite by default. V1 needs a clear transition path without destabilizing ongoing work.
## Decision
1. Preserve database URL configurability through centralized settings.
2. Keep SQLite functional for local dev/test and fast feedback.
3. Treat PostgreSQL as production baseline target for V1 completion.
4. Keep persistence access behind `transcription.db` runtime/session access points.
## Consequences
### Positive
- Clear migration path without immediate broad rewrite.
- Controlled risk while preserving velocity.
### Tradeoffs
- Temporary dual-path assumptions (SQLite local vs PostgreSQL target).
## Alternatives Considered
1. **Immediate forced PostgreSQL-only migration**
- Rejected: higher short-term disruption risk.
2. **Remain SQLite-only for V1**
- Rejected: inconsistent with architecture and requirement trajectory.
@@ -0,0 +1,32 @@
# ADR-0004: In-process worker topology for V1
- **Status:** accepted
- **Date:** 2026-06-25
## Context
The current system uses an in-process background worker. Architecture docs allow this in foundation stage and permit later hardening (optional external worker/queue).
## Decision
Retain in-process worker topology for V1, with improved lifecycle ownership:
1. Worker starts/stops via app lifespan.
2. Worker receives runtime-owned DB engine dependency explicitly.
3. Extension path to external worker remains behind existing service/adapter seams.
## Consequences
### Positive
- Keeps operational complexity low for personal-scale use.
- Preserves delivery focus on V1 completion.
### Tradeoffs
- Throughput/scaling limits remain compared to external queue-based topology.
## Alternatives Considered
1. **Immediate queue/external worker introduction**
- Rejected: premature complexity for current scale.
2. **Ad hoc thread lifecycle management outside lifespan**
- Rejected: weaker shutdown guarantees and poorer ownership clarity.
+20
View File
@@ -0,0 +1,20 @@
# Architecture Decision Records (ADRs)
This directory records significant architecture decisions for Version 1.
## ADR Format
Each ADR should include:
1. **Status** (`proposed`, `accepted`, `superseded`)
2. **Context**
3. **Decision**
4. **Consequences**
5. **Alternatives Considered**
## Index
- [ADR-0001: Lifespan-owned runtime resources](ADR-0001-lifespan-owned-runtime-resources.md)
- [ADR-0002: Explicit schema bootstrap policy](ADR-0002-explicit-schema-bootstrap-policy.md)
- [ADR-0003: Persistence baseline and transition path](ADR-0003-persistence-baseline-and-transition-path.md)
- [ADR-0004: In-process worker topology for V1](ADR-0004-in-process-worker-topology.md)
+2 -4
View File
@@ -6,7 +6,7 @@ This project is a production application for transcribing and preserving histori
Read [architecture.md](architecture.md) first. Read [architecture.md](architecture.md) first.
Then review [ver1/ver1.md](ver1/ver1.md) for completion scope and [ver1/ver1-step1-results.md](ver1/ver1-step1-results.md) for current architecture-consolidation status. Then review [ver1/ver1.md](ver1/ver1.md) for completion scope.
The architecture page is the primary technical reference and defines: The architecture page is the primary technical reference and defines:
@@ -41,10 +41,8 @@ This operating model keeps deployment and maintenance simple while preserving cl
## Documentation Map ## Documentation Map
- Architecture and technical design: [architecture.md](architecture.md)
- Version 1 implementation plan: [ver1/ver1.md](ver1/ver1.md) - Version 1 implementation plan: [ver1/ver1.md](ver1/ver1.md)
- Version 1 Step 1 plan: [ver1/ver1-step1.md](ver1/ver1-step1.md) - Architecture and technical design: [architecture.md](architecture.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) - Architecture decision records (ADR index): [adr/README.md](adr/README.md)
- Runtime and deployment requirements: [requirements.md](requirements.md) - Runtime and deployment requirements: [requirements.md](requirements.md)
- Error handling policy and operational guidance: [error_handling.md](error_handling.md) - Error handling policy and operational guidance: [error_handling.md](error_handling.md)
@@ -0,0 +1,73 @@
# Ver1 Step 1/2 Carry-Forward Checklist
## Purpose
Track open Step 1 and Step 2 follow-ups through later V1 steps, with lightweight verification evidence and requirement traceability.
This artifact implements the carry-forward approach defined in:
- `docs/ver1/ver1-step1-2_revised.md`
Historical records remain unchanged:
- `docs/ver1/ver1-step1.md`
- `docs/ver1/ver1-step1-results.md`
- `docs/ver1/ver1-step2.md`
- `docs/ver1/ver1-step2-results.md`
---
## Status Legend
- `not started`
- `in progress`
- `done`
- `deferred`
---
## Carry-Forward Mapping Matrix
| ID | Carry-Forward Task | Source | Related REQ | Owning V1 Step(s) | Validation Method | Status | Evidence Link/Note |
| --- | --- | --- | --- | --- | --- | --- | --- |
| CF-A1 | Confirm remaining implicit/global runtime ownership and lift only high-impact resources to lifespan ownership | Step 1 residual follow-up | REQ-7 | Step 3, Step 9 | Inspection + test | in progress | Step 3 added `services/library.py` and `api/routes.py` using existing service/session access patterns; no new module-global runtime resource ownership introduced. Reconfirm in Step 9 release readiness. |
| CF-A2 | Finalize migration + rollback runbook usage and rehearse on representative local data | Step 1 residual follow-up | REQ-10 | Step 4, Step 9 | Demonstration + test | not started | |
| CF-A3 | Maintain lightweight boundary enforcement (review checklist and/or simple import checks) | Step 1 residual follow-up | REQ-7, REQ-11 | Step 3, Step 7 | Inspection | in progress | Step 3 implementation keeps UI/API composition thin and pushes revision/search/export logic to `services/library.py`; continue with Step 7 checks. |
| CF-B1 | Build compact error-path inventory for major failure paths and category mapping | Step 2 governance follow-up | REQ-2, REQ-3, REQ-4, REQ-5 | Step 6, Step 7 | Inspection | not started | Use `docs/ver1/ver1-step2-error-path-inventory.md` |
| CF-B2 | Standardize required logging fields at critical boundary handoffs | Step 2 residual follow-up | REQ-3, REQ-4, REQ-8 | Step 6 | Inspection + test | not started | |
| CF-B3 | Revisit retry backoff strategy only if observed runtime behavior justifies extra complexity | Step 2 residual follow-up | REQ-2, REQ-6 | Step 6, Step 8 | Analysis + test | deferred | Keep fixed backoff unless evidence suggests change |
| CF-C1 | Integrate Step 1/2 completed outcomes and open follow-ups into V1 traceability tracking | Revision-plan workstream | REQ-0..REQ-12 (traceability) | Step 3, Step 10 | Inspection | done | Step 3 artifacts added: `docs/ver1/ver1-step3.md`, `docs/ver1/ver1-step3-results.md`, and this checklist updated with Step 3 evidence and routing. |
| CF-C2 | Keep carry-forward routing aligned with revised V1 plan (architecture via 3/4/9, reliability via 6/7) | Revision-plan workstream | REQ-0..REQ-12 (execution alignment) | Step 3+ | Inspection | in progress | Step 3 execution followed routing: functional features implemented in Step 3; migration/rollback items remain in Step 4/9; logging/error-path standardization remains Step 6/7. |
---
## Execution Notes
### Step 3 (Functional Completion)
- Use CF-A1 and CF-A3 during requirement-slice implementation reviews.
- Record any discovered boundary/runtime ownership gaps in this checklist.
### Step 4 (Data Model and Migration Safety)
- Execute CF-A2 rehearsal and link evidence (commands, runbook notes, outcomes).
### Step 6 (Minimal Observability & Operability)
- Execute CF-B1 and CF-B2 with focused artifacts and log-field verification.
### Step 7 (Test Coverage and Practical Quality Gates)
- Add/verify tests supporting CF-A3 and CF-B1/B2 where meaningful.
### Step 8 (Performance Validation)
- Reassess CF-B3 only if retries/backoff are observed to cause practical issues.
### Step 9 (Release Readiness)
- Reconfirm CF-A1/A2 readiness in release checklist and rollback drill.
### Step 10 (Documentation Completion)
- Ensure final V1 docs reference outcomes from this checklist where relevant.
---
## Acceptance Check for Carry-Forward Completion
- [ ] Historical Step 1/2 documents remain unchanged.
- [ ] Every open Step 1/2 follow-up has an owning V1 step and validation method.
- [ ] Evidence links are recorded for each completed carry-forward item.
- [ ] No carry-forward item introduces unnecessary complexity for personal-scale operation.
+166
View File
@@ -0,0 +1,166 @@
# Ver1 Step 1 & Step 2 Revision Plan (Additive)
## Purpose
Define a **targeted implementation follow-through plan** for Step 1 and Step 2 outcomes so remaining V1 work stays aligned with `docs/ver1/ver1.md`:
- personal-scale operation
- single operator
- private-network assumptions
- low operational overhead
- practical, testable controls
This document is additive and does **not** replace or revise historical Step 1/Step 2 records.
---
## Source Documents Reviewed
- `docs/ver1/ver1.md`
- `docs/ver1/ver1-step1.md`
- `docs/ver1/ver1-step1-results.md`
- `docs/ver1/ver1-step2.md`
- `docs/ver1/ver1-step2-results.md`
- `docs/architecture.md`
- `docs/error_handling.md`
- `docs/requirements.md`
- `docs/index.md`
- `docs/intent.md`
---
## Revision Goals
1. Preserve all completed Step 1/Step 2 technical hardening work.
2. Keep historical Step 1/Step 2 documents unchanged.
3. Convert residual risks/follow-ups into concrete implementation tasks for subsequent V1 steps.
4. Preserve traceability to requirements and implemented evidence.
5. Maintain alignment with personal-scale architecture and operating model.
---
## Scope
### In Scope
- Define carry-forward implementation tasks based on Step 1/2 residual risks and open items.
- Map carry-forward tasks to later V1 steps (especially Steps 3, 4, 6, 7, and 9).
- Define lightweight verification evidence expected for each carry-forward task.
- Update V1 traceability references to include completed Step 1/2 outcomes and deferred follow-ups.
### Out of Scope
- Simplifying tone/structure of existing Step 1/2 documents
- Clarifying or rewriting historical Step 1/2 plan/results content
- Editing `docs/ver1/ver1-step1.md`
- Editing `docs/ver1/ver1-step1-results.md`
- Editing `docs/ver1/ver1-step2.md`
- Editing `docs/ver1/ver1-step2-results.md`
- Re-implementing Step 1/2 code changes
- Rewriting `docs/ver1/ver1.md`
- Deleting historical sections/results
- Altering requirements IDs or architecture principles
---
## Carry-Forward Implementation Plan
## Workstream A — Close Step 1 follow-ups through later V1 steps
### A1) Runtime ownership completion (REQ-7 continuity)
- Confirm whether any remaining runtime resources still use implicit/global ownership.
- Move only high-impact remaining resources to explicit lifespan ownership when needed.
- Keep ownership model simple and documented.
### A2) Schema/migration operations readiness (REQ-10 continuity)
- Finalize practical migration + rollback runbook usage in Step 4 execution.
- Rehearse upgrade and rollback on representative local data.
- Keep production startup free from implicit schema mutation.
### A3) Boundary enforcement (lightweight only)
- Keep architecture boundary checks lightweight (review checklist and/or simple import checks).
- Avoid heavy governance tooling unless clear recurring drift appears.
### Expected Outcome
Step 1 architecture hardening remains intact and is completed pragmatically where open items remain.
---
## Workstream B — Close Step 2 follow-ups through later V1 steps
### B1) Error-path inventory and coverage visibility
- Create a compact error-path inventory artifact (or equivalent matrix section) covering major failure paths.
- Ensure each critical path maps to category, retriable policy, and surfaced behavior.
### B2) Logging field consistency at key boundaries
- Standardize required fields at critical failure handoffs (`error_id`, `category`, `operation`, identifiers when available).
- Prioritize worker/API/service boundaries first.
### B3) Retry policy refinement (only if needed)
- Keep current bounded retry baseline.
- Revisit richer backoff strategy only if observed behavior justifies added complexity.
### Expected Outcome
Step 2 reliability behavior stays stable, diagnosable, and right-sized for personal-scale operation.
---
## Workstream C — Integrate Step 1/2 outputs into ongoing V1 governance
### C1) Traceability integration
- Link completed Step 1/2 outcomes and deferred follow-ups to the V1 traceability matrix.
- Ensure open follow-ups have owning step and validation method.
### C2) Execution alignment with revised V1 plan
- Route architecture follow-ups primarily through Steps 3/4/9.
- Route reliability/diagnostics follow-ups primarily through Steps 6/7.
### Expected Outcome
Step 1/2 work is fully carried forward without revising historical documents.
## Deliverables
1. This document (`docs/ver1/ver1-step1-2_revised.md`) as the carry-forward implementation plan.
2. A compact Step 1/2 carry-forward checklist linked to V1 steps and validation methods.
3. Traceability updates showing where each open Step 1/2 follow-up will be closed.
4. Optional new artifact for error-path inventory (if created during Step 6/7 execution).
---
## Acceptance Criteria
- Historical Step 1/Step 2 documents remain unchanged.
- Open Step 1/2 follow-ups are explicitly mapped to later V1 steps with validation expectations.
- No loss of core technical intent (REQ-7, REQ-10, error taxonomy, retry safety, traceability).
- No conflicts introduced with `docs/architecture.md`, `docs/error_handling.md`, or `docs/ver1/ver1.md`.
- Carry-forward tasks remain right-sized for personal-scale operation.
---
## Implementation Order
1. Keep existing Step 1/Step 2 docs unchanged as historical records.
2. Define carry-forward tasks and owning V1 steps in this document.
3. Create and maintain carry-forward traceability artifacts:
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md`
- `docs/ver1/ver1-step2-error-path-inventory.md`
4. Execute carry-forward tasks during Steps 3+ and capture evidence in step results docs.
5. Perform final consistency pass across `docs/ver1/*` references.
---
## Risks and Mitigations
1. **Risk:** Open Step 1/2 items are forgotten as Step 3+ work proceeds.
**Mitigation:** Track each follow-up in the V1 traceability matrix with owning step and evidence expectation.
2. **Risk:** Carry-forward work expands beyond personal-scale needs.
**Mitigation:** Apply simplicity guardrails from `docs/architecture.md` before accepting additional hardening tasks.
3. **Risk:** Reliability follow-ups become fragmented across multiple steps.
**Mitigation:** Keep one consolidated carry-forward checklist and update it at milestone check-ins.
---
## Notes
This revision effort is scope-alignment and implementation-follow-through focused.
Historical Step 1/Step 2 documents are intentionally preserved as-is.
+86
View File
@@ -0,0 +1,86 @@
# Ver1 Step 1 Results: Architecture Consolidation
## Summary
Step 1 implementation has been completed for the primary architecture-consolidation objectives:
1. Lifespan-owned runtime resource model introduced for DB runtime ownership.
2. Schema bootstrap policy changed from implicit-always to explicit/environment-aware.
3. Worker startup now receives lifespan-owned DB engine dependency.
4. ADR set established for key V1 architectural decisions.
## Implemented Changes
### 1) Runtime ownership
- Updated `src/transcription/db.py`:
- Added `DatabaseRuntime` resource model.
- Added explicit runtime lifecycle methods:
- `initialize_database_runtime(...)`
- `get_database_runtime()`
- `dispose_database_runtime()`
- Updated `src/transcription/app.py`:
- Lifespan initializes DB runtime and stores it on `app.state`.
- Lifespan disposes DB runtime on shutdown.
### 2) Schema bootstrap policy (REQ-10 alignment)
- Updated `src/transcription/config.py`:
- Added `environment` setting (`development`, `test`, `production`).
- Added `bootstrap_schema_on_startup` explicit override setting.
- Updated `src/transcription/db.py`:
- Added `should_bootstrap_schema(settings)` policy function.
- Updated `src/transcription/app.py`:
- Startup now calls `create_all(...)` only when policy allows.
### 3) Worker dependency ownership
- Updated `src/transcription/worker.py`:
- `process_next_queued_job(..., engine=None)` now supports explicit engine injection.
- `run_worker_loop(..., engine=None, ...)` now supports explicit engine injection.
- Updated `src/transcription/app.py`:
- Worker thread is started with lifespan-owned engine.
### 4) ADR governance
Created:
- `docs/adr/README.md`
- `docs/adr/ADR-0001-lifespan-owned-runtime-resources.md`
- `docs/adr/ADR-0002-explicit-schema-bootstrap-policy.md`
- `docs/adr/ADR-0003-persistence-baseline-and-transition-path.md`
- `docs/adr/ADR-0004-in-process-worker-topology.md`
## Test Evidence
Targeted regression checks executed successfully:
- `uv run pytest tests/test_app.py tests/test_db.py tests/services/test_worker.py -q`
- Result: pass
## Residual Risks / Follow-ups
1. Full REQ-7 completion may still require broader runtime ownership coverage for additional resources as V1 expands.
2. Production schema management workflow (migrations/runbook tooling) should be finalized in subsequent V1 steps.
3. Additional boundary enforcement automation (import-lint style checks) can be added in later hardening.
## Step 1 Exit Assessment
- Architecture ownership clarity: **met**
- Schema bootstrap policy hardening: **met**
- Worker lifecycle dependency clarity: **met**
- ADR baseline established: **met**
## Completion Checklist With Evidence
| Criterion | Status | Evidence |
| --- | --- | --- |
| Architecture conformance matrix approved | partial | Consolidation implemented and documented in `docs/ver1/ver1-step1.md` + this results doc; formal matrix artifact can be added as a follow-up appendix. |
| REQ-7 ownership gaps resolved or explicitly deferred | met | Lifespan-owned DB runtime and explicit worker engine wiring implemented in `src/transcription/app.py`, `src/transcription/db.py`, `src/transcription/worker.py`. Residual scope documented under follow-ups. |
| REQ-10 explicit bootstrap policy implemented and verified | met | Policy implemented via `environment` + `bootstrap_schema_on_startup` in `src/transcription/config.py`, `should_bootstrap_schema(...)` in `src/transcription/db.py`, startup gate in `src/transcription/app.py`, tested in `tests/test_db.py`. |
| Dependency direction rules documented and enforced | partial | Layering and runtime ownership documented in `docs/architecture.md`. Lightweight enforcement exists via review and test discipline; automated import-lint remains a follow-up. |
| ADR set created for major Step 1 decisions | met | `docs/adr/README.md` and ADR-0001 through ADR-0004 created. |
| Architecture/index docs updated to match implementation | met | `docs/architecture.md` and `docs/index.md` updated with V1 Step 1 runtime policy and links to V1/ADR artifacts. |
| Regression and full test suites pass | met | Targeted: `uv run pytest tests/test_app.py tests/test_db.py tests/services/test_worker.py -q`; full suite: `uv run pytest -q`. |
| Step 1 results artifact published | met | This document (`docs/ver1/ver1-step1-results.md`) created and updated with summary, evidence, risks, and checklist. |
Step 1 is complete and ready to hand off to Ver1 Step 2.
+309
View File
@@ -0,0 +1,309 @@
# Step 1 Implementation Plan: Architecture Consolidation
## Purpose
Align the implemented MVP codebase with the production architecture and V1 constraints documented in:
- `docs/architecture.md`
- `docs/requirements.md`
- `docs/error_handling.md`
- `docs/index.md`
- `docs/intent.md`
- `docs/ver1/ver1.md` (Step 1)
This step hardens architecture boundaries and ownership without expanding product scope.
---
## MCP Skill and Guide Inputs Incorporated
This plan explicitly incorporates patterns and guardrails from john-stream-mcp resources:
1. `resource://skills/fastapi-uv-docker/document`
- App factory and lifespan ownership
- Health endpoint and cloud-native baseline expectations
- Environment-driven configuration and startup discipline
2. `resource://skills/fastapi-async-sqlalchemy-modernization/document`
- Current-state gap audit first
- Target runtime model before refactor
- Explicit resource lifecycle ownership
- Transaction/session boundary clarity
- Phased migration with rollback points
3. `resource://skills/nicegui/document`
- Clear dependency direction
- UI/page registration as composition, not business logic container
- Async responsiveness and boundary separation
4. `resource://prompts/greenfield-architecture/document`
- Pattern-comparison-first planning
- Explicit tradeoffs and staged implementation
- Output contract with risks, open questions, and next steps
---
## Current-State Gap Summary (Architecture vs Implementation)
Based on docs and current `src/transcription` code:
1. **REQ-7 gap (lifespan-owned resources)**
- DB engine/session factory are module globals in `db.py`, not app lifespan-owned.
- Worker thread lifecycle is owned by lifespan (good), but DB/provider resource ownership is mixed.
2. **REQ-10 gap (explicit opt-in schema bootstrap)**
- `create_all()` is executed unconditionally on startup in `app.py`.
3. **Data store target gap (REQ-9 + architecture baseline)**
- Runtime still defaults to SQLite MVP setup; production architecture targets PostgreSQL baseline with optional MongoDB.
4. **Layering clarity gap (architecture layer model)**
- Boundaries exist but are not yet formally enforced (interface/app/domain/infra dependency rules are implicit, not codified).
5. **Decision record gap**
- No ADR set documenting key V1 architectural decisions and deviations from MVP.
---
## Scope for Step 1
### In scope
1. Produce architecture conformance audit and decision records.
2. Define and implement target runtime ownership model for core resources.
3. Establish explicit schema bootstrap policy (opt-in in production paths).
4. Consolidate module boundaries and dependency direction rules.
5. Update architecture docs to reflect implemented reality and V1 trajectory.
### Out of scope
- Full async SQLAlchemy rewrite (plan and seams only if deferred)
- MongoDB feature implementation
- New user-facing features
- Major worker architecture replacement (in-process worker remains baseline)
---
## Target Architecture Decisions for V1
1. **Keep modular monolith topology** (FastAPI + NiceGUI + in-process worker).
2. **Preserve container-light simplicity guardrails** from `architecture.md`.
3. **Move runtime ownership to lifespan** for:
- DB engine/session factory lifecycle
- Worker runtime resources
- Provider client factory/config lifecycle
4. **Adopt explicit schema bootstrap policy**:
- Dev/test: opt-in auto-bootstrap allowed
- Production: startup must not mutate schema implicitly
5. **Formalize boundary map**:
- Interface (`api`, `ui`) -> Application (`services`) -> Domain (`models/rules`) -> Infrastructure (`db`, `providers`)
- No reverse imports
---
## Detailed Work Breakdown
## Phase A — Architecture Audit and Baseline Freeze
- [ ] **A1. Produce architecture conformance matrix**
- Map each architecture section to current modules/files.
- Classify each row: `aligned`, `partial`, `not aligned`.
- [ ] **A2. Produce REQ-7/REQ-9/REQ-10 focused gap report**
- Explicitly capture current vs required state.
- Include operational risk if left unresolved.
- [ ] **A3. Freeze MVP architecture baseline**
- Record current baseline behavior and known temporary shortcuts.
- Link this baseline from `docs/ver1/ver1.md`.
### Deliverables
- `docs/ver1/ver1-step1-audit.md` (or equivalent section in this doc)
- Architecture conformance table
### Exit Criteria
- No architecture changes begin before gap matrix and baseline are approved.
---
## Phase B — Resource Ownership Consolidation (Lifespan-Centric)
- [ ] **B1. Define runtime resource ownership contract**
- `app.py` lifespan owns resource initialization and cleanup order.
- `app.state` carries resource handles/factories.
- No hidden module-global side-effect initialization for runtime resources.
- [ ] **B2. Refactor DB ownership model**
- Replace module-global engine singleton pattern with lifespan-initialized resource model.
- Define one canonical session-factory access path for app/worker/services.
- [ ] **B3. Normalize worker dependencies**
- Ensure worker uses lifespan-owned resources/factories rather than implicit globals.
- Preserve deterministic startup/shutdown behavior.
- [ ] **B4. Define provider adapter ownership**
- Provider client creation strategy is centralized and lifecycle-aware.
- Avoid per-call hidden client construction when unnecessary.
### MCP-Guided Guardrails
- Use explicit lifecycle composition patterns from `fastapi-async-sqlalchemy-modernization`.
- Maintain app-factory + lifespan structure per `fastapi-uv-docker`.
- Keep UI registration as composition only per `nicegui`.
### Exit Criteria
- Core runtime resources have one owner and one cleanup path.
- No critical resource has ambiguous ownership.
---
## Phase C — Schema Bootstrap Policy (REQ-10 Alignment)
- [ ] **C1. Define environment-aware bootstrap policy**
- `auto_create_schema` (or equivalent) disabled in production by default.
- Startup schema mutation is explicit and intentional.
- [ ] **C2. Split startup responsibilities**
- App startup performs health-critical initialization only.
- Schema bootstrap path is moved to explicit command/flag workflow.
- [ ] **C3. Update deployment/runbook docs**
- Document migration/bootstrap flow for dev, staging, prod.
- Ensure policy is testable and auditable.
### Exit Criteria
- Normal production startup path does not call schema auto-create implicitly.
- Bootstrap behavior is explicit and documented.
---
## Phase D — Module Boundary Enforcement
- [ ] **D1. Publish dependency direction rules**
- Allowed import directions across `api`, `ui`, `services`, `models/domain`, `db/providers`.
- Explicitly disallow reverse dependencies.
- [ ] **D2. Reconcile package map with docs**
- Ensure docs architecture elements match real package layout and naming.
- Update docs where intentional deviations remain.
- [ ] **D3. Isolate cross-layer responsibilities**
- Keep API/UI presentation concerns out of services.
- Keep provider/DB specifics out of interface layer.
- [ ] **D4. Add lightweight architecture checks**
- Add static/import checks and/or review checklist in CI/review process.
### Exit Criteria
- Boundary rules are documented and applied.
- Architectural drift can be detected during review/CI.
---
## Phase E — Architecture Decision Records (ADRs)
- [ ] **E1. Create ADR index**
- Add `docs/adr/README.md` with template and status model.
- [ ] **E2. Record minimum V1 ADR set**
1. Runtime ownership model (lifespan-owned resources)
2. Schema bootstrap policy (explicit vs implicit)
3. Persistence baseline (PostgreSQL target; SQLite transition strategy)
4. Worker topology (in-process for V1, extension path preserved)
- [ ] **E3. Cross-link ADRs**
- Link from architecture and V1 docs.
### Exit Criteria
- Major architecture decisions are explicit, versioned, and discoverable.
---
## Phase F — Documentation Consolidation
- [ ] **F1. Update `docs/architecture.md`**
- Reflect real implementation and V1 target state separately.
- Mark transitional choices clearly.
- [ ] **F2. Update `docs/index.md` navigation consistency**
- Ensure architecture/readme references match actual docs/files.
- [ ] **F3. Update `docs/requirements.md` traceability notes**
- Mark REQ-7/REQ-10 status and verification approach after consolidation.
- [ ] **F4. Add Step 1 result summary**
- Create `docs/ver1/ver1-step1-results.md` after implementation.
### Exit Criteria
- Docs are internally consistent and match runtime architecture reality.
---
## Verification Plan
## Architecture Verification Matrix (Step 1)
1. **Inspection**
- Resource ownership map exists and matches code.
- Schema bootstrap policy is explicit and environment-aware.
- ADRs exist for each key architecture decision.
2. **Automated checks**
- Existing test suite remains green.
- New/updated tests validate startup policy (no implicit schema mutation in production mode).
- Import/dependency-direction checks pass (if introduced).
3. **Demonstration**
- App starts in dev mode with explicit expected behavior.
- App starts in production mode without mutating schema implicitly.
- Worker lifecycle starts/stops cleanly with app lifespan.
---
## Risks and Mitigations
1. **Risk:** Refactor destabilizes MVP behavior
**Mitigation:** Phase changes with small PRs and regression checks after each phase.
2. **Risk:** Over-rotation into premature async rewrite
**Mitigation:** Keep this step focused on lifecycle ownership and boundaries; defer full async migration unless required.
3. **Risk:** Schema policy changes break local DX
**Mitigation:** Keep explicit dev bootstrap path simple and documented.
4. **Risk:** Boundary rules become “doc only”
**Mitigation:** Add CI/review enforcement and architecture checklist.
---
## Recommended Implementation Order
1. Phase A — Audit and baseline freeze
2. Phase B — Resource ownership consolidation
3. Phase C — Schema bootstrap policy
4. Phase D — Boundary enforcement
5. Phase E — ADR authoring
6. Phase F — Documentation consolidation
This order minimizes risk: diagnose first, then refactor ownership, then lock policy, then enforce boundaries, and finally finalize docs.
---
## Step 1 Completion Checklist
- [ ] Architecture conformance matrix approved.
- [ ] REQ-7 ownership gaps resolved or explicitly deferred with owner/date.
- [ ] REQ-10 explicit bootstrap policy implemented and verified.
- [ ] Dependency direction rules documented and enforced.
- [ ] ADR set created for all major Step 1 decisions.
- [ ] Architecture and index docs updated to match implementation.
- [ ] Full test suite passes after consolidation.
- [ ] `docs/ver1/ver1-step1-results.md` created with evidence and residual risks.
---
## Handoff to Step 2
Once Step 1 completes, Step 2 (Error Handling & Reliability Hardening) can proceed on stable architecture seams:
- consistent lifecycle ownership,
- explicit startup policy,
- clear module boundaries,
- documented architecture decisions.
@@ -0,0 +1,42 @@
# Ver1 Step 2 Error-Path Inventory (Carry-Forward)
## Purpose
Provide a compact inventory of major failure paths with taxonomy mapping and retry behavior, aligned with:
- `docs/error_handling.md`
- `docs/ver1/ver1-step2-results.md`
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md` (CF-B1)
This is a lightweight operational artifact for Step 6/7 follow-through.
---
## Inventory Table
| Path ID | Boundary/Operation | Typical Failure Source | Category | Retriable | Surface Behavior | Current Coverage | Notes |
| --- | --- | --- | --- | --- | --- | --- | --- |
| EP-API-001 | API upload request validation | invalid payload / empty file metadata | `validation_error` | no | structured API error envelope (400) | partial | confirm all upload variants |
| EP-API-002 | API resource lookup | missing job/document | `not_found_error` | no | structured API error envelope (404) | partial | verify consistency for all lookup routes |
| EP-SVC-001 | Service provider-call mapping | provider SDK/HTTP failure | `external_provider_error` | sometimes | normalized AppError and safe message | partial | ensure consistent mapping in service boundary tests |
| EP-WKR-001 | Worker provider timeout | timeout/unavailable upstream | `external_provider_error` or `infrastructure_transient_error` | yes | retry or terminal failed with persisted reason | partial | validate category mapping remains deterministic |
| EP-WKR-002 | Worker non-retriable domain/input failure | deterministic invalid input/state | `user_input_error` or `conflict_error` | no | immediate terminal failed with persisted reason | partial | ensure no retry on non-retriable categories |
| EP-WKR-003 | Worker retry exhaustion | repeated retriable failure | category from source; terminal state | capped then no | explicit failed status + error detail | met | implemented in Step 2; keep regression coverage |
| EP-UI-001 | UI upload action failure | surfaced AppError or fallback exception | category-based safe user message | category-driven | title + message + suggestion + error id | partial | verify consistency on all primary UI actions |
| EP-LOG-001 | Cross-boundary error logging | missing/uneven fields | n/a | n/a | logs include `error_id`, `category`, `operation`, ids when available | partial | complete in Step 6 (CF-B2) |
---
## Verification Targets (Step 6/7)
1. Every critical path has category + retriable policy defined.
2. API/UI behavior remains safe and actionable.
3. Worker terminal failures are explicit and persisted.
4. Logging fields are consistent at critical handoffs.
---
## Evidence Links
- Step 2 implementation results: `docs/ver1/ver1-step2-results.md`
- Carry-forward tracking: `docs/ver1/ver1-step1-2-carry-forward-checklist.md`
- Canonical contract: `docs/error_handling.md`
+80
View File
@@ -0,0 +1,80 @@
# Ver1 Step 2 Results: Error Handling & Reliability Hardening
## Summary
Step 2 implementation is complete for the planned reliability and error-handling hardening scope:
1. Worker retries are now explicit, bounded, and category-driven.
2. Error behavior is more consistent across worker/API/UI boundaries.
3. Logging now includes stronger boundary context in key failure paths.
4. Test coverage was expanded for retry policy and new reliability settings.
## Implemented Changes
### 1) Worker retry policy and terminal behavior
- Updated `src/transcription/models.py`:
- Added `Job.retry_count` with default `0`.
- Updated `src/transcription/config.py`:
- Added `worker_max_retries`.
- Added `worker_retry_backoff_seconds`.
- Updated `src/transcription/worker.py`:
- Added bounded retry decision path (`_should_retry`).
- Added requeue behavior (`_requeue_for_retry`) for retriable errors.
- Added deterministic terminal failure behavior (`_finalize_failed_job`).
- Preserved transcript failure detail persistence (`error_id`, `category`, suggestion).
### 2) API fallback normalization hardening
- Updated `src/transcription/api/errors.py`:
- Fallback handler now emits safe generic internal message for unhandled exceptions.
- Added structured boundary logging fields including operation and exception type.
### 3) UI interaction reliability guard
- Updated `src/transcription/ui/upload_page.py`:
- Added duplicate in-flight submission guard to prevent repeated upload handling while busy.
### 4) Observability/logging improvements
- Updated worker logs in `src/transcription/worker.py` to include operation and domain identifiers in key transitions:
- pick
- retry
- transcribed
- failed
## Test Coverage Added/Updated
- Updated `tests/test_models.py`:
- Assert `retry_count` default.
- Updated `tests/test_config.py`:
- Added worker retry settings default test.
- Updated `tests/services/test_worker.py`:
- Added retriable requeue test.
- Added retry-exhaustion terminal failure test.
- Updated existing tests for settings-driven worker behavior.
- Existing API error tests remained green with fallback behavior updates:
- `tests/api/test_error_responses.py`
## Verification Evidence
Executed and passing:
- `uv run pytest tests/services/test_worker.py tests/test_models.py tests/test_config.py tests/api/test_error_responses.py -q`
- `uv run pytest -q`
## Residual Risks / Follow-ups
1. Retry policy currently uses simple fixed backoff; richer strategy (exponential/jitter) can be added in later hardening.
2. Full cross-layer structured logging standardization can be expanded in Step 6 observability work.
3. A formal Step 2 error-path inventory artifact (`ver1-step2-audit.md`) is still recommended for governance completeness.
## Step 2 Exit Assessment
- Error taxonomy and envelope stability: **met**
- Bounded retry and terminal failure behavior: **met**
- Worker reliability controls: **met**
- UI interaction hardening for duplicate actions: **met**
- Test coverage expansion and full-suite regression safety: **met**
Step 2 is complete and ready to hand off to Ver1 Step 3.
+302
View File
@@ -0,0 +1,302 @@
# Step 2 Implementation Plan: Error Handling & Reliability Hardening
## Purpose
Implement **Ver1 Step 2** from `docs/ver1/ver1.md` by standardizing failure behavior and reliability controls so the system fails safely, predictably, and transparently across UI, API, services, worker, and provider boundaries.
Primary governing docs:
- `docs/error_handling.md` (authoritative contract)
- `docs/requirements.md` (REQ-2, REQ-3, REQ-4, REQ-5, REQ-6)
- `docs/architecture.md` (boundary ownership and worker lifecycle)
- `docs/ver1/ver1.md` (Step 2 objective)
---
## MCP Skill and Guide Inputs Incorporated
This plan integrates guidance from john-stream-mcp resources:
1. `resource://skills/python-logging-dictconfig/document`
- centralized `dictConfig` logging
- startup-only configuration
- stable named loggers and boundary-level logging discipline
2. `resource://skills/pytesting/document`
- deterministic, behavior-first tests
- explicit marker usage and fast/slow lane discipline
- integration checks for boundary behavior and error contracts
3. `resource://skills/fastapi-async-sqlalchemy-modernization/document`
- classify at source boundary
- explicit transaction/session behavior under failure
- phased rollout with quality gates and rollback awareness
4. `resource://skills/nicegui-ui-customization/document`
- explicit user-facing error feedback for each interaction
- prevent duplicate actions during in-flight operations
- preserve one-way dependency boundaries from UI -> services
5. `resource://skills/fastapi-uv-docker/document` (applied selectively)
- lifespan-safe startup/shutdown behavior
- health/readiness posture and cloud-native operational checks
---
## Current-State Gap Summary
The project already has a strong baseline (`AppError`, taxonomy enum, API envelope, worker persistence), but Step 2 needs completion-level hardening:
1. **Error contract consistency**
- API envelope exists, but consistency must be verified for all error pathways.
2. **Cross-boundary category normalization**
- Provider/service/worker mappings exist, but require stricter policy checks and tests.
3. **Retry policy implementation depth**
- Step 2 requires bounded retry policy and clear terminal behavior for retriable failures.
4. **Operational traceability**
- Logging exists; Step 2 requires consistent structured fields at critical boundaries.
5. **UI failure UX consistency**
- UI error handling exists; Step 2 requires explicit contract coverage and anti-duplication safeguards.
---
## Scope for Step 2
### In scope
1. Enforce canonical error taxonomy and envelope across all boundaries.
2. Standardize logging fields and boundary-level error traceability.
3. Implement/complete bounded retry and terminal failure behavior in worker paths.
4. Improve UI/API error presentation consistency and actionable guidance.
5. Add comprehensive Step 2 test coverage and verification matrix.
6. Update documentation to reflect final Step 2 policies and behavior.
### Out of scope
- Major architecture/topology changes (external queue, distributed worker)
- New end-user feature expansion outside reliability/error handling
- Full async ORM migration (unless required by bug fix)
---
## Target Decisions for Step 2
1. **Taxonomy stability is mandatory**
- `ErrorCategory` values remain stable contract identifiers.
2. **Classification occurs at source boundary**
- adapters/services normalize early; UI/API only present safely.
3. **User safety over internal detail leakage**
- expose safe message + suggestion + error_id; keep sensitive detail in logs.
4. **Retry is explicit and bounded**
- only retriable categories may retry; retries are capped; terminal failures persist reason.
5. **Boundary logs carry correlation fields**
- include `error_id`, `category`, `operation`, and domain identifiers where available.
---
## Detailed Work Breakdown
## Phase A — Error Contract Audit and Policy Lock
- [ ] **A1. Build error-path inventory**
- Enumerate all failure entry points across:
- `api/`
- `ui/`
- `services/`
- `worker.py`
- `providers/`
- [ ] **A2. Produce taxonomy mapping table**
- For each known exception path, map:
- source exception type
- target `ErrorCategory`
- retriable flag
- API status (if exposed)
- [ ] **A3. Reconcile with `docs/error_handling.md`**
- Resolve any mismatch in category semantics, status codes, or suggested actions.
### Deliverables
- `docs/ver1/ver1-step2-audit.md` (recommended)
- taxonomy mapping table
### Exit Criteria
- Every known failure path has explicit category + retriable policy.
---
## Phase B — API and Service Contract Hardening
- [ ] **B1. Enforce API envelope completeness**
- Ensure all API errors return:
- `error_id`, `category`, `message`, `suggestion`, `timestamp`
- [ ] **B2. Verify category-to-status mapping consistency**
- Confirm `api/errors.py` matches `docs/error_handling.md` mapping guidance.
- [ ] **B3. Normalize service exceptions at boundary**
- Services should raise `AppError` subclasses for known failures.
- Unknown exceptions must become `internal_unexpected_error` with traceable `error_id`.
- [ ] **B4. Ensure safe detail handling**
- API/UI messages remain safe.
- Diagnostic context remains in logs/persisted failure detail where appropriate.
### Exit Criteria
- No unstructured/unclassified exception escapes core boundaries.
- API responses are contract-stable for all tested failure modes.
---
## Phase C — Worker Retry and Terminal Failure Policy
- [ ] **C1. Define bounded retry policy**
- Add configurable retry settings (attempt limit/backoff policy).
- Limit retries to retriable categories.
- [ ] **C2. Implement terminal failure persistence**
- On retry exhaustion, persist clear terminal reason and `error_id`.
- Ensure job status transitions end deterministically at `failed`.
- [ ] **C3. Add duplicate-processing safety checks**
- Prevent duplicate terminal updates when job already resolved.
- [ ] **C4. Validate worker lifecycle under repeated transient failures**
- Ensure loop remains stable and responsive.
### Exit Criteria
- Retries are bounded and policy-driven.
- Exhausted retries produce deterministic failed state with evidence.
---
## Phase D — Logging and Observability Contract Enforcement
- [ ] **D1. Central logging conformance check**
- Confirm startup-only `dictConfig` use remains canonical.
- No module-level `basicConfig` use.
- [ ] **D2. Standardize error log fields**
- Require at minimum when available:
- `error_id`, `category`, `operation`, `exception_type`, `job_id`, `document_id`
- [ ] **D3. Boundary handoff logging**
- Add/normalize logs at transitions:
- UI action -> service
- service -> provider/db
- worker pickup -> terminal state
- [ ] **D4. Log noise control**
- Avoid duplicate stack-trace logging across layers for same exception.
### Exit Criteria
- Critical failure events are traceable end-to-end via logs and `error_id`.
---
## Phase E — UI Error UX Consistency and Interaction Hardening
- [ ] **E1. Standardize user error presentation**
- For upload/jobs interactions, ensure:
- clear title
- plain-language message
- suggested action
- visible error reference id
- [ ] **E2. Add in-flight interaction guards**
- Prevent duplicate submits/click storms during pending operations.
- [ ] **E3. Ensure deterministic UI state recovery**
- controls re-enable after failure
- status text remains actionable
- [ ] **E4. Keep UI boundary clean**
- no provider/protocol details leaked into page modules
### Exit Criteria
- All primary UI actions have consistent success/failure interaction behavior.
---
## Phase F — Test Expansion and Verification
Apply pytesting guidance: behavior-first assertions, deterministic fixtures, strict markers.
- [ ] **F1. API error contract tests**
- verify envelope fields and status mapping for each category class.
- [ ] **F2. Service classification tests**
- verify known failures map to expected `AppError` subclasses/categories.
- [ ] **F3. Worker retry policy tests**
- retriable failure retries and eventual success
- retriable failure exhaustion -> terminal failed
- non-retriable failure -> immediate failed
- [ ] **F4. UI error behavior tests**
- upload/jobs actions show actionable feedback on failures
- duplicate action guard behavior
- [ ] **F5. Regression guard tests**
- at least one test per previously observed production/real-world failure mode
### Validation Commands
- `uv run pytest --collect-only -q`
- `uv run pytest -m unit -q`
- `uv run pytest -m "not external" -q`
- `uv run pytest -q`
### Exit Criteria
- All Step 2 reliability/error contract tests pass.
- Existing suite remains green.
---
## Recommended Implementation Order
1. Phase A — audit and policy lock
2. Phase B — API/service contract hardening
3. Phase C — worker retry and terminal policy
4. Phase D — logging/traceability normalization
5. Phase E — UI consistency hardening
6. Phase F — test expansion and full verification
This order reduces risk by locking policy first, then applying behavior changes at core boundaries before UI polish.
---
## Risks and Mitigations
1. **Risk:** Overly broad retry policy causes hidden failure loops
**Mitigation:** strict category-based retry eligibility + hard cap + terminal persistence.
2. **Risk:** User-facing messages become too technical
**Mitigation:** enforce safe message + suggestion contract in tests.
3. **Risk:** Logging becomes noisy/redundant
**Mitigation:** boundary logging rules and single-trace ownership.
4. **Risk:** Reliability work introduces regressions in happy path
**Mitigation:** run full suite continuously; preserve integration pipeline tests.
---
## Step 2 Completion Checklist
- [ ] Error taxonomy mapping table completed and approved.
- [ ] API envelope and HTTP status behavior verified for all relevant failure categories.
- [ ] Service/provider exception normalization is consistent and tested.
- [ ] Worker retry behavior is bounded, explicit, and terminal-state safe.
- [ ] Structured error logging fields are present at boundary handoffs.
- [ ] UI failure flows provide clear, actionable, and traceable feedback.
- [ ] Full test suite passes with new Step 2 coverage included.
- [ ] `docs/ver1/ver1-step2-results.md` created with evidence and residual risks.
---
## Handoff to Step 3
After Step 2 completion, Step 3 (Functional Completion by Requirement Domain) proceeds on a hardened foundation:
- stable failure contracts,
- predictable retries and terminal behavior,
- actionable user/API error semantics,
- improved diagnostic traceability.
+160
View File
@@ -0,0 +1,160 @@
# Ver1 Step 3 Results: Functional Completion by Requirement Domain
## Summary
Step 3 implementation has been completed for the planned functional-completion scope in a practical personal-scale form.
Implemented in this step:
1. Revision history and acceptance workflows for transcripts.
2. Search over accepted transcript revisions.
3. Export of accepted transcript data.
4. API routes for jobs, revisions, search, and export.
5. UI pathways for revision management, search, and export.
6. Carry-forward integration updates for Step 1/2 follow-ups owned by Step 3.
---
## Implemented Changes
### 1) Data model expansion (functional domain)
Updated `src/transcription/models.py`:
- Added `JobStatus.COMPLETED`.
- Added `TranscriptRevision` table/model:
- `job_id`
- `revision_number`
- `text`
- `source`
- `accepted`
- `created_at`
- Added `Job.revisions` relationship.
This supports immutable revision history and accepted-transcript semantics for search/export.
### 2) Step 3 service layer
Created `src/transcription/services/library.py` with service-backed functional operations:
- `list_jobs(...)`
- `get_job_detail(...)`
- `add_revision(...)`
- `accept_revision(...)`
- `list_revisions(...)`
- `search_accepted_transcripts(...)`
- `export_transcripts(...)`
Key behavior:
- revisions are append-only and incrementing
- accepted revision is unique per job
- accepting a revision syncs canonical transcript and sets job to `completed`
- search scope is accepted revisions only
- export emits deterministic record payloads for archive workflows
### 3) Worker integration for revision provenance
Updated `src/transcription/worker.py`:
- Success path now calls `add_revision(..., source="worker", accepted=False)`.
- Worker still persists canonical transcript and `transcribed` job state.
- Initial machine transcription now appears in revision history.
### 4) API functional completion
Created `src/transcription/api/routes.py` and wired in `src/transcription/app.py`.
New endpoints:
- `GET /api/jobs`
- `GET /api/jobs/{job_id}`
- `GET /api/jobs/{job_id}/revisions`
- `POST /api/jobs/{job_id}/revisions`
- `POST /api/revisions/{revision_id}/accept`
- `GET /api/search?query=...`
- `GET /api/export?accepted_only=true|false`
### 5) UI functional completion
Updated `src/transcription/ui/jobs_page.py`:
- Job detail now includes revision history panel.
- Added user revision submission.
- Added revision accept action.
- Added `/search` page for accepted transcript search.
- Added `/export` page for accepted transcript export preview.
---
## Test Evidence
### Added/Updated Tests
1. `tests/services/test_library.py`
- revision append/accept behavior
- accepted-only search behavior
- export payload behavior
2. `tests/api/test_routes.py`
- jobs/revisions/search/export API serialization and contract behavior
3. `tests/test_models.py`
- `completed` status transition coverage
- `TranscriptRevision` persistence and relationship coverage
4. `tests/services/test_worker.py`
- success-path now verifies initial worker-generated revision persistence
### Full Validation Run
Executed and passing:
- `uv run pytest -q`
---
## Requirement Slice Coverage (Step 3)
| Slice | REQ Coverage | Status | Evidence |
| --- | --- | --- | --- |
| Core lifecycle completion and visibility | REQ-0, REQ-2, REQ-3, REQ-5, REQ-6 | met | worker integration + API/UI jobs routes + tests |
| Revision history and acceptance | REQ-3, REQ-4, REQ-5, REQ-11 | met | `TranscriptRevision`, `services/library.py`, UI revision panel, tests |
| Search over accepted transcripts | REQ-5, REQ-11 | met | `search_accepted_transcripts`, `/api/search`, `/ui/search`, tests |
| Export transcript data | REQ-4, REQ-5, REQ-11 | met | `export_transcripts`, `/api/export`, `/ui/export`, tests |
| Prompt and verbatim flow continuity | REQ-12 | met (continued) | worker transcription flow unchanged in prompt-loading contract |
---
## Carry-Forward Integration Updates
Updated:
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md`
Step 3 updates recorded for:
- CF-A1: in progress with Step 3 inspection evidence
- CF-A3: in progress with boundary-discipline evidence
- CF-C1: done (Step 3 traceability artifacts integrated)
- CF-C2: in progress (routing preserved for later steps)
---
## Residual Follow-ups
1. Step 4: migration rehearsal and rollback runbook execution for schema changes.
2. Step 6/7: broader error-path inventory closure and logging field normalization.
3. Step 9: release readiness reconfirmation for runtime ownership and migration behavior.
---
## Step 3 Exit Assessment
- Requirement-domain functional completion: **met**
- Data integrity and state consistency for new flows: **met**
- API/UI parity for new Step 3 features: **met**
- Test and regression safety: **met**
- Carry-forward integration obligations (Step 3-owned): **met/in progress as routed**
Step 3 is complete and ready to hand off to Step 4.
+433
View File
@@ -0,0 +1,433 @@
# Step 3 Implementation Plan: Functional Completion by Requirement Domain
## Purpose
Implement **Ver1 Step 3** from `docs/ver1/ver1.md` by completing all in-scope V1 functional requirements in a practical, user-first order while preserving:
- personal-scale operation
- single-operator workflow
- private-network deployment assumptions
- low operational overhead
- clean architecture boundaries
Primary governing docs:
- `docs/ver1/ver1.md` (Step 3 objective and sequencing)
- `docs/architecture.md` (module boundaries, workflow, simplicity guardrails)
- `docs/requirements.md` (REQ-0 through REQ-12 traceability)
- `docs/error_handling.md` (error contract across boundaries)
- `docs/intent.md` (verbatim transcription policy and prompt curation)
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md` (Step 1/2 carry-forward integration)
- `docs/ver1/ver1-step2-error-path-inventory.md` (failure-path coverage visibility)
---
## MCP Resources Reviewed and Applied
All resources on `john-stream-mcp` were reviewed. Step 3 applies the following guidance directly:
1. `resource://skills/nicegui/document`
- modular page registration
- one-way dependency flow (`ui/api -> services -> infra`)
- async-first UI responsiveness expectations
2. `resource://skills/nicegui-ui-customization/document`
- reusable UI component extraction for repeated patterns
- in-flight guards and explicit success/failure user feedback
- event-driven updates over ad-hoc polling
3. `resource://skills/fastapi-async-sqlalchemy-modernization/document`
- explicit transaction/session boundaries
- deterministic resource ownership and cleanup continuity from Step 1
- incremental migration strategy with rollback-aware checkpoints
4. `resource://skills/pydantic-settings/document`
- typed configuration as single source of runtime truth
- explicit source precedence and environment-safe defaults
5. `resource://skills/python-logging-dictconfig/document`
- centralized startup-only logging configuration
- named logger discipline and boundary-level structured fields
6. `resource://skills/pytesting/document`
- deterministic test structure and marker discipline
- behavior-first tests with clear fast-path and full-suite validation
7. `resource://skills/fastapi-uv-docker/document`
- health endpoint and runtime startup/shutdown hygiene
- compose/deployment readiness constraints relevant to functional completion
8. `resource://skills/python-typing/document`
- modern typing updates where touched by Step 3 work
9. `resource://skills/ruff-linting-formating/document`
- maintain lint/format consistency in all modified modules
10. `resource://prompts/greenfield-architecture/document`
- explicit staged delivery with tradeoff-aware sequencing and test strategy
11. `resource://prompts/pytest-scaffold/document`
12. `resource://prompts/pytest-fill-scaffold/document`
- structure-first test planning, then deterministic implementation fill-in
Resources reviewed but not directly in Step 3 execution scope (no changes required now):
- `copilot-customization`, `mcp-details`, `vscode-configuration`, `zensical-docs`
- prompts: `authoring`, `mcp-consumer-repo-shim`
---
## Step 3 Success Criteria
Step 3 is complete when:
1. All Step 3-targeted requirement slices are implemented and verified.
2. Functional behavior is available through UI/API where required.
3. Core data integrity and state transitions are deterministic.
4. Error behavior follows `docs/error_handling.md` contracts.
5. Carry-forward Step 1/2 items mapped to Step 3 are updated with evidence.
---
## Requirement-Slice Execution Model (Applied to Every Slice)
For each slice, execute this sequence:
1. Confirm contract/schema and boundary ownership.
2. Implement service/domain logic.
3. Implement persistence/state transitions.
4. Integrate API and/or UI behavior.
5. Add/update unit + integration + targeted end-to-end tests.
6. Update docs and traceability artifacts.
Definition of done per slice:
- behavior is functional
- tests pass in intended marker lanes
- error pathways are classified and surfaced correctly
- requirement traceability is updated with evidence
---
## Detailed Workstreams
## Workstream A — Functional Baseline Audit and Slice Backlog Lock
### Goals
- establish exact Step 3 functional delta from current implementation
- lock a practical slice backlog before coding
### Tasks
1. Build Step 3 requirement matrix (REQ -> current status -> gap -> target slice).
2. Map each gap to one of these domains:
- Upload and lifecycle integrity
- Review and revision history
- Search over accepted transcripts
- Export workflows
- Prompt asset management behavior
- API/UI parity and status visibility
3. Align each slice with architecture boundary ownership and persistence strategy.
4. Link open carry-forward items from checklist:
- CF-A1, CF-A3 (architecture continuity in Step 3)
- CF-C1, CF-C2 (traceability/execution continuity)
### Deliverables
- Step 3 requirement-slice matrix (appendix in this doc or separate artifact)
- prioritized slice backlog with owner and validation method
### Exit Criteria
- every Step 3 slice maps to REQ IDs and a validation method
- no ambiguous ownership remains for in-scope slices
---
## Workstream B — Core End-User Flows (Upload -> Transcribe -> Review)
### Related Requirements
- REQ-0, REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-6, REQ-12
### Goals
- guarantee end-to-end reliability and usability of the primary user flow
- ensure review experience supports transcript acceptance and correction
### Tasks
1. Validate and close any lifecycle-state gaps:
- enforce valid transitions (`queued -> processing -> transcribed/failed/completed`)
- ensure transition visibility in UI/API
2. Review experience completion:
- transcript detail display stability
- failure detail readability and actionability
- acceptance/edit path for human review
3. Ensure prompt-asset integration remains file-based and auditable:
- one prompt per Markdown file
- prompt selection/usage traceability in job outcomes (if available in model)
4. Confirm worker/UI interactions remain responsive under long-running jobs:
- in-flight guards
- clear status refresh behavior
### Deliverables
- complete end-user flow behavior with stable lifecycle visibility
- test coverage for happy path and failure path
### Exit Criteria
- user can run upload -> process -> review reliably
- failed and successful outcomes are both actionable and traceable
---
## Workstream C — Revision History and Provenance Completion
### Related Requirements
- REQ-3, REQ-4, REQ-5, REQ-11
### Goals
- finalize immutable transcript revision behavior and provenance consistency
### Tasks
1. Define/confirm revision invariants:
- append-only revision history
- clear current/accepted revision indicator
2. Persist revision events consistently through service layer boundaries.
3. Ensure UI/API expose revision timeline and selected revision details.
4. Align error handling for revision conflicts and missing resources.
### Deliverables
- revision-history feature completeness
- provenance and history read-path coverage
### Exit Criteria
- transcript edits produce deterministic revision records
- previous revisions remain inspectable
---
## Workstream D — Search Completion (Accepted Transcript Scope)
### Related Requirements
- REQ-0, REQ-5, REQ-11
### Goals
- provide practical search over accepted transcripts for personal corpus usage
### Tasks
1. Finalize searchable scope and indexing rules (accepted/current text only).
2. Implement service-backed search query behavior.
3. Expose search in UI/API with clear result metadata (document/job/revision context).
4. Add guardrails for empty/no-result/error scenarios with actionable messaging.
### Deliverables
- functional search pathway with deterministic results for accepted text
### Exit Criteria
- operator can find transcripts reliably by text queries
- no-result and error states are clear and non-silent
---
## Workstream E — Export Completion
### Related Requirements
- REQ-0, REQ-4, REQ-5, REQ-11
### Goals
- deliver practical export of transcript data for personal archive use
### Tasks
1. Finalize export contract (format, included fields, scope filters).
2. Implement export service with deterministic data mapping.
3. Add UI/API trigger path and user-visible completion/failure feedback.
4. Validate export integrity against persisted source-of-record entities.
### Deliverables
- end-to-end export capability with operator-visible outcomes
### Exit Criteria
- export output is complete, consistent, and usable for downstream personal archive workflows
---
## Workstream F — API/UI Parity and Interaction Hardening
### Related Requirements
- REQ-5 plus cross-cutting REQ-2/3/4
### Goals
- ensure UI and API expose coherent feature behavior and error contracts
### Tasks
1. Verify API/UI parity matrix for each Step 3 slice.
2. Standardize interaction behavior:
- loading and in-flight states
- success/failure notifications
- stable error_id visibility where user-facing
3. Ensure route/page modules remain composition-focused (business logic in services).
### Deliverables
- API/UI parity checklist with resolved gaps
### Exit Criteria
- no major flow exists in one interface with conflicting semantics in the other
---
## Workstream G — Carry-Forward Integration During Step 3
### Goals
- close Step 1/2 follow-ups that are Step 3-owned
### Tasks
1. Update checklist item CF-A1 as Step 3 slices touch runtime resources.
2. Update checklist item CF-A3 with lightweight boundary enforcement evidence.
3. Update CF-C1/CF-C2 traceability mapping with Step 3 outcomes.
### Deliverables
- updated `docs/ver1/ver1-step1-2-carry-forward-checklist.md` evidence entries
### Exit Criteria
- Step 3-owned carry-forward items are either completed or explicitly routed with evidence
---
## Test and Validation Plan
Apply `pytesting` guidance with deterministic, behavior-focused coverage.
### Validation Lanes
1. Structure/collection:
- `uv run pytest --collect-only -q`
2. Fast feedback lane:
- `uv run pytest -m unit -q`
3. Main verification lane:
- `uv run pytest -m "not external" -q`
4. Full suite:
- `uv run pytest -q`
### Required Coverage Areas
- lifecycle transition invariants
- revision history invariants
- search query behavior and result mapping
- export integrity and failure handling
- UI interaction guards and actionable failure feedback
- API envelope and status consistency for new/changed flows
### Test Design Rules
- one behavior target per test
- minimize heavy mocking; prefer real-path behavior checks where practical
- keep markers explicit and strict
---
## Logging, Error, and Config Guardrails for Step 3 Changes
1. Logging
- keep centralized startup logging config (`dictConfig`) as canonical
- include required error fields at boundary failures (`error_id`, `category`, `operation`, identifiers where available)
2. Error handling
- preserve taxonomy stability from `docs/error_handling.md`
- map any new failure pathways into existing categories
- surface actionable suggestions in UI/API
3. Configuration
- use typed settings and avoid ad-hoc env reads in business modules
- keep environment behavior explicit and documented
---
## Implementation Order (Detailed)
1. Workstream A: audit and backlog lock
2. Workstream B: core flow completion
3. Workstream C: revision/provenance completion
4. Workstream D: search completion
5. Workstream E: export completion
6. Workstream F: API/UI parity hardening
7. Workstream G: carry-forward integration updates
8. Full validation pass + docs/traceability updates
---
## Deliverables
1. Step 3 requirement-slice matrix with REQ mapping and evidence links
2. implemented Step 3 functional slices across service/persistence/API/UI
3. updated tests and passing validation lanes
4. updated carry-forward checklist entries (`CF-A1`, `CF-A3`, `CF-C1`, `CF-C2` as applicable)
5. Step 3 results document (`docs/ver1/ver1-step3-results.md`)
---
## Risks and Mitigations
1. **Risk:** Scope creep from optional enhancements during feature completion
- **Mitigation:** enforce REQ-mapped slice backlog and defer non-REQ enhancements
2. **Risk:** Functional parity drift between UI and API
- **Mitigation:** maintain parity matrix and verify both surfaces per slice
3. **Risk:** Data-model changes introduce migration surprises
- **Mitigation:** coordinate with Step 4 runbook expectations early and test on representative data
4. **Risk:** Reliability regressions while adding functionality
- **Mitigation:** run full error-path regression checks and keep Step 2 contracts intact
---
## Step 3 Completion Checklist
- [ ] Step 3 requirement-slice matrix completed and linked to REQ IDs.
- [ ] Core end-user flow is functionally complete and verified.
- [ ] Revision history/provenance behavior is complete and test-covered.
- [ ] Search over accepted transcripts is complete and test-covered.
- [ ] Export flow is complete and test-covered.
- [ ] API/UI parity checklist has no unresolved high-impact gaps.
- [ ] Step 3-owned carry-forward items are updated with evidence.
- [ ] Validation lanes pass (`collect-only`, unit, non-external, full).
- [ ] `docs/ver1/ver1-step3-results.md` is created with evidence and residual follow-ups.
---
## Handoff to Step 4
Step 3 completion enables Step 4 (Data Model and Migration Safety) with:
- finalized functional domain behavior
- stable persistence expectations
- traceable requirement evidence
- clarified migration-impact surface
+113
View File
@@ -0,0 +1,113 @@
# Ver1 Step 4 Migration and Rollback Runbook
## Purpose
Provide a concise, operator-safe procedure for schema migration execution,
compatibility validation, and rollback/mitigation for personal-scale deployments.
This runbook supports `docs/ver1/ver1-step4.md` and REQ-10 by keeping normal
production startup non-mutating unless explicitly configured otherwise.
---
## Preconditions
1. Application version to deploy is known and checked out.
2. `.env` values are configured for target environment.
3. Database backup path is prepared.
4. Application process is stopped before migration on production-like systems.
---
## Commands
Use explicit migration runner operations:
1. List pending migrations:
- `uv run python -m transcription.migration_runner --list`
2. Apply pending migrations:
- `uv run python -m transcription.migration_runner --apply`
3. Validate schema compatibility:
- `uv run python -m transcription.migration_runner --check`
Recommended execution order:
1. `--list`
2. backup database
3. `--apply`
4. `--check`
5. start application
---
## Backup Procedure (SQLite Baseline)
For SQLite deployments, copy the DB file before migration:
- Example DB path default: `./transcription.db`
- Keep timestamped backup copy in a safe location.
If the file is in active use, stop the app first.
---
## Verification Checklist
After migration apply:
1. `--check` exits successfully.
2. `schema_migration_history` includes applied revisions.
3. Application starts successfully.
4. Health endpoint responds: `/healthz`.
5. Critical flows smoke-check:
- upload
- job processing
- revision listing/acceptance
---
## Rollback and Mitigation Decision Tree
1. If migration fails before changes commit:
- fix issue
- re-run apply
2. If migration partially applied or compatibility check fails:
- stop app
- restore from backup
- investigate and produce forward-fix migration if needed
3. If app starts but functional invariants fail:
- stop app
- restore backup
- add corrective migration/backfill and rehearse before retry
For this Step 4 baseline, backup restore is the primary rollback mechanism.
---
## Failure Classification Guidance
Classify migration failures using `docs/error_handling.md` categories:
- transient connection issues -> `infrastructure_transient_error`
- permissions/misconfiguration -> `infrastructure_persistent_error`
- unexpected migration logic defects -> `internal_unexpected_error`
Record failure details with operation context and timestamp.
---
## Operational Notes
- `migration_auto_apply_on_startup` defaults to `False`.
- `validate_schema_on_startup` defaults to `True`.
- Startup schema validation fails fast on incompatibility.
This protects production from accidental schema drift.
---
## Post-Step-4 Follow-Up
If migration complexity grows beyond lightweight revision scripts,
introduce a dedicated migration framework in a future step while preserving
this runbook structure and operator-first workflow.
+153
View File
@@ -0,0 +1,153 @@
# Ver1 Step 4 Results: Data Model and Migration Safety
## Summary
Step 4 implementation status: **complete (baseline scope)**.
This document records completed migration-safety work, validation evidence, and remaining follow-ups for Ver1 Step 4.
Implemented in this step:
1. Added explicit migration framework module with revision history tracking.
2. Added schema compatibility validation and startup guardrails.
3. Added migration runner CLI for list/apply/check operations.
4. Added migration tests and Step 4 validation evidence.
5. Added Step 4 migration/rollback runbook.
---
## Implemented Changes
### 1) Schema audit and invariant lock
Implemented read-only compatibility checks in `src/transcription/db.py`:
- `validate_schema_compatibility(...)` verifies required V1 tables:
- `document`
- `job`
- `transcript`
- `transcriptrevision`
- verifies required `job.retry_count` column
- returns explicit issue identifiers (non-mutating check)
### 2) Migration policy/tooling lock
Added explicit migration revision model in `src/transcription/migrations.py`:
- `MigrationRevision` dataclass
- ordered `MIGRATIONS` registry
- migration history table: `schema_migration_history`
- explicit pending-list and apply operations
### 3) Forward migration implementation
Implemented two baseline forward migrations:
1. `0001_add_retry_count_to_job`
2. `0002_create_transcriptrevision_table`
Each migration is idempotent and recorded in migration history.
### 4) Rollback and mitigation runbook
Created `docs/ver1/ver1-step4-migration-runbook.md` with:
- preconditions
- list/apply/check command sequence
- backup-first procedure
- verification checklist
- rollback/mitigation decision tree
- error classification guidance aligned to `docs/error_handling.md`
### 5) Backfill implementation or explicit no-backfill decision
No backfill required for this baseline Step 4 scope.
Rationale:
- additive migration operations only
- default values and new-table creation do not require historical row rewrites for current V1 invariants
- residual advanced backfill scenarios deferred unless future schema evolution introduces incompatible transforms
---
## Test and Verification Evidence
### Added/Updated Tests
1. `tests/test_migrations.py`
- pending migration discovery
- migration apply + history recording
- idempotent re-apply behavior
2. `tests/test_db.py`
- compatibility-check behavior on fresh schema
- table expectation updates for `transcriptrevision`
3. `tests/test_config.py`
- migration safety setting defaults
4. `tests/test_app.py`
- lifespan test compatibility with migration/validation startup hooks
### Validation Runs
Run and record outcomes:
- `uv run pytest --collect-only -q` -> passed
- `uv run pytest -m unit -q` -> passed
- `uv run pytest -m "not external" -q` -> passed
- `uv run pytest -q` -> passed
### Migration Rehearsal Evidence
Migration rehearsal details (test-based):
- baseline data set used: in-memory SQLite legacy-shaped schema fixture (`job` table missing Step 4 additions)
- forward migration result: pending revisions applied successfully (`0001`, `0002`)
- post-migration verification result: schema checks pass and migration history recorded
- rollback/mitigation rehearsal result: runbook defined backup-restore primary rollback class for personal-scale SQLite deployment
---
## Requirement Traceability (Step 4)
| Step 4 Area | REQ Coverage | Status | Evidence |
| --- | --- | --- | --- |
| Schema lifecycle and state persistence safety | REQ-3, REQ-4, REQ-11 | met | `src/transcription/migrations.py`, `tests/test_migrations.py`, `tests/test_db.py` |
| Lifespan/runtime ownership continuity | REQ-7 | met | `src/transcription/app.py` startup checks + existing lifespan ownership model |
| Explicit non-mutating production startup policy | REQ-10 | met | `migration_auto_apply_on_startup=False` default + explicit runner workflow + startup validation gate |
| Prompt/data continuity constraints | REQ-12 | met (continued) | no prompt-contract mutation in Step 4 changes |
---
## Operational Artifacts Produced
- `docs/ver1/ver1-step4.md`
- `docs/ver1/ver1-step4-migration-runbook.md`
- `src/transcription/migrations.py`
- `src/transcription/migration_runner.py`
- README migration workflow updates
---
## Risks, Exceptions, and Follow-Ups
1. This lightweight migration system is appropriate for current personal-scale scope but may require a dedicated framework as schema complexity grows.
2. Rollback remains backup-restore primary; reversible down-migration coverage is intentionally limited in this baseline.
3. Startup compatibility checks currently fail fast with generic runtime error text and can be further normalized under API/operator error envelopes in later hardening.
Open follow-ups to carry forward:
- Evaluate migration framework escalation criteria in Step 9/10 readiness updates.
- Add optional richer structured migration logging fields if observability scope expands.
---
## Step 4 Exit Assessment
- Schema validation against finalized V1 domain: **met**
- Forward migration path safety and repeatability: **met (baseline scope)**
- Rollback/mitigation readiness: **met (backup-restore primary path)**
- Backfill risk closure: **met (no backfill required for current deltas)**
- Test and regression safety: **met**
Step 4 completion status: **complete (baseline scope)**
---
## Handoff to Step 5
Once Step 4 is marked complete, Step 5 can proceed with:
- verified migration safety baseline
- explicit rollback and recovery procedures
- reduced data-integrity risk entering private-network safety hardening
+378
View File
@@ -0,0 +1,378 @@
# Step 4 Implementation Plan: Data Model and Migration Safety
## Purpose
Implement **Ver1 Step 4** from `docs/ver1/ver1.md` by making data-model evolution safe, explicit, and repeatable for personal-scale deployment.
Step 4 ensures schema changes are handled through deterministic migration workflows rather than implicit startup mutation, while preserving:
- personal-scale operational simplicity
- single-operator deployment model
- lifecycle-owned runtime resource boundaries
- stable requirement traceability and low rollback risk
Primary governing docs:
- `docs/ver1/ver1.md` (Step 4 objective and sequencing)
- `docs/architecture.md` (runtime ownership, persistence boundaries, simplicity guardrails)
- `docs/requirements.md` (REQ-3, REQ-4, REQ-7, REQ-10, REQ-11, REQ-12 emphasis)
- `docs/error_handling.md` (failure classification and safe error surfacing)
- `docs/intent.md` (verbatim/transcription/revision domain behavior)
---
## MCP Resources Reviewed and Applied
All currently available resources on `john-stream-mcp` were reviewed. Step 4 applies the following guidance directly:
1. `resource://skills/fastapi-async-sqlalchemy-modernization/document`
- explicit engine/session lifecycle ownership
- transaction boundary clarity for schema transitions and backfills
- phased rollout with rollback-aware checkpoints
2. `resource://skills/pydantic-settings/document`
- typed migration/runtime safety settings
- explicit source-precedence behavior for operational toggles
- fail-fast config semantics for unsafe startup paths
3. `resource://skills/pytesting/document`
- deterministic migration verification lanes
- strict marker discipline
- behavior-first test coverage for migration outcomes
4. `resource://skills/python-logging-dictconfig/document`
- startup-centralized logging configuration
- structured migration and rollback event traceability
5. `resource://skills/fastapi-uv-docker/document`
- deployment and rehearsal discipline
- startup/health posture validation during migration windows
6. `resource://skills/python-typing/document`
- modern typing hygiene for touched migration/persistence modules
7. `resource://skills/ruff-linting-formating/document`
- lint/format consistency for migration scripts and database modules
Planning methodology inputs also applied:
8. `resource://prompts/greenfield-architecture/document`
- staged execution with explicit risk and extension handling
9. `resource://prompts/pytest-scaffold/document`
10. `resource://prompts/pytest-fill-scaffold/document`
- test-structure-first and deterministic fill-in sequencing
Reviewed but not directly Step 4 execution-critical:
- skills: `copilot-customization`, `mcp-details`, `nicegui`, `nicegui-ui-customization`, `vscode-configuration`, `zensical-docs`
- prompts: `authoring`, `mcp-consumer-repo-shim`
---
## Current-State Gap Summary (Step 4 Scope)
Based on Step 13 outcomes and current docs/tests:
1. **Bootstrap policy baseline is present**
- Environment-aware schema bootstrap policy exists and aligns with REQ-10 intent.
2. **Functional model expanded in Step 3**
- Revision/acceptance features introduce schema evolution requirements that need formal migration safety rehearsal.
3. **Runbook maturity required**
- Step 4 requires explicit migration + rollback procedures and evidence.
4. **Backfill risk must be evaluated**
- New/changed fields and semantics must be checked for historical data reconciliation needs.
5. **Release-path integration needed**
- Step 4 artifacts must feed Step 9 release readiness and Step 10 docs completion.
---
## Scope for Step 4
### In scope
1. Validate final V1 schema against implemented domain behavior (post-Step 3 reality).
2. Define and implement forward-safe migration path for expected upgrades.
3. Define and document rollback/mitigation strategy for migration failures.
4. Implement backfill scripts only if required, with idempotent behavior.
5. Rehearse migration + rollback locally using representative sample data.
6. Add Step 4-specific verification tests and operational checks.
7. Produce operator-facing migration/rollback runbook and Step 4 results evidence.
### Out of scope
- Distributed/externally orchestrated migration systems
- Major persistence-architecture rewrites beyond V1 scope
- Non-V1 enhancement migrations unrelated to implemented requirement slices
---
## Target Decisions for Step 4
1. **Production startup remains non-mutating by default**
- Preserve REQ-10 posture and avoid implicit schema mutation at normal startup.
2. **Schema changes are explicit operator workflows**
- Migrations run as deliberate operational actions, not hidden side effects.
3. **Migration safety beats migration speed**
- Additive and reversible-first patterns are preferred where possible.
4. **Rollback policy is explicit per change**
- Each migration must declare rollback class:
- direct rollback supported
- forward-fix required
- backup restore required
5. **Backfills are optional and minimal**
- Introduce only when required by correctness/invariants, never by convenience.
6. **Migration observability is mandatory**
- Structured logs include operation, migration identifier, status, and failure classification.
---
## Detailed Work Breakdown
## Phase A — Schema and Domain Invariant Audit
- [ ] **A1. Build canonical V1 schema inventory**
- Enumerate all persisted entities and key fields:
- document records
- jobs and statuses
- transcripts
- transcript revisions
- failure/provenance fields
- [ ] **A2. Validate invariants against implemented behavior**
- Cross-check Step 3 functionality and current domain expectations:
- append-only revision history
- accepted revision semantics
- canonical transcript synchronization behavior
- [ ] **A3. Classify required schema deltas**
- Categorize deltas:
- additive and safe
- compatibility-sensitive
- potentially destructive (must be staged or deferred)
### Deliverables
- `docs/ver1/ver1-step4-schema-audit.md` (recommended)
- schema-delta matrix with risk class and owning module
### Exit Criteria
- all required schema changes have explicit rationale and risk classification
- no ambiguous domain invariant remains
---
## Phase B — Migration Policy and Tooling Lock
- [ ] **B1. Lock migration workflow policy**
- Define canonical migration execution path and artifact conventions.
- [ ] **B2. Define migration authoring checklist**
- Include:
- preconditions
- forward steps
- rollback class
- post-verification checks
- [ ] **B3. Align policy with runtime startup safeguards**
- Ensure production startup remains explicit/non-mutating by default.
- [ ] **B4. Define operator invocation standard**
- One documented command path for local and production-like workflows.
### Deliverables
- migration policy section (this doc + runbook)
- migration authoring/review checklist
### Exit Criteria
- one unambiguous migration process exists and is documented
- startup policy and migration policy are consistent and non-conflicting
---
## Phase C — Forward Migration Implementation
- [ ] **C1. Implement required migration set**
- Build migration artifacts for all approved Step 4 deltas.
- [ ] **C2. Preserve compatibility where needed**
- Use staged expand/contract strategy when direct cutover is unsafe.
- [ ] **C3. Add migration logging checkpoints**
- Log start, phase boundaries, completion, and failure details.
- [ ] **C4. Verify post-migration schema state**
- Confirm expected tables/columns/constraints/indexes are present.
### Deliverables
- migration artifacts/scripts for V1 target schema
- schema verification checklist outputs
### Exit Criteria
- baseline-to-target forward migration executes successfully
- post-migration checks pass deterministically
---
## Phase D — Rollback and Mitigation Strategy
- [ ] **D1. Define rollback classes per migration**
- direct downgrade vs forward-fix vs backup-restore.
- [ ] **D2. Create rollback decision tree**
- trigger conditions, safe stop points, and recovery path.
- [ ] **D3. Align failure classification with `error_handling.md`**
- normalize migration failures into canonical categories:
- `infrastructure_transient_error`
- `infrastructure_persistent_error`
- `internal_unexpected_error` (as needed)
- [ ] **D4. Rehearse rollback flow**
- run at least one migration failure simulation and execute chosen recovery path.
### Deliverables
- rollback/mitigation decision tree
- rehearsal evidence notes
### Exit Criteria
- operator can execute rollback/mitigation without undocumented steps
- migration failure paths are diagnosable and classified
---
## Phase E — Backfill Decision and Execution (Conditional)
- [ ] **E1. Determine backfill necessity**
- inspect whether existing records violate new invariants.
- [ ] **E2. If required, implement idempotent backfill**
- resumable, batch-safe, and deterministic update semantics.
- [ ] **E3. Add post-backfill verification**
- validate:
- revision sequencing integrity
- accepted/current transcript consistency
- job lifecycle consistency
- [ ] **E4. If not required, record explicit “no backfill needed” evidence**
### Deliverables
- backfill script(s) and checklist (if applicable)
- no-backfill rationale artifact (if not applicable)
### Exit Criteria
- required backfills completed and verified OR formally ruled out with evidence
---
## Phase F — Verification and Test Expansion
Apply `pytesting` guidance (deterministic, behavior-first, strict markers).
- [ ] **F1. Migration application tests**
- verify forward migration from representative baseline.
- [ ] **F2. Post-migration schema contract tests**
- verify expected schema shape and key constraints.
- [ ] **F3. Rollback/mitigation tests**
- verify chosen rollback class behavior where practical.
- [ ] **F4. Startup policy regression tests**
- confirm production-mode startup does not mutate schema implicitly.
- [ ] **F5. Backfill behavior tests (if applicable)**
- idempotency and invariants after repeated execution.
### Validation Commands
- `uv run pytest --collect-only -q`
- `uv run pytest -m unit -q`
- `uv run pytest -m "not external" -q`
- `uv run pytest -q`
### Exit Criteria
- all Step 4 migration-safety checks pass
- no REQ-10 regression introduced
---
## Phase G — Runbook and Documentation Closure
- [ ] **G1. Create migration and rollback runbook**
- include:
- prerequisites
- backup step
- migration execution
- verification
- rollback/mitigation
- [ ] **G2. Update traceability artifacts**
- map Step 4 outcomes to REQ IDs and evidence.
- [ ] **G3. Prepare Step 4 handoff artifacts**
- ensure outputs feed Step 9 release readiness and Step 10 docs completion.
### Deliverables
- `docs/ver1/ver1-step4-migration-runbook.md` (recommended)
- `docs/ver1/ver1-step4-results.md`
- updated traceability references where needed
### Exit Criteria
- migration operations are executable using docs alone
- Step 4 evidence is complete and auditable
---
## Recommended Implementation Order
1. Phase A — schema/invariant audit
2. Phase B — migration policy and tooling lock
3. Phase C — forward migration implementation
4. Phase D — rollback/mitigation strategy + rehearsal
5. Phase E — backfill decision and execution (conditional)
6. Phase F — test and verification expansion
7. Phase G — runbook + traceability closure
This sequence minimizes risk by locking policy and scope before irreversible data changes.
---
## Risks and Mitigations
1. **Risk:** Data loss from unsafe schema transitions
- **Mitigation:** backup-first gate, staged migration strategies, post-check verification.
2. **Risk:** Startup policy drift reintroduces implicit schema mutation
- **Mitigation:** explicit regression tests for production startup behavior (REQ-10 guard).
3. **Risk:** Rollback path is incomplete or untested
- **Mitigation:** mandatory rollback class declaration + rehearsal evidence.
4. **Risk:** Backfill scripts cause partial/inconsistent state
- **Mitigation:** idempotent design, batching, and invariant-focused verification.
5. **Risk:** Migration failure diagnostics are unclear
- **Mitigation:** structured logging + error category mapping per `error_handling.md`.
---
## Step 4 Completion Checklist
- [ ] V1 schema audit completed and approved.
- [ ] Migration workflow policy is locked and documented.
- [ ] Required forward migrations are implemented and validated.
- [ ] Rollback/mitigation decision tree is documented and rehearsed.
- [ ] Backfill required/not-required decision is evidenced.
- [ ] Migration-safety test coverage is added and passing.
- [ ] Startup non-mutation policy remains verified in production mode.
- [ ] Step 4 runbook and results artifacts are completed.
---
## Handoff to Step 5
Step 4 completion enables Step 5 (Private-Network Safety Baseline) with:
- stable, explicit schema evolution mechanics
- reduced upgrade risk for single-operator deployments
- migration/rollback procedures suitable for personal-scale production
- traceable evidence for release-readiness gates
+178
View File
@@ -0,0 +1,178 @@
# Ver1 Step 5 Results: Private-Network Safety Baseline
## Summary
Step 5 implementation status: **complete**.
This document records completed private-network safety controls, validation evidence, and residual risks for Ver1 Step 5.
Implemented in this step:
1. Added private-network security assumptions and control matrix (`docs/ver1/ver1-step5-security-assumptions.md`).
2. Implemented optional single-operator access control for `/ui*` and `/api*` via HTTP Basic auth.
3. Added upload-size guardrails (`MAX_UPLOAD_BYTES`) and config fail-fast validation for operator credential requirements.
4. Hardened unexpected-error user-facing messaging to reduce sensitive detail leakage.
5. Added Step 5 tests for access control, security settings, and upload size boundaries.
6. Executed dependency/security scans (`pip-audit`, `bandit`) with no critical/high findings.
---
## Implemented Changes
### 1) Security assumptions and threat model
Completed.
- Added `docs/ver1/ver1-step5-security-assumptions.md` defining:
- trusted private-network deployment assumptions
- single-operator usage model
- explicit out-of-scope classes (enterprise IAM, internet-facing zero-trust, multi-tenant controls)
- Added Step 5 control/ownership matrix and residual-risk notes.
### 2) Single-operator access control baseline
Completed.
- New module: `src/transcription/security.py`
- `is_protected_path(...)` protects `/ui*` and `/api*`
- `enforce_request_access(...)` enforces optional operator auth
- robust Basic auth parsing and safe denial responses via `AccessDeniedError`
- App middleware added in `src/transcription/app.py`:
- enforces auth on protected paths
- returns consistent `401` envelope and `WWW-Authenticate: Basic` for denied requests
- Health endpoint `/healthz` remains intentionally unauthenticated.
### 3) Input validation and safe-output hardening
Completed baseline.
- `src/transcription/services/upload.py`
- added size-based validation guard (`max_upload_bytes`)
- emits `user_input_error` with actionable guidance on over-limit uploads
- `src/transcription/errors.py`
- `classify_unexpected_error(...)` now returns operation-only message without embedding raw exception text
- preserves traceability via existing `error_id` and taxonomy while reducing accidental sensitive leak risk
### 4) Secret handling and configuration safety
Completed baseline.
- `src/transcription/config.py` additions:
- `max_upload_bytes` (default `15 * 1024 * 1024`)
- `operator_access_enabled` (default `False`)
- `operator_username` (default `operator`)
- `operator_password` (optional, required when auth enabled)
- Added settings validator enforcing fail-fast config safety:
- raises validation error if `OPERATOR_ACCESS_ENABLED=true` and `OPERATOR_PASSWORD` unset
- `README.md` updated with Step 5 security env settings and explicit secret-handling guidance.
### 5) Dependency/security scanning baseline
Completed.
- Dependency vulnerability scan:
- `uvx pip-audit`
- Result: **No known vulnerabilities found**
- Static security scan:
- `uvx bandit -r src/transcription`
- Result: **No issues identified** (0 low/medium/high)
---
## Test and Verification Evidence
### Added/Updated Tests
1. `tests/api/test_access_control.py`
- unauthorized protected API denied (`401` + challenge)
- invalid credentials denied
- valid credentials accepted
- `/ui` protected when auth enabled
- `/healthz` remains unprotected
2. `tests/services/test_upload.py`
- added rejection test for payloads above `MAX_UPLOAD_BYTES`
3. `tests/test_config.py`
- added security defaults assertions
- added fail-fast assertion for missing `OPERATOR_PASSWORD` when auth enabled
4. `tests/test_errors.py`
- updated expectations for sanitized unexpected-error message behavior
5. Updated integration expectations where failure detail should no longer include raw exception text:
- `tests/services/test_worker.py`
- `tests/integration/test_pipeline_flow.py`
6. `tests/test_app.py` updated for new middleware wiring.
### Validation Runs
Run and record outcomes:
- `uv run pytest --collect-only -q` -> passed
- `uv run pytest -m unit -q` -> passed
- `uv run pytest -m "not external" -q` -> passed
- `uv run pytest -q` -> passed
### Security Scan Evidence
Record scan commands and outcomes:
- dependency scan command(s): `uvx pip-audit`
- static/security lint command(s): `uvx bandit -r src/transcription`
- critical/high findings: none
- remediation/defer decisions: no remediations required for Step 5 baseline
---
## Requirement Traceability (Step 5)
| Step 5 Area | REQ Coverage | Status | Evidence |
| --- | --- | --- | --- |
| Private-network and single-operator safety posture | REQ-9 | met | `docs/ver1/ver1-step5-security-assumptions.md`, README security section |
| Access control behavior at UI/API boundaries | REQ-5, REQ-7 | met | `src/transcription/security.py`, `src/transcription/app.py`, `tests/api/test_access_control.py` |
| Input validation and safe user-facing error behavior | REQ-1, REQ-2, REQ-5 | met | `src/transcription/services/upload.py`, `src/transcription/errors.py`, updated tests |
| Config and startup safety controls | REQ-8, REQ-10 | met | `src/transcription/config.py`, `tests/test_config.py`, `README.md` |
| Persistence and domain integrity continuity | REQ-11, REQ-12 | met (no regressions) | full test lane pass including integration and worker flows |
---
## Operational Artifacts Produced
- `docs/ver1/ver1-step5.md`
- `docs/ver1/ver1-step5-results.md`
- `docs/ver1/ver1-step5-security-assumptions.md`
- `src/transcription/security.py`
- `tests/api/test_access_control.py`
---
## Risks, Exceptions, and Follow-Ups
1. Basic auth is intentionally right-sized for trusted private-network use; if deployment posture changes, stronger identity controls are required.
2. Current model remains single shared operator credential (no per-user audit identity).
3. No built-in brute-force/rate-limit controls in Step 5 scope; evaluate in future hardening if threat model expands.
Open follow-ups to carry forward:
- Consider stronger auth/session model if system becomes multi-user or internet-accessible.
- Consider request throttling/rate limiting if threat model changes.
---
## Step 5 Exit Assessment
- Private-network assumptions and controls: **met**
- Access-control baseline effectiveness: **met**
- Validation and safe-output safety: **met (baseline)**
- Secret handling and config safety: **met**
- Dependency/security risk closure: **met (no critical/high findings)**
- Test and regression safety: **met**
Step 5 completion status: **complete**
---
## Handoff to Step 6
Once Step 5 is marked complete, Step 6 can proceed with:
- clearer operational security assumptions for logs/runbooks
- hardened boundary behavior for diagnosis and support
- reduced risk posture for personal-scale ongoing operations
@@ -0,0 +1,51 @@
# Ver1 Step 5 Security Assumptions (Private-Network Baseline)
## Operating Model
This system is operated as:
1. single operator
2. trusted private network
3. non-public deployment (no direct internet exposure for UI/API)
Out of scope for Step 5:
- enterprise IAM/SSO/RBAC
- internet-facing zero-trust edge controls
- multi-tenant user isolation
## Step 5 Controls and Ownership
| Control | Boundary Owner | Verification |
| --- | --- | --- |
| Optional operator authentication for `/ui*` and `/api*` routes | `src/transcription/security.py`, `src/transcription/app.py` | `tests/api/test_access_control.py` |
| Unauthorized contract (`401` + safe envelope + `WWW-Authenticate`) | `src/transcription/api/errors.py` | `tests/api/test_access_control.py` |
| Upload size guard (`MAX_UPLOAD_BYTES`) | `src/transcription/services/upload.py`, `src/transcription/config.py` | `tests/services/test_upload.py` |
| Fail-fast auth config when enabled | `src/transcription/config.py` | `tests/test_config.py` |
| Safe unexpected error messaging (reduced leak surface) | `src/transcription/errors.py` | `tests/test_errors.py`, worker/integration failure tests |
## Access-Control Policy (Step 5)
- Health endpoint (`/healthz`) remains unauthenticated for operability checks.
- When `OPERATOR_ACCESS_ENABLED=true`, protected paths require HTTP Basic auth:
- `/ui`
- `/ui/...`
- `/api/...`
- Credentials are runtime-configured:
- `OPERATOR_USERNAME` (default `operator`)
- `OPERATOR_PASSWORD` (required when access is enabled)
## Secrets Policy
- Secrets must be provided via runtime environment variables.
- Secrets must not be committed to source control.
- Secrets must not be logged.
- Example secret values in docs must always be placeholders.
## Residual Risks (Accepted for Step 5)
1. HTTP Basic credentials are suitable only for trusted private-network deployment.
2. No per-user identity model (single shared operator credential).
3. No advanced brute-force/rate-limit controls in Step 5 scope.
These are carried forward for future hardening only if deployment posture changes.
+459
View File
@@ -0,0 +1,459 @@
# Step 5 Implementation Plan: Private-Network Safety Baseline
## Purpose
Implement **Ver1 Step 5** from `docs/ver1/ver1.md` by applying right-sized security controls for a single-user system running on a trusted private network.
Step 5 focuses on practical risk reduction without introducing unnecessary complexity, while preserving:
- personal-scale operational simplicity
- single-operator workflow
- explicit boundary ownership from `docs/architecture.md`
- safety and diagnostics behavior defined in `docs/error_handling.md`
Primary governing docs:
- `docs/ver1/ver1.md` (Step 5 objective and sequencing)
- `docs/architecture.md` (deployment model and module boundaries)
- `docs/error_handling.md` (safe user output and diagnostic boundaries)
- `docs/requirements.md` (REQ-1, REQ-2, REQ-5, REQ-7, REQ-8, REQ-9, REQ-10, REQ-11, REQ-12)
- `docs/intent.md` (domain integrity priorities)
---
## MCP Resources Reviewed and Applied
All currently available resources on `john-stream-mcp` were reviewed. Step 5 applies the following guidance directly:
1. `resource://skills/pydantic-settings/document`
- typed security-related runtime settings
- explicit env/source precedence
- fail-fast handling for missing/invalid required values
2. `resource://skills/fastapi-uv-docker/document`
- environment and deployment safety defaults
- startup/health posture and container hygiene assumptions
- local secret handling expectations
3. `resource://skills/pytesting/document`
- deterministic security-behavior test lanes
- marker discipline and behavior-first assertions
4. `resource://skills/python-logging-dictconfig/document`
- centralized logging discipline
- avoid leaking sensitive values in logs
5. `resource://skills/nicegui-ui-customization/document`
- user-safe failure messaging in UI
- resilient interaction behavior and clear error feedback
6. `resource://skills/ruff-linting-formating/document`
- keep lint quality baseline stable during safety changes
Planning methodology input:
7. `resource://prompts/greenfield-architecture/document`
- explicit tradeoff-oriented staging
- scope discipline for minimally sufficient security controls
Reviewed but not directly Step 5 execution-critical:
- skills: `copilot-customization`, `fastapi-async-sqlalchemy-modernization`, `mcp-details`, `nicegui`, `python-typing`, `vscode-configuration`, `zensical-docs`
- prompts: `authoring`, `mcp-consumer-repo-shim`, `pytest-scaffold`, `pytest-fill-scaffold`
---
## Current-State Gap Summary (Step 5 Scope)
Based on current implementation and prior Step outputs:
1. **Private-network assumptions are implicit, not fully codified**
- Need explicit, documented security posture and operator constraints.
2. **Access control for UI/API is minimal or absent**
- Step 5 requires basic single-operator gating appropriate for private-network use.
3. **Input validation baseline exists but needs security-oriented audit closure**
- Upload and API validation should be verified for abuse-resistant boundaries.
4. **Safe error output baseline exists (Step 2), but needs security confirmation pass**
- Must ensure no sensitive internals leak through API/UI error payloads.
5. **Secret handling documentation needs formalization in Step 5 artifacts**
- Local workflow should clearly prohibit secrets in repo-tracked files and logs.
6. **Dependency/security scanning is not yet formalized as a recurring gate**
- Step 5 requires lightweight scanning and triage of high-risk findings.
---
## Scope for Step 5
### In scope
1. Codify private-network and single-operator security assumptions in docs and config.
2. Add basic access control for UI/API actions (right-sized for trusted network model).
3. Audit and harden input-validation boundaries (upload, API params/payloads, operational flags).
4. Verify safe error surface behavior (UI/API) and prevent sensitive leak paths.
5. Formalize local secret handling policy and usage examples.
6. Add lightweight dependency/security scan workflow and triage policy.
7. Add Step 5 verification tests and results artifact.
### Out of scope
- Internet-facing zero-trust security architecture
- Enterprise IAM/SSO/role systems
- Full cryptographic key-management infrastructure
- Major security product integrations beyond lightweight V1 needs
---
## Target Decisions for Step 5
1. **Threat model is explicitly private-network + single operator**
- Security controls are right-sized to this posture and documented as assumptions.
2. **Access control is required, even in private network mode**
- Basic gate (single shared operator credential/token) protects UI/API mutation paths.
3. **Validation and output safety are strict defaults**
- Reject invalid inputs early; never expose sensitive internals in user-facing outputs.
4. **Secrets are runtime-only**
- No secrets committed to source control; no plaintext secret logging.
5. **Security scanning is lightweight but mandatory**
- Add recurring dependency/security checks with high-risk triage and closure workflow.
6. **No security control may violate Step 14 operational simplicity guardrails**
- Preserve deployability and maintainability for personal-scale use.
---
## Detailed Work Breakdown
## Phase A — Security Posture Definition and Gap Lock
- [ ] **A1. Define Step 5 threat model**
- trusted private network
- single operator
- local deployment assumptions
- explicit out-of-scope threat classes
- [ ] **A2. Produce security baseline checklist**
- access control
- validation boundaries
- safe error behavior
- secret handling
- dependency risk checks
- [ ] **A3. Map controls to architecture boundaries**
- UI
- API
- service
- config/runtime
- operator runbooks
### Deliverables
- `docs/ver1/ver1-step5-security-assumptions.md` (recommended)
- Step 5 control matrix (control -> owner -> validation method)
### Exit Criteria
- private-network safety posture is explicit and approved
- each in-scope control has boundary ownership and verification path
---
## Phase B — Basic Single-Operator Access Control
- [ ] **B1. Select access mechanism**
- minimal approach suitable for private-network model
- explicitly document tradeoffs and operator ergonomics
- [ ] **B2. Protect mutating operations first**
- upload/create/accept/export-trigger endpoints
- UI actions that trigger persistence changes
- [ ] **B3. Protect read operations as policy requires**
- determine read-path gating expectations and apply consistently
- [ ] **B4. Add clear unauthorized behavior contract**
- stable API status and safe message
- UI feedback with actionable operator guidance
### Deliverables
- access-control policy and implementation notes
- unauthorized behavior matrix (UI/API)
### Exit Criteria
- unauthorized actions are blocked consistently
- authorized operator flows remain usable and deterministic
---
## Phase C — Input Validation and Safe Output Hardening
- [ ] **C1. Validation audit for all entry points**
- file uploads (type/size/content guards)
- route/query/body constraints
- service-layer invariants
- [ ] **C2. Normalize validation failures to canonical taxonomy**
- `validation_error` vs `user_input_error` consistency
- [ ] **C3. Confirm safe error output policy under security lens**
- no stack traces/secrets/internal paths in UI/API default outputs
- preserve error reference IDs for traceability
- [ ] **C4. Add abuse-resistant guardrails where practical**
- basic request-size and payload-shape constraints
- anti-duplication interaction safeguards (where missing)
### Deliverables
- validation-path inventory and hardening checklist
- safe-output verification notes
### Exit Criteria
- input boundaries are deterministic and tested
- user-facing error outputs remain safe and actionable
---
## Phase D — Secrets Handling and Configuration Safety
- [ ] **D1. Define secret handling policy**
- where secrets are allowed (runtime env only)
- where secrets are prohibited (source files, docs examples beyond placeholders)
- [ ] **D2. Enforce settings expectations**
- required secret fields fail fast
- avoid fallback defaults that silently weaken safety
- [ ] **D3. Add operator documentation for local secret workflow**
- how to set environment values safely
- how to rotate/update credentials locally
- [ ] **D4. Validate logging does not leak secret values**
- startup/config logs
- error logs for provider/config failures
### Deliverables
- secret-handling section in runbook/README/docs
- settings and logging safety verification notes
### Exit Criteria
- no secret leakage paths remain in normal operations
- operator can configure secrets safely using docs only
---
## Phase E — Dependency and Security Scanning Baseline
- [ ] **E1. Select lightweight scanning commands for V1**
- dependency vulnerability scan
- optional static security scan if practical
- [ ] **E2. Define triage policy for findings**
- severity classification
- required closure criteria for Step 5 completion
- [ ] **E3. Run scans and capture evidence**
- record command outputs/summaries
- remediate or formally defer with risk notes
- [ ] **E4. Add recurring execution guidance**
- local pre-release checklist integration
- future CI gate handoff for Step 7/9
### Deliverables
- Step 5 scan report artifact (recommended)
- triage log of resolved/deferred findings
### Exit Criteria
- no unresolved critical vulnerabilities in Step 5 scope
- high-risk findings are resolved or explicitly risk-accepted with rationale
---
## Phase F — Verification and Test Expansion
Apply `pytesting` guidance (deterministic, behavior-first, strict markers).
- [ ] **F1. Access-control tests**
- unauthorized requests are rejected as expected
- authorized operator requests succeed
- [ ] **F2. Validation and abuse-boundary tests**
- invalid payloads rejected with stable category/status
- file-type/size constraints enforced
- [ ] **F3. Safe-output tests**
- API/UI error responses avoid sensitive details
- error IDs and suggestions remain present
- [ ] **F4. Config/secret safety tests**
- required secrets fail fast when missing
- no unsafe fallback behavior introduced
### Validation Commands
- `uv run pytest --collect-only -q`
- `uv run pytest -m unit -q`
- `uv run pytest -m "not external" -q`
- `uv run pytest -q`
### Exit Criteria
- Step 5 safety behavior is test-covered and passing
- no regression in core upload/transcribe/review workflows
---
## Phase G — Documentation and Risk Closure
- [ ] **G1. Create Step 5 results artifact**
- `docs/ver1/ver1-step5-results.md`
- [ ] **G2. Update operator-facing docs**
- security assumptions and local deployment cautions
- credential handling and recovery basics
- [ ] **G3. Update traceability and carry-forward notes**
- map Step 5 controls to REQ and evidence
### Deliverables
- `docs/ver1/ver1-step5-results.md`
- updated security assumptions checklist and risk summary
### Exit Criteria
- Step 5 controls and residual risks are fully documented
- handoff is ready for Step 6 observability and Step 7 quality gates
---
## Recommended Implementation Order
1. Phase A — posture definition and gap lock
2. Phase B — access control baseline
3. Phase C — validation/output hardening
4. Phase D — secrets and config safety
5. Phase E — dependency/security scan baseline
6. Phase F — test expansion and verification
7. Phase G — docs and risk closure
This order reduces risk by locking assumptions first, then applying controls at highest-impact boundaries before final verification and documentation.
---
## Step 5 Execution Checklist (Phase-by-Phase)
Use this checklist to execute Step 5 in implementation order and record progress/evidence.
### Phase A — Security Posture Definition and Gap Lock
- [ ] Publish `docs/ver1/ver1-step5-security-assumptions.md`.
- [ ] Record explicit in-scope and out-of-scope threat classes.
- [ ] Produce Step 5 control matrix (control, owner, validation method).
- [ ] Confirm boundary ownership for each control (UI/API/service/config/docs).
### Phase B — Basic Single-Operator Access Control
- [ ] Choose and document access mechanism (with rationale and tradeoffs).
- [ ] Implement enforcement for mutating API operations.
- [ ] Implement corresponding UI-side access behavior for protected actions.
- [ ] Decide and enforce read-path protection policy.
- [ ] Add unauthorized API/UI contract tests.
### Phase C — Input Validation and Safe Output Hardening
- [ ] Complete input-validation inventory for upload/API/service boundaries.
- [ ] Tighten payload/file constraints where gaps are found.
- [ ] Ensure validation failure categories match `docs/error_handling.md`.
- [ ] Verify user-facing errors remain safe, actionable, and traceable.
- [ ] Add regression tests for invalid/boundary inputs.
### Phase D — Secrets Handling and Configuration Safety
- [ ] Document secrets policy (runtime-only, no repo storage).
- [ ] Verify required secret settings fail fast when missing.
- [ ] Audit logs for accidental secret leakage risk paths.
- [ ] Update operator docs for local secret setup/rotation workflow.
- [ ] Add tests for config safety expectations where practical.
### Phase E — Dependency and Security Scanning Baseline
- [ ] Select scanning commands and record tool versions.
- [ ] Run baseline scans and capture outputs.
- [ ] Triage findings by severity and exploitability in private-network context.
- [ ] Resolve/mitigate critical findings; document accepted residual risk.
- [ ] Add recurring scan guidance for release workflow handoff.
### Phase F — Verification and Test Expansion
- [ ] Run `uv run pytest --collect-only -q`.
- [ ] Run `uv run pytest -m unit -q`.
- [ ] Run `uv run pytest -m "not external" -q`.
- [ ] Run `uv run pytest -q`.
- [ ] Confirm no regressions in upload/transcribe/review core flows.
### Phase G — Documentation and Risk Closure
- [ ] Complete `docs/ver1/ver1-step5-results.md` with evidence.
- [ ] Update docs/README/runbooks with final Step 5 security posture.
- [ ] Record REQ traceability updates and residual risks.
- [ ] Confirm Step 5 completion checklist items are all closed.
---
## Risks and Mitigations
1. **Risk:** Over-engineering beyond private-network needs
- **Mitigation:** enforce Step 5 scope discipline and threat-model constraints.
2. **Risk:** Access controls disrupt operator usability
- **Mitigation:** keep mechanism minimal and test primary workflows thoroughly.
3. **Risk:** Sensitive details leak through errors/logging
- **Mitigation:** apply safe-output and log-sanitization checks with tests.
4. **Risk:** Unpatched dependency vulnerabilities remain invisible
- **Mitigation:** formalize scan + triage + evidence capture workflow.
5. **Risk:** Secret handling remains ad hoc
- **Mitigation:** fail-fast settings + explicit operator documentation + review checks.
---
## Step 5 Completion Checklist
- [ ] Private-network and single-operator security assumptions are documented.
- [ ] Basic single-operator access control is implemented and verified.
- [ ] Input-validation boundaries are audited, hardened, and test-covered.
- [ ] UI/API error output safety is confirmed under security tests.
- [ ] Secret handling policy and local workflow docs are complete.
- [ ] Dependency/security scans are run; critical findings are resolved.
- [ ] Step 5 tests pass across all validation lanes.
- [ ] `docs/ver1/ver1-step5-results.md` is completed with evidence and residual risks.
---
## Handoff to Step 6
Step 5 completion enables Step 6 (Minimal Observability & Operability) with:
- explicit security assumptions for operator context
- access and validation controls suitable for private-network operation
- safer runtime/configuration handling for ongoing operations
- dependency-risk visibility feeding release-readiness gates
+206
View File
@@ -0,0 +1,206 @@
## Step 6 Goal (from `docs/ver1/ver1.md`)
Implement **minimal observability & operability** so a single operator can quickly diagnose and recover from common failures.
---
## 1) Current-State Assessment (what already exists)
### Already in place
- Central startup logging initialization via `setup_logging()` and `dictConfig` (`src/transcription/config.py`, `src/transcription/app.py`).
- Error taxonomy and `error_id` envelope contract (`src/transcription/errors.py`) aligned with `docs/error_handling.md`.
- Error handling for API and worker includes category + error IDs in some paths (`src/transcription/api/errors.py`, `src/transcription/worker.py`).
- Basic health endpoint `/healthz` (`src/transcription/api/health.py`).
- UI error display already shows actionable message + error reference (`src/transcription/ui/error_presenter.py`).
### Gaps to close for Step 6
1. **Structured logging is inconsistent** (many logs are free-form text with embedded key/value; no enforced schema).
2. **Boundary coverage is incomplete** (UI/service/API/worker dont all emit consistent operation logs).
3. `/healthz` is very basic; no lightweight readiness/startup diagnostics endpoint/reporting.
4. No concise **operator runbook** yet (start/stop, log interpretation, recovery playbooks).
5. Minimal counters/timings are not yet standardized.
---
## 2) MCP Guidance Incorporated (relevant items)
From `john-stream-mcp`, these are directly applied:
- **`python-logging-dictconfig`**: keep one centralized `dictConfig`, configure once at startup, named loggers in modules.
- **`fastapi-async-sqlalchemy-modernization`**: include observability + health/readiness checks; explicit lifecycle and deterministic startup/shutdown checks.
- **`fastapi-uv-docker`**: keep `/healthz`; add practical readiness/ops checks for deployment clarity.
- **`pytesting`**: deterministic tests, concise structure, validation lanes (`collect-only`, `unit`, `not external`, full).
- **`pydantic-settings`**: keep typed settings as single source for logging/health behavior flags.
- **`nicegui` + `nicegui-ui-customization`**: preserve clear, actionable user-facing error feedback and non-blocking UI flows.
- **`zensical-docs`**: produce focused, navigable operator docs.
(Other MCP resources were reviewed but are not core to Step 6 implementation scope.)
---
## 3) Detailed Implementation Plan for Step 6
## Workstream A — Structured Logging Contract
### A1. Define a canonical log event schema
Create a project log schema (doc + code-level constants) with required keys:
- `timestamp` (UTC)
- `level`
- `logger`
- `operation`
- `event`
- `error_id` (when error)
- `category` (when error)
- `exception_type` (when error)
- `job_id`, `document_id` (when relevant)
- optional: `duration_ms`, `retry_count`, `status`
### A2. Standardize log emission helpers
Add small logging helpers (or adapter utilities) to reduce drift:
- `log_operation_start(...)`
- `log_operation_success(...)`
- `log_operation_error(...)`
Keep this minimal and avoid heavy observability frameworks.
### A3. Update formatter to structured output
Use `dictConfig` to emit either:
- JSON lines (preferred for structure), or
- strict key-value line format with fixed fields.
**Recommendation:** JSON lines to satisfy “structured logging” unambiguously while still simple.
---
## Workstream B — Boundary-by-Boundary Instrumentation
### B1. API boundary (`src/transcription/api/*`)
- Add request-level operation logs for key routes (`upload.submit`, `jobs.list`, `jobs.get`, etc.).
- Ensure API exception handler logs always include `error_id`, `category`, `operation`, `exception_type`.
### B2. Service boundary (`src/transcription/services/*`)
- Add operation logs around:
- upload validation/persist,
- transcription orchestration,
- revision add/accept,
- search/export.
- Add timing (`duration_ms`) for high-value operations only.
### B3. Worker boundary (`src/transcription/worker.py`)
- Standardize all worker log events to schema.
- Ensure retry logs include: `retriable`, `retry_count`, `max_retries`, `backoff_seconds`.
- Ensure terminal failure logs include error contract fields.
### B4. UI boundary (`src/transcription/ui/*`)
- Keep user-safe UI messages as-is.
- Add backend/UI logger events for user-triggered failures (operation + error_id + category) so UI-visible errors correlate to server logs.
---
## Workstream C — Health, Readiness, Startup Operability
### C1. Keep `/healthz` lightweight
- Return “process is running” status quickly.
### C2. Add lightweight `/readyz`
Include small checks:
- DB connectivity ping.
- Worker thread alive check.
- Optional prompt directory existence check.
Return structured status payload with per-check pass/fail.
### C3. Startup self-check summary log
At startup, emit one concise ops summary event:
- environment
- schema validation result
- worker started
- directories checked
- bootstrap/migration mode flags
---
## Workstream D — Minimal Counters & Timings
Add only high-value diagnostics:
1. `worker_jobs_processed_total`
2. `worker_jobs_failed_total`
3. `worker_retries_total`
4. `transcription_duration_ms` (per job)
5. `upload_persist_duration_ms` (per upload path)
Implementation can be log-derived counters (no external metrics backend required).
---
## Workstream E — Operator Runbook
Create concise runbook doc (recommended: `docs/ver1/ver1-step6-operator-runbook.md`) with:
1. **Start/Stop**
- local `uv` run mode
- docker compose mode (if applicable)
2. **Where logs are**
- stdout, docker logs commands, filtering by `error_id` / `operation`.
3. **Common failure patterns → recovery**
- provider timeout
- auth denied
- missing prompt dir
- DB unavailable
- job stuck/failed with retry exhausted
4. **Recovery procedures**
- restart sequence
- verify health/readiness
- when to requeue/re-upload
5. **Escalation artifacts**
- capture timestamp + error_id + operation + job_id/document_id
Also update `README.md` with short links to the runbook.
---
## Workstream F — Verification & Quality Gates
### Tests to add/update
- `tests/api/test_health.py`
- `/healthz` baseline
- `/readyz` pass/fail behavior
- `tests/api/test_error_responses.py` / `tests/api/test_routes.py`
- logs include `error_id/category/operation` on failures
- `tests/services/test_worker.py`
- retry/failure log fields + timing presence
- `tests/ui/*`
- ensure UI error correlation path includes operation/ref id behavior
### Validation commands (per MCP pytest guidance)
- `uv run pytest --collect-only -q`
- `uv run pytest -m unit -q`
- `uv run pytest -m "not external" -q`
- `uv run pytest -q`
---
## 4) Traceability to Governing Docs
- **`docs/ver1/ver1.md` Step 6:** all 5 implementation bullets covered.
- **`docs/error_handling.md`:** logging contract fields and error taxonomy continuity enforced.
- **`docs/architecture.md`:** respects modular boundaries, in-process worker model, low-complexity ops.
- **`docs/requirements.md`:**
- REQ-8 (startup logging/config centralization) strengthened,
- REQ-5 (status visibility) improved operationally,
- REQ-7 lifecycle ownership observability improved.
- **`docs/intent.md`:** keeps operation simple for personal-scale archival workflow.
---
## 5) Suggested Execution Order (low risk)
1. Logging schema + formatter + helpers
2. Worker/API instrumentation (highest value)
3. Service/UI instrumentation
4. `/readyz` + startup summary check
5. Runbook + README links
6. Tests + Step 6 results artifact (`docs/ver1/ver1-step6-results.md`)
+133 -131
View File
@@ -1,40 +1,41 @@
# Version 1 Implementation Plan # Version 1 Implementation Plan
This plan defines the path from MVP to **Version 1 complete**. 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. The objective is to deliver the full scoped product with readiness for reliable personal-scale operation, while explicitly separating refinements/enhancements into a future document.
--- ---
## 0) Plan Governance & Scope Control (Foundation) ## 0) Plan Governance & Scope Control (Foundation)
**Goal:** Keep execution focused on V1 completion, not optimization/perfection. **Goal:** Keep execution focused on V1 completion and avoid unnecessary process overhead.
### Implementation Steps ### Implementation Steps
1. Create and maintain a **V1 Traceability Matrix**: 1. Create and maintain a **V1 Traceability Matrix**:
- Requirement ID - Requirement ID
- Current status (`done`, `partial`, `not started`) - Current status (`done`, `partial`, `not started`)
- Owner
- Validation method - Validation method
2. Define V1 completion gates: 2. Define V1 completion gates:
- Functional complete - Functional complete
- Operationally complete - Operationally complete
- Production-ready complete - Personal-deployment ready
3. Snapshot the MVP baseline (tag/changelog reference). 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. 4. Keep a standing rule: non-V1 ideas go to a separate enhancements backlog, and enter V1 only by explicit approval.
### Deliverables ### Deliverables
- `docs/ver1/ver1.md` (this plan) - `docs/ver1/ver1.md` (this plan)
- V1 traceability artifact (linked from here when created) - V1 traceability artifact:
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md`
- `docs/ver1/ver1-step2-error-path-inventory.md` (supporting artifact)
### Exit Criteria ### Exit Criteria
- Every in-scope requirement has explicit ownership and status. - Every in-scope requirement has explicit status and validation evidence.
- Scope-change process is agreed and followed. - Scope-change discipline is followed consistently.
--- ---
## 1) Architecture Consolidation ## 1) Architecture Consolidation
**Goal:** Align implementation with the intended architecture and reduce MVP shortcuts. **Goal:** Align implementation with intended architecture while preserving simplicity.
### Implementation Steps ### Implementation Steps
1. Compare implemented modules/components with architecture documentation. 1. Compare implemented modules/components with architecture documentation.
@@ -42,201 +43,202 @@ The objective is to deliver the full scoped product with production readiness, w
- Temporary coupling - Temporary coupling
- Missing interfaces - Missing interfaces
- Placeholder services/components - Placeholder services/components
3. Resolve high-risk architectural gaps first. 3. Resolve architecture gaps that threaten reliability, maintainability, or clear boundaries.
4. Record key decisions and tradeoffs in ADRs. 4. Record material decisions and tradeoffs in ADRs.
### Deliverables ### Deliverables
- Updated architecture diagrams and boundaries - Updated architecture diagrams and boundaries
- ADR entries for major decisions - ADR entries for material decisions
### Exit Criteria ### Exit Criteria
- Architecture documentation reflects system reality. - Architecture documentation reflects system reality.
- Critical architecture risks are addressed or scheduled with owners/dates. - High-impact architecture risks are addressed or explicitly scheduled.
--- ---
## 2) Error Handling & Reliability Hardening ## 2) Error Handling & Reliability Hardening
**Goal:** Ensure predictable, safe behavior under failure conditions. **Goal:** Ensure predictable, diagnosable behavior under expected failure conditions.
### Implementation Steps ### Implementation Steps
1. Standardize error taxonomy and envelope format across all layers. 1. Apply the canonical taxonomy and response model from `docs/error_handling.md` across UI/API/service/worker boundaries.
2. Ensure clear distinction between: 2. Ensure clear distinction between:
- User-facing errors - User-facing safe messages
- Internal/system errors - Internal diagnostic detail
- Retryable vs non-retryable failures - Retryable vs non-retryable failures
3. Add resilience controls where needed: 3. Implement practical resilience controls where needed:
- Timeouts - Timeouts
- Retries with backoff - Bounded retries with backoff
- Circuit breaking / fallback logic - Explicit terminal failure states
4. Add failure-path tests for critical workflows. 4. Add failure-path tests for critical workflows.
### Deliverables ### Deliverables
- Error code catalog/reference - Error handling reference aligned with `docs/error_handling.md`
- Failure mode test coverage for critical paths - Failure-mode test coverage for critical paths
### Exit Criteria ### Exit Criteria
- Error behavior is consistent across major flows. - Error behavior is consistent across major flows.
- Known failure scenarios are tested and pass. - Known failure scenarios are tested and pass.
- Failed jobs include actionable, traceable failure detail.
--- ---
## 3) Functional Completion by Requirement Domain ## 3) Functional Completion by Requirement Domain
**Goal:** Complete all V1 functional requirements in a risk-aware order. **Goal:** Complete all V1 requirements in a practical, user-first order.
### Recommended Order ### Recommended Order
1. Business-critical end-user flows 1. End-user core flows (upload → transcribe → review)
2. Data integrity and consistency capabilities 2. Data integrity and persistence behavior
3. Admin/operational controls 3. Minimal operator controls needed for personal use
4. Lower-priority UX and quality-of-life items that are in V1 scope 4. In-scope UX quality improvements
### Implementation Steps ### Implementation Steps
For each requirement slice: For each requirement slice:
1. Finalize contract/schema 1. Confirm contract/schema
2. Implement domain logic 2. Implement service/domain logic
3. Implement persistence/state changes 3. Implement persistence/state transitions
4. Integrate API/UI 4. Integrate API/UI behavior
5. Add automated tests 5. Add or update automated tests
6. Update docs 6. Update relevant docs
### Deliverables ### Deliverables
- Requirement completion report with validation evidence - Requirement completion report with validation evidence linked to REQ IDs
### Exit Criteria ### Exit Criteria
- All V1 must-have requirements are complete and validated. - All V1 must-have requirements are complete and verified.
--- ---
## 4) Data Model, Migration, and Backfill Safety ## 4) Data Model and Migration Safety
**Goal:** Ensure data model and migrations are production-safe. **Goal:** Keep schema evolution safe and simple for personal-scale deployment.
### Implementation Steps ### Implementation Steps
1. Validate schema against final V1 domain needs. 1. Validate schema against finalized V1 domain needs.
2. Implement forward-safe migrations. 2. Implement forward-safe migrations for expected upgrades.
3. Define rollback/mitigation plans for migration failures. 3. Define a simple rollback/mitigation path for migration failures.
4. Build and verify backfill scripts (if needed). 4. Add backfill scripts only where truly required.
5. Add migration rehearsal in staging with representative data. 5. Rehearse migration + rollback locally using representative sample data.
### Deliverables ### Deliverables
- Migration runbook - Migration and rollback runbook
- Backfill verification checklist - Backfill checklist (if applicable)
### Exit Criteria ### Exit Criteria
- Migration plan validated in staging. - Migration path is tested and documented.
- No unresolved data-loss risk for V1 rollout. - No unresolved data-loss risk for V1 upgrade.
--- ---
## 5) Security, Access Control, and Compliance Baseline ## 5) Private-Network Safety Baseline
**Goal:** Close MVP security gaps and establish V1 baseline controls. **Goal:** Apply right-sized security controls for a single-user system on a trusted private network.
### Implementation Steps ### Implementation Steps
1. Complete authn/authz coverage for all routes/actions. 1. Enforce private-network deployment assumptions in docs and configuration.
2. Enforce input validation and output sanitization. 2. Ensure basic single-operator access control for UI/API actions.
3. Verify secret management and credential rotation process. 3. Enforce input validation and safe error output behavior.
4. Add audit logging for sensitive operations. 4. Keep secrets out of source control; document local secret handling.
5. Run dependency/security scanning in CI and remediate findings. 5. Run lightweight dependency/security scanning and resolve high-risk findings.
### Deliverables ### Deliverables
- Security checklist with status - Security assumptions checklist (private network, single operator)
- Threat/risk update for V1 scope - Basic risk update for V1 scope
### Exit Criteria ### Exit Criteria
- No unresolved critical/high vulnerabilities for V1 launch. - No unresolved critical vulnerabilities.
- Access control behavior verified by tests. - Access behavior and validation rules are verified for intended operating model.
--- ---
## 6) Observability & Operability ## 6) Minimal Observability & Operability
**Goal:** Make system behavior observable and supportable in production. **Goal:** Keep operation and troubleshooting simple, clear, and reliable.
### Implementation Steps ### Implementation Steps
1. Standardize structured logging and correlation IDs. 1. Standardize structured logging across UI/API/service/worker boundaries.
2. Add core metrics: 2. Ensure logged errors include category and error reference IDs per `error_handling.md`.
- Latency 3. Add lightweight health/startup checks.
- Throughput 4. Document a concise operator runbook:
- Error rates - start/stop
- Resource saturation - log locations
3. Add tracing for critical request/workflow paths. - common failure patterns and recovery steps
4. Define SLOs/SLIs and alert thresholds. 5. Add minimal counters/timings only where they clearly improve diagnosis.
5. Prepare incident response and rollback runbooks.
### Deliverables ### Deliverables
- Dashboards and alerts - Logging and error-traceability baseline
- Operations runbooks - Operator runbook
### Exit Criteria ### Exit Criteria
- Team can detect, triage, and remediate incidents quickly. - Operator can diagnose common failures using logs + runbook.
- Core production signals are available and reliable. - System recovery procedures are documented and repeatable.
--- ---
## 7) Test Strategy Expansion & Quality Gates ## 7) Test Coverage and Practical Quality Gates
**Goal:** Raise confidence for repeatable, low-risk releases. **Goal:** Prevent regressions in critical flows without overbuilding test infrastructure.
### Implementation Steps ### Implementation Steps
1. Expand unit and integration tests across V1 features. 1. Expand unit and integration tests for all V1 requirement slices.
2. Add contract tests between key components/services. 2. Add end-to-end tests for critical journeys:
3. Add end-to-end tests for critical user journeys. - upload
4. Add non-functional tests where relevant: - process/transcribe
- Performance/load - view result
- Soak - failure visibility
- Failure-injection scenarios 3. Add targeted contract tests where adapter boundaries are error-prone.
5. Enforce CI quality gates (tests, lint, type checks, security scans). 4. Keep CI gates focused on high-value checks (tests, lint, type checks, dependency scan).
### Deliverables ### Deliverables
- Test matrix with ownership - V1 test matrix mapped to requirements and critical flows
- CI gate definition and thresholds - CI quality-gate checklist
### Exit Criteria ### Exit Criteria
- Critical-path regressions are blocked automatically. - Critical-path regressions are automatically detected.
- Test coverage and reliability thresholds meet V1 targets. - Test suite gives consistent release confidence for personal-scale operation.
--- ---
## 8) Performance & Scalability Validation ## 8) Performance Validation for Personal Scale
**Goal:** Meet expected V1 performance at projected load. **Goal:** Confirm acceptable responsiveness for expected personal-use workload.
### Implementation Steps ### Implementation Steps
1. Define performance budgets per key flow. 1. Define practical performance expectations for key flows.
2. Benchmark current behavior in staging. 2. Run representative tests using real document samples.
3. Optimize bottlenecks (queries, caching, concurrency, etc.). 3. Address obvious bottlenecks in queries, file handling, or worker concurrency.
4. Re-test after each optimization and compare against budget. 4. Document known limits and expected operating bounds.
5. Document known limits and safe operating bounds.
### Deliverables ### Deliverables
- Performance benchmark report - Short performance validation note
- Optimization log - Known-limits summary
### Exit Criteria ### Exit Criteria
- V1 performance targets met for expected usage profile. - Core flows remain responsive for expected corpus size and usage patterns.
--- ---
## 9) Release Engineering & Environment Readiness ## 9) Release Readiness and Environment Simplicity
**Goal:** Make deployment repeatable, controlled, and reversible. **Goal:** Make deployment and rollback repeatable for a single-operator Docker Compose setup.
### Implementation Steps ### Implementation Steps
1. Harden CI/CD pipeline with clear promotion gates. 1. Define a simple release checklist:
2. Ensure config parity and consistency across environments. - run tests
3. Define rollout strategy (phased/canary/limited release as applicable). - run one end-to-end transcription check
4. Validate rollback procedures in staging. - verify migration compatibility
5. Produce release checklist and ownership model. 2. Document environment configuration requirements clearly.
3. Validate deployment and rollback steps in a local rehearsal.
4. Add backup/restore verification for core persisted data.
### Deliverables ### Deliverables
- Release playbook - Release checklist
- Environment readiness checklist - Environment and rollback guide
### Exit Criteria ### Exit Criteria
- Deployment and rollback are rehearsed and reliable. - Deployment/rollback is rehearsed and documented.
- Release process is executable without tribal knowledge. - Operator can release safely without hidden steps.
--- ---
@@ -252,7 +254,7 @@ For each requirement slice:
- Index/navigation - Index/navigation
- Intent alignment summary - Intent alignment summary
2. Add operator troubleshooting guides. 2. Add operator troubleshooting guides.
3. Add integration/API examples for consumers. 3. Add integration/API examples for the operator and future maintainers.
4. Publish changelog/version notes for V1. 4. Publish changelog/version notes for V1.
### Deliverables ### Deliverables
@@ -260,55 +262,55 @@ For each requirement slice:
- V1 release notes - V1 release notes
### Exit Criteria ### Exit Criteria
- A new team member can run/support the system using docs alone. - A future maintainer can run and support the system using docs alone.
--- ---
## 11) Final Validation, UAT, and Launch ## 11) Final Validation and Launch
**Goal:** Confirm readiness and launch V1 safely. **Goal:** Confirm V1 readiness and launch with low operational risk.
### Implementation Steps ### Implementation Steps
1. Run full-system acceptance validation against the V1 traceability matrix. 1. Run end-to-end acceptance validation against the V1 traceability matrix.
2. Conduct stakeholder UAT and capture sign-off. 2. Complete operator acceptance checks on representative real documents.
3. Execute production readiness review. 3. Execute launch checklist (including backup, migration, and rollback readiness).
4. Launch in controlled phases and monitor key signals. 4. Launch and monitor logs/status closely during initial use.
### Deliverables ### Deliverables
- UAT/PRR sign-off records - Acceptance validation record
- Launch checklist and monitoring plan - Launch checklist completion record
### Exit Criteria ### Exit Criteria
- Stakeholder approval achieved. - V1 requirements are validated.
- Launch metrics are stable within defined thresholds. - Initial launch behavior is stable and recoverable.
--- ---
## 12) Post-Launch Stabilization (3060 Days) ## 12) Post-Launch Stabilization
**Goal:** Consolidate V1 in production before major expansion. **Goal:** Address early issues quickly and lock in a reliable V1 baseline.
### Implementation Steps ### Implementation Steps
1. Track incidents, defects, and user feedback. 1. Track defects and operational pain points observed after launch.
2. Prioritize stabilization fixes with short cycle times. 2. Prioritize short-cycle stabilization fixes.
3. Remove temporary flags/mitigations introduced during launch. 3. Remove temporary launch-only workarounds when safe.
4. Produce post-launch retrospective and handoff to standard roadmap cadence. 4. Capture a brief retrospective and update the next-phase backlog.
### Deliverables ### Deliverables
- Stabilization report - Stabilization summary
- Prioritized backlog update - Updated backlog for post-V1 enhancements
### Exit Criteria ### Exit Criteria
- Incident/error rates converge to steady-state targets. - Major launch issues are resolved.
- V1 transitions from launch mode to normal operations. - System transitions to steady personal-use operation.
--- ---
## Recommended Execution Rhythm ## Recommended Execution Rhythm
- **Weekly:** Requirement closure + risk review - **Weekly:** Requirement closure + risk review
- **Biweekly:** Release train with quality gates - **As needed (small batch releases):** Run release checklist and deploy
- **Milestone reviews:** After phases 2, 6, 9, and 11 - **Milestone check-ins:** After phases 2, 6, 9, and 11
--- ---
+1 -12
View File
@@ -12,29 +12,18 @@ description = "Historical document transcription system"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"aiosqlite>=0.21.0",
"asyncpg>=0.31.0",
"fastapi>=0.138.0", "fastapi>=0.138.0",
"nicegui==3.13.0", "nicegui==3.13.0",
"openrouter>=0.7.0", "openrouter>=0.7.0",
"psycopg2-binary>=2.9.12",
"pydantic>=2.13.4", "pydantic>=2.13.4",
"pydantic-settings>=2.9.1", "pydantic-settings>=2.9.1",
"sqlmodel>=0.0.25", "sqlmodel>=0.0.25",
] ]
[project.optional-dependencies]
[dependency-groups]
dev = [ dev = [
"pytest>=8.0", "pytest>=8.0",
"pytest-asyncio>=0.25", "pytest-asyncio>=0.25",
"httpx2>=2.5.0",
"ipykernel>=7.3.0",
"ipywidgets>=8.1.8",
"pre-commit>=4.6.0",
"rich>=15.0.0",
"ruff>=0.15.20",
"ty>=0.0.54",
] ]
[tool.pytest.ini_options] [tool.pytest.ini_options]
-62
View File
@@ -1,62 +0,0 @@
line-length = 120
indent-width = 4
target-version = "py313"
exclude = [
".venv",
".devenv",
".git",
".vscode",
"build",
"site",
"__pycache__",
]
[lint]
preview = true
extend-select = [
"ARG", # https://docs.astral.sh/ruff/rules/#flake8-unused-arguments-arg
"B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
"C4", # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4
"DOC102", # https://docs.astral.sh/ruff/rules/docstring-extraneous-parameter/
"DOC202", # https://docs.astral.sh/ruff/rules/docstring-extraneous-returns/
"DOC403", # https://docs.astral.sh/ruff/rules/docstring-extraneous-yields/
"DOC502", # https://docs.astral.sh/ruff/rules/docstring-extraneous-exception/
"E", "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
"F", # https://docs.astral.sh/ruff/rules/#pyflakes-f
"FURB", # https://docs.astral.sh/ruff/rules/#refurb-furb
"I", # https://docs.astral.sh/ruff/rules/#isort-i
"N", # https://docs.astral.sh/ruff/rules/#pep8-naming-n
"PD", # https://docs.astral.sh/ruff/rules/#pandas-vet-pd
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
"PLR0202", # https://docs.astral.sh/ruff/rules/no-classmethod-decorator/
"PLR0203", # https://docs.astral.sh/ruff/rules/no-staticmethod-decorator/
"PLR0206", # https://docs.astral.sh/ruff/rules/property-with-parameters/
"PLR0915", # https://docs.astral.sh/ruff/rules/too-many-statements/
"PLR1702", # https://docs.astral.sh/ruff/rules/too-many-nested-blocks/
"TRY002",
]
extend-fixable = ["ALL"]
ignore = [
"UP046",
"UP047",
]
[lint.extend-per-file-ignores]
"*.ipynb" = [
"F401", # unused imports
"F841", # unused local variable
"F821", # undefined name in exploratory notebook cells
]
[lint.isort]
force-single-line = true
[format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
+6
View File
@@ -34,6 +34,12 @@ def _status_for(error: AppError) -> int:
def register_error_handlers(app: FastAPI) -> None: def register_error_handlers(app: FastAPI) -> None:
"""Register API exception handlers on the app.""" """Register API exception handlers on the app."""
@app.exception_handler(AccessDeniedError)
async def access_denied_handler(_request: Request, exc: AccessDeniedError) -> JSONResponse:
envelope = build_error_envelope(exc)
headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None
return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers)
@app.exception_handler(AppError) @app.exception_handler(AppError)
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse: async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
envelope = build_error_envelope(exc) envelope = build_error_envelope(exc)
+161
View File
@@ -0,0 +1,161 @@
"""Functional API routes for jobs, revisions, search, and export."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter
from pydantic import BaseModel
from pydantic import Field
from transcription.services.library import accept_revision
from transcription.services.library import add_revision
from transcription.services.library import export_transcripts
from transcription.services.library import get_job_detail
from transcription.services.library import list_jobs
from transcription.services.library import list_revisions
from transcription.services.library import search_accepted_transcripts
router = APIRouter(prefix="/api", tags=["transcription"])
class CreateRevisionRequest(BaseModel):
text: str = Field(min_length=1)
source: str = "user"
accepted: bool = False
@router.get("/jobs")
def get_jobs() -> list[dict[str, str]]:
jobs = list_jobs()
return [
{
"id": str(job.id),
"document_id": str(job.document_id),
"status": job.status.value,
"created_at": job.created_at.isoformat(),
"updated_at": job.updated_at.isoformat(),
}
for job in jobs
]
@router.get("/jobs/{job_id}")
def get_job(job_id: UUID) -> dict[str, object | None]:
detail = get_job_detail(job_id=job_id)
return {
"job": {
"id": str(detail.job.id),
"document_id": str(detail.job.document_id),
"status": detail.job.status.value,
"created_at": detail.job.created_at.isoformat(),
"updated_at": detail.job.updated_at.isoformat(),
},
"document": (
{
"id": str(detail.document.id),
"filename": detail.document.filename,
"file_path": detail.document.file_path,
}
if detail.document is not None
else None
),
"transcript": (
{
"id": str(detail.transcript.id),
"text": detail.transcript.text,
"error_detail": detail.transcript.error_detail,
"created_at": detail.transcript.created_at.isoformat(),
}
if detail.transcript is not None
else None
),
"accepted_revision": (
{
"id": str(detail.accepted_revision.id),
"revision_number": detail.accepted_revision.revision_number,
"text": detail.accepted_revision.text,
"source": detail.accepted_revision.source,
"created_at": detail.accepted_revision.created_at.isoformat(),
}
if detail.accepted_revision is not None
else None
),
}
@router.get("/jobs/{job_id}/revisions")
def get_job_revisions(job_id: UUID) -> list[dict[str, object]]:
revisions = list_revisions(job_id=job_id)
return [
{
"id": str(revision.id),
"job_id": str(revision.job_id),
"revision_number": revision.revision_number,
"text": revision.text,
"source": revision.source,
"accepted": revision.accepted,
"created_at": revision.created_at.isoformat(),
}
for revision in revisions
]
@router.post("/jobs/{job_id}/revisions")
def create_job_revision(job_id: UUID, payload: CreateRevisionRequest) -> dict[str, object]:
revision = add_revision(
job_id=job_id,
text=payload.text,
source=payload.source,
accepted=payload.accepted,
)
return {
"id": str(revision.id),
"job_id": str(revision.job_id),
"revision_number": revision.revision_number,
"text": revision.text,
"source": revision.source,
"accepted": revision.accepted,
"created_at": revision.created_at.isoformat(),
}
@router.post("/revisions/{revision_id}/accept")
def accept_job_revision(revision_id: UUID) -> dict[str, object]:
revision = accept_revision(revision_id=revision_id)
return {
"id": str(revision.id),
"job_id": str(revision.job_id),
"revision_number": revision.revision_number,
"text": revision.text,
"source": revision.source,
"accepted": revision.accepted,
"created_at": revision.created_at.isoformat(),
}
@router.get("/search")
def search(query: str) -> list[dict[str, object]]:
results = search_accepted_transcripts(query=query)
return [
{
"revision_id": str(revision.id),
"job_id": str(revision.job_id),
"revision_number": revision.revision_number,
"text": revision.text,
"source": revision.source,
"accepted": revision.accepted,
"created_at": revision.created_at.isoformat(),
}
for revision in results
]
@router.get("/export")
def export(accepted_only: bool = True) -> dict[str, object]:
records = export_transcripts(accepted_only=accepted_only)
return {
"count": len(records),
"accepted_only": accepted_only,
"records": records,
}
+56 -17
View File
@@ -2,21 +2,50 @@
from __future__ import annotations from __future__ import annotations
from contextlib import AsyncExitStack
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from threading import Event
from threading import Thread
from fastapi import FastAPI from fastapi import FastAPI
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from .api.errors import register_error_handlers from .api.errors import register_error_handlers
from .api.health import router as health_router from .api.health import router as health_router
from .config import configure_logging from .config import configure_logging
from .config import get_settings from .config import get_settings
from .db import cleanup_database
from .db import create_all from .db import create_all
from .db import dispose_database_runtime
from .db import initialize_database_runtime from .db import initialize_database_runtime
from .services import ServiceBundle
from .ui import register_pages from .ui import register_pages
from .worker import worker_consumer_lifespan from .worker import run_worker_loop
def _start_worker(app: FastAPI) -> None:
session_factory: async_sessionmaker[AsyncSession] = app.state.db_session_factory
stop_event = Event()
worker_thread = Thread(
target=run_worker_loop,
kwargs={
"session_factory": session_factory,
"stop_event": stop_event,
"poll_interval_seconds": 1.0,
},
daemon=True,
)
worker_thread.start()
app.state.worker_stop_event = stop_event
app.state.worker_thread = worker_thread
def _stop_worker(app: FastAPI) -> None:
stop_event = getattr(app.state, "worker_stop_event", None)
worker_thread = getattr(app.state, "worker_thread", None)
if stop_event is not None:
stop_event.set()
if worker_thread is not None:
worker_thread.join(timeout=2.0)
@asynccontextmanager @asynccontextmanager
@@ -25,32 +54,42 @@ async def _lifespan(app: FastAPI):
settings = get_settings() settings = get_settings()
app.state.settings = settings app.state.settings = settings
app.state.services = ServiceBundle() runtime = initialize_database_runtime(settings=settings)
app.state.runtime = initialize_database_runtime(settings=settings) app.state.db_engine = runtime.engine
app.state.db_session_factory = runtime.session_factory
if settings.should_bootstrap_schema: if settings.should_bootstrap_schema:
await create_all(engine=app.state.runtime.engine) await create_all(engine=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)
async with AsyncExitStack() as stack: _start_worker(app)
stack.push_async_callback(dispose_database_runtime) try:
stop_event, worker_notifier = await stack.enter_async_context(
worker_consumer_lifespan(
session_factory=app.state.runtime.session_factory,
poll_interval_seconds=1.0,
)
)
app.state.worker_stop_event = stop_event
app.state.worker_notifier = worker_notifier
yield yield
finally:
_stop_worker(app)
await cleanup_database()
def create_app() -> FastAPI: def create_app() -> 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)
@app.middleware("http")
async def operator_access_middleware(request: Request, call_next):
settings = get_settings()
try:
enforce_request_access(request=request, settings=settings)
except AccessDeniedError as exc:
envelope = build_error_envelope(exc)
headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None
return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers)
return await call_next(request)
register_error_handlers(app) register_error_handlers(app)
register_pages(app) register_pages(app)
app.include_router(health_router) app.include_router(health_router)
app.include_router(transcription_router)
return app return app
-39
View File
@@ -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)
+13 -4
View File
@@ -41,12 +41,21 @@ class Settings(BaseSettings):
# --- persistence --- # --- persistence ---
database_url: str = "sqlite:///./transcription.db" database_url: str = "sqlite:///./transcription.db"
bootstrap_schema_on_startup: bool | None = None bootstrap_schema_on_startup: bool | None = None
sqlite_check_same_thread: bool = False migration_auto_apply_on_startup: bool = False
validate_schema_on_startup: bool = True
# --- filesystem paths --- # --- filesystem paths ---
upload_dir: Path = Path("./uploads") upload_dir: Path = Path("./uploads")
prompt_dir: Path = Path("./prompts") prompt_dir: Path = Path("./prompts")
# --- upload safety ---
max_upload_bytes: int = 15 * 1024 * 1024
# --- single-operator access control ---
operator_access_enabled: bool = False
operator_username: str = "operator"
operator_password: str | None = None
# --- worker reliability --- # --- worker reliability ---
worker_max_retries: int = 0 worker_max_retries: int = 0
worker_retry_backoff_seconds: float = 0.0 worker_retry_backoff_seconds: float = 0.0
@@ -62,10 +71,10 @@ class Settings(BaseSettings):
_settings: ContextVar[Settings | None] = ContextVar("settings", default=None) _settings: ContextVar[Settings | None] = ContextVar("settings", default=None)
def get_settings(**kwargs) -> Settings: def get_settings() -> Settings:
settings = _settings.get() settings = _settings.get()
if settings is None: if settings is None:
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] settings = Settings() # pyright: ignore[reportCallIssue]
_settings.set(settings) _settings.set(settings)
return settings return settings
@@ -75,7 +84,7 @@ LOGGING_CONFIG: dict[str, object] = {
"disable_existing_loggers": False, "disable_existing_loggers": False,
"formatters": { "formatters": {
"standard": { "standard": {
"format": "%(asctime)s %(levelname)-8s | %(message)s", "format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S", "datefmt": "%Y-%m-%d %H:%M:%S",
} }
}, },
+149
View File
@@ -0,0 +1,149 @@
"""Database runtime ownership, schema bootstrap, and session access.
V1 moves database resource ownership to explicit runtime initialization so
startup/shutdown behavior is predictable and lifespan-managed.
"""
from __future__ import annotations
import contextlib
import logging
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from sqlalchemy import inspect
from sqlalchemy import text
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from .config import Settings
from .config import get_settings
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class DatabaseRuntime:
"""Database runtime resources owned by app lifespan."""
engine: AsyncEngine
session_factory: async_sessionmaker[AsyncSession]
_runtime: DatabaseRuntime | None = None
def _to_async_database_url(database_url: str) -> str:
"""Normalize configured database URL to an async SQLAlchemy driver URL."""
if database_url.startswith("sqlite://") and not database_url.startswith("sqlite+aiosqlite://"):
return database_url.replace("sqlite://", "sqlite+aiosqlite://", 1)
if database_url.startswith("postgresql://") and not database_url.startswith("postgresql+asyncpg://"):
return database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
return database_url
def _build_engine(settings: Settings) -> AsyncEngine:
database_url = _to_async_database_url(settings.database_url)
connect_args: dict[str, object] = {}
if database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
return create_async_engine(
url=database_url,
echo=False,
pool_pre_ping=True,
connect_args=connect_args,
)
def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime:
"""Initialize lifespan-owned async DB resources once per process."""
global _runtime
if _runtime is not None:
return _runtime
active_settings = settings or get_settings()
engine = _build_engine(active_settings)
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
_runtime = DatabaseRuntime(engine=engine, session_factory=session_factory)
logger.debug("Initialized async database runtime for database_url=%s", engine.url)
return _runtime
def get_engine() -> AsyncEngine:
"""Return the current async SQLAlchemy engine."""
runtime = _runtime or initialize_database_runtime()
return runtime.engine
def get_session_factory() -> async_sessionmaker[AsyncSession]:
"""Return the shared async session factory."""
runtime = _runtime or initialize_database_runtime()
return runtime.session_factory
async def cleanup_database() -> None:
"""Cleanup database runtime resources."""
await dispose_database_runtime()
async def dispose_database_runtime() -> None:
"""Dispose lifespan-owned async database resources."""
global _runtime
if _runtime is None:
return
await _runtime.engine.dispose()
_runtime = None
async def create_all(*, engine: AsyncEngine | None = None) -> None:
"""Create all tables on the selected engine."""
# Import models so SQLModel metadata is fully registered before bootstrap.
from transcription import models as _models # noqa: F401
active_engine = engine or get_engine()
async with active_engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all)
await connection.run_sync(_ensure_sqlite_compat_columns)
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
def _ensure_sqlite_compat_columns(connection: Connection) -> None:
"""Apply lightweight dev/test SQLite compatibility column patches.
This performs read-only validation and never mutates schema.
"""
if connection.engine.url.get_backend_name() != "sqlite":
return
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
required_tables = {"document", "job", "transcript", "transcriptrevision"}
missing_tables = sorted(required_tables - table_names)
for table_name in missing_tables:
issues.append(f"missing_table:{table_name}")
columns = {column["name"] for column in inspector.get_columns("job")}
if "retry_count" not in columns:
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0")
@contextlib.asynccontextmanager
async def get_session(
*,
session_factory: async_sessionmaker[AsyncSession] | None = None,
) -> AsyncGenerator[AsyncSession]:
"""Yield a database session and ensure cleanup."""
active_session_factory = session_factory or get_session_factory()
async with active_session_factory() as session:
yield session
def should_bootstrap_schema(settings: Settings) -> bool:
"""Compatibility helper for explicit bootstrap checks."""
return settings.should_bootstrap_schema
-6
View File
@@ -1,6 +0,0 @@
from .operations import create_all
from .runtime import dispose_database_runtime
from .runtime import get_session
from .runtime import initialize_database_runtime
__all__ = ["create_all", "dispose_database_runtime", "get_session", "initialize_database_runtime"]
-60
View File
@@ -1,60 +0,0 @@
from __future__ import annotations
import logging
from sqlalchemy import inspect
from sqlalchemy import text
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlmodel import SQLModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..models import Job
from ..models import JobStatus
from .runtime import get_engine
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:
"""Create all tables on the selected engine."""
# Import models so SQLModel metadata is fully registered before bootstrap.
from transcription import models as _models # noqa: F401
active_engine = engine or get_engine()
async with active_engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all)
await connection.run_sync(_ensure_sqlite_compat_columns)
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
def _ensure_sqlite_compat_columns(connection: Connection) -> None:
"""Apply lightweight dev/test SQLite compatibility column patches.
This keeps local bootstrap resilient when models evolve but no full
migration tooling is in place yet.
"""
if connection.engine.url.get_backend_name() != "sqlite":
return
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
if "job" not in table_names:
return
columns = {column["name"] for column in inspector.get_columns("job")}
if "retry_count" not in columns:
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0")
-103
View File
@@ -1,103 +0,0 @@
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from functools import partial
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.pool import StaticPool
from ..config import Settings
from ..config import get_settings
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class DatabaseRuntime:
"""Database runtime resources owned by app lifespan."""
engine: AsyncEngine
session_factory: async_sessionmaker[AsyncSession]
_runtime: ContextVar[DatabaseRuntime | None] = ContextVar("database_runtime", default=None)
async def dispose_database_runtime() -> None:
"""Dispose lifespan-owned async database resources."""
runtime = _runtime.get()
if runtime is None:
return
await runtime.engine.dispose()
_runtime.set(None)
def _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:
"""Initialize lifespan-owned async DB resources once per process."""
runtime = _runtime.get()
if runtime is not None:
return runtime
active_settings = settings or get_settings()
engine = _build_engine(active_settings)
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
runtime = DatabaseRuntime(engine=engine, session_factory=session_factory)
_runtime.set(runtime)
logger.debug("Initialized async database runtime for database_url=%s", engine.url)
return runtime
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
+6 -3
View File
@@ -17,7 +17,6 @@ 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"
PROCESSING = "processing_error"
INFRA_TRANSIENT = "infrastructure_transient_error" INFRA_TRANSIENT = "infrastructure_transient_error"
INFRA_PERSISTENT = "infrastructure_persistent_error" INFRA_PERSISTENT = "infrastructure_persistent_error"
INTERNAL_UNEXPECTED = "internal_unexpected_error" INTERNAL_UNEXPECTED = "internal_unexpected_error"
@@ -72,8 +71,9 @@ def build_error_envelope(error: AppError) -> ErrorEnvelope:
def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError: def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
"""Normalize unknown exceptions into internal_unexpected_error.""" """Normalize unknown exceptions into internal_unexpected_error."""
_ = exc
return AppError( return AppError(
f"Unexpected error during {operation}: {exc}", f"Unexpected error during {operation}",
category=ErrorCategory.INTERNAL_UNEXPECTED, category=ErrorCategory.INTERNAL_UNEXPECTED,
suggestion="Retry once. If it persists, review logs and report the error reference id.", suggestion="Retry once. If it persists, review logs and report the error reference id.",
retriable=False, retriable=False,
@@ -82,4 +82,7 @@ def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
def format_error_detail(error: AppError) -> str: def format_error_detail(error: AppError) -> str:
"""Return a compact persisted failure string for transcript.error_detail.""" """Return a compact persisted failure string for transcript.error_detail."""
return f"[{error.category.value}] {error.message} | suggestion={error.suggestion} | error_id={error.error_id}" return (
f"[{error.category.value}] {error.message} | "
f"suggestion={error.suggestion} | error_id={error.error_id}"
)
+75
View File
@@ -0,0 +1,75 @@
"""CLI entrypoint for explicit schema migration and compatibility checks."""
from __future__ import annotations
import argparse
from transcription.config import get_settings
from transcription.db import initialize_database_runtime
from transcription.db import validate_schema_compatibility
from transcription.migrations import apply_pending_migrations
from transcription.migrations import list_pending_migrations
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Transcription schema migration runner")
parser.add_argument(
"--apply",
action="store_true",
help="Apply all pending migrations.",
)
parser.add_argument(
"--list",
action="store_true",
help="List pending migrations.",
)
parser.add_argument(
"--check",
action="store_true",
help="Run schema compatibility check.",
)
return parser
def main() -> int:
parser = _build_parser()
args = parser.parse_args()
if not (args.apply or args.list or args.check):
parser.error("Specify at least one action: --list, --apply, or --check")
runtime = initialize_database_runtime(settings=get_settings())
engine = runtime.engine
if args.list:
pending = list_pending_migrations(engine=engine)
if not pending:
print("No pending migrations.")
else:
print("Pending migrations:")
for migration in pending:
print(f"- {migration.revision_id}: {migration.description}")
if args.apply:
applied = apply_pending_migrations(engine=engine)
if not applied:
print("No migrations applied.")
else:
print("Applied migrations:")
for revision_id in applied:
print(f"- {revision_id}")
if args.check:
issues = validate_schema_compatibility(engine=engine)
if issues:
print("Schema compatibility check failed:")
for issue in issues:
print(f"- {issue}")
return 1
print("Schema compatibility check passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+132
View File
@@ -0,0 +1,132 @@
"""Lightweight schema migration helpers for V1 Step 4.
This module provides explicit, operator-invoked migration execution for
personal-scale deployments without introducing heavyweight migration tooling.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from sqlalchemy import inspect
from sqlalchemy import text
from sqlalchemy.engine import Connection
from sqlalchemy.engine import Engine
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class MigrationRevision:
"""Represents one ordered schema migration revision."""
revision_id: str
description: str
apply: Callable[[Connection], None]
def _ensure_history_table(connection: Connection) -> None:
"""Create migration history table when missing."""
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS schema_migration_history (
revision_id VARCHAR(64) PRIMARY KEY,
description VARCHAR(255) NOT NULL,
applied_at VARCHAR(64) NOT NULL
)
"""
)
)
def _get_applied_revisions(connection: Connection) -> set[str]:
"""Return applied migration revision IDs."""
_ensure_history_table(connection)
rows = connection.execute(text("SELECT revision_id FROM schema_migration_history")).fetchall()
return {row[0] for row in rows}
def _record_revision(connection: Connection, revision: MigrationRevision) -> None:
"""Persist one applied migration revision record."""
connection.execute(
text(
"""
INSERT INTO schema_migration_history (revision_id, description, applied_at)
VALUES (:revision_id, :description, :applied_at)
"""
),
{
"revision_id": revision.revision_id,
"description": revision.description,
"applied_at": datetime.now(UTC).isoformat(),
},
)
def _apply_0001_add_retry_count(connection: Connection) -> None:
"""Ensure job.retry_count exists for legacy databases."""
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
if "job" not in table_names:
return
columns = {column["name"] for column in inspector.get_columns("job")}
if "retry_count" in columns:
return
# Compatible with SQLite and PostgreSQL for this additive integer column.
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
def _apply_0002_create_transcriptrevision(connection: Connection) -> None:
"""Ensure transcriptrevision table exists."""
# Import models lazily so metadata is fully populated.
from sqlmodel import SQLModel
from transcription.models import TranscriptRevision # noqa: F401
table = SQLModel.metadata.tables["transcriptrevision"]
table.create(bind=connection, checkfirst=True)
MIGRATIONS: tuple[MigrationRevision, ...] = (
MigrationRevision(
revision_id="0001_add_retry_count_to_job",
description="Add retry_count column to job table with default 0",
apply=_apply_0001_add_retry_count,
),
MigrationRevision(
revision_id="0002_create_transcriptrevision_table",
description="Create transcriptrevision table for immutable transcript history",
apply=_apply_0002_create_transcriptrevision,
),
)
def list_pending_migrations(*, engine: Engine) -> list[MigrationRevision]:
"""Return pending migrations ordered by revision."""
with engine.begin() as connection:
applied = _get_applied_revisions(connection)
return [revision for revision in MIGRATIONS if revision.revision_id not in applied]
def apply_pending_migrations(*, engine: Engine) -> list[str]:
"""Apply all pending migrations and return applied revision IDs."""
pending = list_pending_migrations(engine=engine)
applied_ids: list[str] = []
for revision in pending:
logger.info("Applying migration revision=%s", revision.revision_id)
with engine.begin() as connection:
_ensure_history_table(connection)
revision.apply(connection)
_record_revision(connection, revision)
applied_ids.append(revision.revision_id)
logger.info("Applied migration revision=%s", revision.revision_id)
return applied_ids
+19 -18
View File
@@ -1,8 +1,4 @@
"""SQLModel domain models for the transcription system. """SQLModel domain models for the transcription system."""
Three models capture the MVP lifecycle:
Document -> one-to-many -> Job -> one-to-one -> Transcript
"""
from datetime import UTC from datetime import UTC
from datetime import datetime from datetime import datetime
@@ -20,6 +16,7 @@ class JobStatus(StrEnum):
QUEUED = "queued" QUEUED = "queued"
PROCESSING = "processing" PROCESSING = "processing"
TRANSCRIBED = "transcribed" TRANSCRIBED = "transcribed"
COMPLETED = "completed"
FAILED = "failed" FAILED = "failed"
@@ -48,28 +45,32 @@ class Job(SQLModel, table=True):
# --- relationships --- # --- relationships ---
document: Document = Relationship(back_populates="jobs") document: Document = Relationship(back_populates="jobs")
transcript: Optional["Transcript"] = Relationship(back_populates="job") transcript: Optional["Transcript"] = Relationship(back_populates="job")
revisions: list["TranscriptRevision"] = Relationship(back_populates="job")
@property
def filename(self) -> str:
"""Return the filename of the associated document."""
return self.document.filename if self.document else "unknown"
class Transcript(SQLModel, table=True): class Transcript(SQLModel, table=True):
"""The output of a transcription job.""" """Canonical transcript state for a job (latest text or failure detail)."""
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
job_id: UUID = Field(foreign_key="job.id", unique=True) job_id: UUID = Field(foreign_key="job.id", unique=True)
"""ID for the associated job. There's a 1-1 relationship bewteen transcripts and jobs."""
provider: str
"""Name of the transcription provider used to generate this transcript."""
prompt_name: str
"""Name of the prompt used to generate this transcript."""
text: str | None = None 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 error_detail: str | None = None
"""Details of any error that occurred during transcription."""
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# --- relationships --- # --- relationships ---
job: Job = Relationship(back_populates="transcript") job: Job = Relationship(back_populates="transcript")
class TranscriptRevision(SQLModel, table=True):
"""Immutable transcript revision history for review/acceptance workflows."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
job_id: UUID = Field(foreign_key="job.id", index=True)
revision_number: int = Field(ge=1)
text: str
source: str = Field(default="worker")
accepted: bool = Field(default=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# --- relationships ---
job: Job = Relationship(back_populates="revisions")
+1 -15
View File
@@ -2,9 +2,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Protocol from typing import Protocol
from uuid import UUID
from ..models import Transcript
class ProviderError(RuntimeError): class ProviderError(RuntimeError):
@@ -25,22 +22,11 @@ class TranscriptionResult:
text: str text: str
provider: str provider: str
prompt_name: str
model: str model: str
def to_transcript(self, job_id: UUID) -> Transcript:
"""Convert a TranscriptionResult to a Transcript model instance."""
return Transcript(
job_id=job_id,
provider=self.provider,
prompt_name=self.prompt_name,
text=self.text,
)
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: def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
"""Transcribe the provided image according to the prompt text.""" """Transcribe the provided image according to the prompt text."""
...
+4 -6
View File
@@ -6,10 +6,8 @@ import base64
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from typing import cast
from openrouter import OpenRouter from openrouter import OpenRouter
from openrouter.components.chatmessages import ChatMessagesTypedDict
from transcription.config import Settings from transcription.config import Settings
from transcription.config import get_settings from transcription.config import get_settings
@@ -46,12 +44,12 @@ class OpenRouterTranscriptionProvider:
"""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: def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> 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)
try: try:
response = await self._client.chat.send_async( response = self._client.chat.send(
messages=cast(list[ChatMessagesTypedDict], request.messages), messages=request.messages,
model=request.model, model=request.model,
http_referer=request.http_referer, http_referer=request.http_referer,
x_open_router_title=request.x_open_router_title, x_open_router_title=request.x_open_router_title,
@@ -65,7 +63,7 @@ class OpenRouterTranscriptionProvider:
text = self._extract_text(response) text = self._extract_text(response)
model = self._get_optional_attr(response, "model") or self.model model = self._get_optional_attr(response, "model") or self.model
logger.info("OpenRouter transcription completed using model=%s", model) logger.info("OpenRouter transcription completed using model=%s", model)
return TranscriptionResult(text=text, provider="openrouter", prompt_name="", model=model) return TranscriptionResult(text=text, provider="openrouter", model=model)
def _build_request(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> OpenRouterRequest: def _build_request(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> OpenRouterRequest:
image_b64 = base64.b64encode(image_bytes).decode("ascii") image_b64 = base64.b64encode(image_bytes).decode("ascii")
+82
View File
@@ -0,0 +1,82 @@
"""Step 5 single-operator access control helpers."""
from __future__ import annotations
import base64
import binascii
import secrets
from fastapi import Request
from transcription.config import Settings
from transcription.errors import AppError
from transcription.errors import ErrorCategory
class AccessDeniedError(AppError):
"""Raised when a request is not authorized for operator actions."""
def __init__(self, message: str, *, suggestion: str, should_challenge: bool = True) -> None:
super().__init__(message, category=ErrorCategory.USER_INPUT, suggestion=suggestion)
self.should_challenge = should_challenge
def is_protected_path(path: str) -> bool:
"""Return True when a request path requires operator authentication."""
return path == "/ui" or path.startswith(("/ui/", "/api"))
def enforce_request_access(*, request: Request, settings: Settings) -> None:
"""Enforce basic operator access control for protected paths."""
if not settings.operator_access_enabled or not is_protected_path(request.url.path):
return
if not settings.operator_password:
raise AppError(
"Operator authentication is enabled but credentials are not configured",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Set OPERATOR_PASSWORD in the runtime environment and restart the app.",
)
authorization = request.headers.get("Authorization")
username, password = _parse_basic_authorization_header(authorization)
valid_username = secrets.compare_digest(username, settings.operator_username)
valid_password = secrets.compare_digest(password, settings.operator_password)
if not (valid_username and valid_password):
raise AccessDeniedError(
"Invalid operator credentials",
suggestion="Provide valid operator credentials and retry.",
)
def _parse_basic_authorization_header(value: str | None) -> tuple[str, str]:
if not value:
raise AccessDeniedError(
"Operator authentication required",
suggestion="Provide HTTP Basic operator credentials and retry.",
)
scheme, _, token = value.partition(" ")
if scheme.lower() != "basic" or not token:
raise AccessDeniedError(
"Operator authentication required",
suggestion="Provide HTTP Basic operator credentials and retry.",
)
try:
decoded = base64.b64decode(token, validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError) as exc:
raise AccessDeniedError(
"Invalid authentication header",
suggestion="Provide HTTP Basic operator credentials and retry.",
) from exc
username, sep, password = decoded.partition(":")
if not sep or not username:
raise AccessDeniedError(
"Invalid authentication header",
suggestion="Provide HTTP Basic operator credentials and retry.",
)
return username, password
+22 -16
View File
@@ -1,19 +1,25 @@
"""Service layer exports.""" """Service layer exports."""
from dataclasses import dataclass from transcription.services.transcription import DEFAULT_PROMPT_FILE
from dataclasses import field from transcription.services.transcription import PromptLoadError
from transcription.services.transcription import TranscriptionError
from transcription.services.transcription import load_image_payload
from transcription.services.transcription import load_prompt_text
from transcription.services.transcription import transcribe_document_image
from transcription.services.upload import SUPPORTED_UPLOAD_EXTENSIONS
from transcription.services.upload import UploadError
from transcription.services.upload import UploadJobResult
from transcription.services.upload import create_upload_job
from .documents import DocumentService __all__ = [
from .jobs import JobService "DEFAULT_PROMPT_FILE",
from .transcription import TranscriptionService "SUPPORTED_UPLOAD_EXTENSIONS",
"PromptLoadError",
__all__ = ["DocumentService", "JobService", "ServiceBundle", "TranscriptionService"] "TranscriptionError",
"UploadError",
"UploadJobResult",
@dataclass(frozen=True, slots=True) "create_upload_job",
class ServiceBundle: "load_image_payload",
"""Container for all service instances.""" "load_prompt_text",
"transcribe_document_image",
documents: DocumentService = field(default_factory=DocumentService) ]
jobs: JobService = field(default_factory=JobService)
transcriptions: TranscriptionService = field(default_factory=TranscriptionService)
-60
View File
@@ -1,60 +0,0 @@
import asyncio
from abc import ABC
from collections.abc import Sequence
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from ..config import Settings
from ..config import get_settings
from ..db.runtime import get_session_factory
class ServiceBase(ABC):
"""Thin service class for managing documents in the database."""
settings: Settings
session_factory: async_sessionmaker[AsyncSession]
queue: asyncio.Queue
def __init__(
self,
session_factory: async_sessionmaker[AsyncSession] | None = None,
queue: asyncio.Queue | None = None,
):
self.settings = get_settings()
self.session_factory = session_factory or get_session_factory()
self.queue = queue or asyncio.Queue()
@asynccontextmanager
async def _session_scope(self, session: AsyncSession | None = None):
"""Provide a transactional scope around a series of operations."""
if session is not None:
# Reuse the provided session if one is passed in
yield session
else:
# Otherwise, create a new session for this scope
async with self.session_factory() as new_session:
yield new_session
async def _finalize(
self,
*,
session: AsyncSession,
caller_session: AsyncSession | None,
refresh: Sequence[object] = (),
) -> None:
"""Finalize a write based on transaction ownership.
Service-owned sessions commit immediately. Caller-owned sessions flush so
orchestration code can commit once at a larger transaction boundary.
"""
should_commit = caller_session is None
if should_commit:
await session.commit()
else:
await session.flush()
for obj in refresh:
await session.refresh(obj)
-127
View File
@@ -1,127 +0,0 @@
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from uuid import UUID
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..errors import AppError
from ..errors import ErrorCategory
from ..models import Document
from .base import ServiceBase
logger = logging.getLogger(__name__)
class DocumentError(AppError):
"""Raised when document operations fail."""
class MissingImageError(DocumentError):
"""Raised when a required image is missing."""
class UploadError(DocumentError):
"""Raised when uploaded content cannot be persisted safely."""
class DocumentAlreadyExistsError(DocumentError):
"""Raised when a document with the same filename already exists in the database."""
@dataclass(frozen=True)
class UploadJobResult:
"""Summary of created upload records."""
document_id: UUID
job_id: UUID
stored_path: Path
original_filename: str
class DocumentService(ServiceBase):
"""Thin service class for managing documents in the database."""
#
# CRUD Operations
#
async def create_document(
self,
document: Document,
*,
session: AsyncSession | None = None,
) -> Document:
"""Create a new document in the database."""
async with self._session_scope(session) as _session:
_session.add(document)
try:
await self._finalize(session=_session, caller_session=session, refresh=(document,))
except IntegrityError as exc:
raise DocumentAlreadyExistsError(
f"Document with id {document.id} already exists",
category=ErrorCategory.VALIDATION,
suggestion="Rename the file and try again.",
) from exc
return document
async def read_document(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
"""Read an existing document from the database.
The selectinload option is used to eagerly load related jobs for the document.
"""
async with self._session_scope(session) as _session:
document = await _session.get(
Document,
document_id,
options=(selectinload(Document.jobs),), # pyright: ignore[reportArgumentType]
)
if document is None:
raise DocumentError(
f"Document with id {document_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Re-upload the source document and retry.",
)
elif not Path(document.file_path).exists():
raise MissingImageError(
f"Document with id {document_id} is missing its image file in {self.settings.upload_dir:!s}",
category=ErrorCategory.NOT_FOUND,
suggestion="Re-upload the source document and retry.",
)
return document
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
"""Update an existing document in the database."""
async with self._session_scope(session) as _session:
merged = await _session.merge(document)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
"""Delete a document from the database."""
async with self._session_scope(session) as _session:
await _session.delete(document)
await self._finalize(session=_session, caller_session=session)
# Query Operations
async def query_documents(
self, *, filename: str | None = None, session: AsyncSession | None = None
) -> Sequence[Document]:
"""Query documents from the database based on provided filters."""
async with self._session_scope(session) as _session:
query = select(Document)
if filename is not None:
query = query.where(Document.filename == filename)
result = await _session.exec(query)
return result.all()
async def list_documents(self, *, session: AsyncSession | None = None) -> Sequence[Document]:
"""List all documents in the database."""
async with self._session_scope(session) as _session:
result = await _session.exec(select(Document))
return result.all()
-146
View File
@@ -1,146 +0,0 @@
from collections.abc import Sequence
from datetime import UTC
from datetime import datetime
from uuid import UUID
from sqlalchemy.orm import selectinload
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..models import Job
from ..models import JobStatus
from .base import ServiceBase
class JobService(ServiceBase):
"""Thin service class for managing jobs in the database."""
#
# CRUD Operations
#
async def create_job(self, job: Job, session: AsyncSession | None = None) -> Job:
"""Create a new job in the database."""
async with self._session_scope(session) as _session:
_session.add(job)
await self._finalize(session=_session, caller_session=session, refresh=(job,))
return job
async def read_job(self, job_id: UUID, session: AsyncSession | None = None) -> Job:
"""Read an existing job from the database.
The related document is always eagerly loaded so callers can safely
access ``job.document`` in async contexts without triggering lazy-load IO.
"""
async with self._session_scope(session) as _session:
query = (
select(Job)
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
.where(Job.id == job_id)
.execution_options(populate_existing=True)
)
job = (await _session.exec(query)).first()
if job is None:
raise ValueError(f"Job with id {job_id} not found")
return job
async def update_job(self, job: Job, session: AsyncSession | None = None) -> Job:
"""Update an existing job in the database."""
async with self._session_scope(session) as _session:
merged = await _session.merge(job)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
return merged
async def delete_job(self, job: Job, session: AsyncSession | None = None) -> None:
"""Delete a job from the database."""
async with self._session_scope(session) as _session:
await _session.delete(job)
await self._finalize(session=_session, caller_session=session)
# Query Operations
async def query_jobs(
self,
*,
status: JobStatus | None = None,
filename: str | None = None,
session: AsyncSession | None = None,
) -> Sequence[Job]:
"""Query jobs from the database based on provided filters."""
async with self._session_scope(session) as _session:
query = select(Job).options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
if status is not None:
query = query.where(Job.status == status)
if filename is not None:
query = query.where(Job.document.filename == filename)
result = await _session.exec(query)
return result.all()
async def list_jobs(
self,
*,
load_docs: bool = False,
session: AsyncSession | None = None,
) -> Sequence[Job]:
"""List all jobs in the database with eagerly loaded documents."""
_ = load_docs
async with self._session_scope(session) as _session:
query = select(Job).options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
result = await _session.exec(query)
return result.all()
# Other Operations
async def mark_job_status(
self,
job_id: UUID,
status: JobStatus,
session: AsyncSession | None = None,
) -> Job:
"""Mark a job with a new status."""
return await self.update_job_state(job_id=job_id, status=status, session=session)
async def update_job_state(
self,
*,
job_id: UUID,
status: JobStatus,
retry_count_increment: int = 0,
session: AsyncSession | None = None,
) -> Job:
"""Update a job's lifecycle fields.
When ``session`` is provided, this method flushes so callers can commit
once at an orchestration boundary.
"""
async with self._session_scope(session) as _session:
query = (
select(Job)
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
.where(Job.id == job_id)
.execution_options(populate_existing=True)
)
job = (await _session.exec(query)).first()
if job is None:
raise ValueError(f"Job with id {job_id} not found")
job.status = status
if retry_count_increment:
job.retry_count += retry_count_increment
job.updated_at = datetime.now(UTC)
await self._finalize(session=_session, caller_session=session, refresh=(job,))
return job
async def read_next_queued_job(
self,
*,
session: AsyncSession | None = None,
) -> Job | None:
"""Read the next queued job ordered by creation time."""
async with self._session_scope(session) as _session:
query = (
select(Job)
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
.where(Job.status == JobStatus.QUEUED)
.order_by(Job.created_at) # pyright: ignore[reportArgumentType]
)
return (await _session.exec(query)).first()
+264
View File
@@ -0,0 +1,264 @@
"""Step 3 functional services: job detail, revisions, search, and export."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from uuid import UUID
from sqlmodel import Session
from sqlmodel import select
from transcription.db import get_session
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.models import TranscriptRevision
class LibraryError(AppError):
"""Base error for review/search/export service pathways."""
@dataclass(frozen=True)
class JobDetail:
"""Job detail read model including latest transcript and accepted revision."""
job: Job
document: Document | None
transcript: Transcript | None
accepted_revision: TranscriptRevision | None
def list_jobs(*, session: Session | None = None) -> list[Job]:
"""Return jobs in most-recent-first order."""
if session is None:
with get_session() as local_session:
return list_jobs(session=local_session)
return list(session.exec(select(Job).order_by(Job.created_at.desc())).all())
def get_job_detail(*, job_id: UUID, session: Session | None = None) -> JobDetail:
"""Fetch job detail with related document/transcript and accepted revision."""
if session is None:
with get_session() as local_session:
return get_job_detail(job_id=job_id, session=local_session)
job = session.get(Job, job_id)
if job is None:
raise LibraryError(
f"Job not found: {job_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh jobs list and open a valid job id.",
)
document = session.get(Document, job.document_id)
transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
accepted_revision = session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.job_id == job.id, TranscriptRevision.accepted.is_(True))
.order_by(TranscriptRevision.revision_number.desc())
).first()
return JobDetail(
job=job,
document=document,
transcript=transcript,
accepted_revision=accepted_revision,
)
def add_revision(
*,
job_id: UUID,
text: str,
source: str = "user",
accepted: bool = False,
session: Session | None = None,
) -> TranscriptRevision:
"""Append a transcript revision and optionally mark it as accepted."""
if not text.strip():
raise LibraryError(
"Revision text cannot be empty",
category=ErrorCategory.VALIDATION,
suggestion="Provide non-empty transcript text and retry.",
)
if session is None:
with get_session() as local_session:
return add_revision(
job_id=job_id,
text=text,
source=source,
accepted=accepted,
session=local_session,
)
job = session.get(Job, job_id)
if job is None:
raise LibraryError(
f"Job not found: {job_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh jobs list and retry with a valid job id.",
)
revisions = list(
session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.job_id == job_id)
.order_by(TranscriptRevision.revision_number)
).all()
)
next_revision_number = (revisions[-1].revision_number + 1) if revisions else 1
if accepted:
for existing in revisions:
if existing.accepted:
existing.accepted = False
session.add(existing)
revision = TranscriptRevision(
job_id=job_id,
revision_number=next_revision_number,
text=text,
source=source,
accepted=accepted,
)
session.add(revision)
transcript = session.exec(select(Transcript).where(Transcript.job_id == job_id)).first()
if transcript is None:
transcript = Transcript(job_id=job_id)
transcript.text = text
transcript.error_detail = None
session.add(transcript)
job.updated_at = datetime.now(UTC)
if accepted:
job.status = JobStatus.COMPLETED
elif job.status == JobStatus.QUEUED:
job.status = JobStatus.TRANSCRIBED
session.add(job)
session.commit()
session.refresh(revision)
return revision
def accept_revision(*, revision_id: UUID, session: Session | None = None) -> TranscriptRevision:
"""Mark one revision as accepted and synchronize canonical transcript/job state."""
if session is None:
with get_session() as local_session:
return accept_revision(revision_id=revision_id, session=local_session)
revision = session.get(TranscriptRevision, revision_id)
if revision is None:
raise LibraryError(
f"Revision not found: {revision_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh job detail and select a valid revision.",
)
all_revisions = list(session.exec(select(TranscriptRevision).where(TranscriptRevision.job_id == revision.job_id)).all())
for item in all_revisions:
item.accepted = item.id == revision.id
session.add(item)
transcript = session.exec(select(Transcript).where(Transcript.job_id == revision.job_id)).first()
if transcript is None:
transcript = Transcript(job_id=revision.job_id)
transcript.text = revision.text
transcript.error_detail = None
session.add(transcript)
job = session.get(Job, revision.job_id)
if job is not None:
job.status = JobStatus.COMPLETED
job.updated_at = datetime.now(UTC)
session.add(job)
session.commit()
session.refresh(revision)
return revision
def list_revisions(*, job_id: UUID, session: Session | None = None) -> list[TranscriptRevision]:
"""Return revision history for a job in ascending revision order."""
if session is None:
with get_session() as local_session:
return list_revisions(job_id=job_id, session=local_session)
if session.get(Job, job_id) is None:
raise LibraryError(
f"Job not found: {job_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh jobs list and open a valid job id.",
)
return list(
session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.job_id == job_id)
.order_by(TranscriptRevision.revision_number)
).all()
)
def search_accepted_transcripts(*, query: str, session: Session | None = None) -> list[TranscriptRevision]:
"""Search accepted transcript revisions using case-insensitive text containment."""
if not query.strip():
raise LibraryError(
"Search query cannot be empty",
category=ErrorCategory.VALIDATION,
suggestion="Enter a non-empty search query and retry.",
)
if session is None:
with get_session() as local_session:
return search_accepted_transcripts(query=query, session=local_session)
pattern = f"%{query.strip()}%"
return list(
session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.accepted.is_(True), TranscriptRevision.text.ilike(pattern))
.order_by(TranscriptRevision.created_at.desc())
).all()
)
def export_transcripts(*, accepted_only: bool = True, session: Session | None = None) -> list[dict[str, str | int | None]]:
"""Export transcript data as serializable records for archive workflows."""
if session is None:
with get_session() as local_session:
return export_transcripts(accepted_only=accepted_only, session=local_session)
statement = select(TranscriptRevision).order_by(TranscriptRevision.created_at)
if accepted_only:
statement = statement.where(TranscriptRevision.accepted.is_(True))
revisions = list(session.exec(statement).all())
payload: list[dict[str, str | int | None]] = []
for revision in revisions:
detail = get_job_detail(job_id=revision.job_id, session=session)
payload.append(
{
"job_id": str(revision.job_id),
"document_id": str(detail.job.document_id),
"filename": detail.document.filename if detail.document else None,
"revision_id": str(revision.id),
"revision_number": revision.revision_number,
"accepted": revision.accepted,
"source": revision.source,
"text": revision.text,
"created_at": revision.created_at.isoformat(),
}
)
return payload
+23 -139
View File
@@ -4,20 +4,12 @@ from __future__ import annotations
import logging import logging
import mimetypes import mimetypes
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from uuid import UUID
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import selectinload
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings from transcription.config import Settings
from transcription.config import get_settings 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 transcription.models import Transcript
from transcription.providers import ProviderAuthError from transcription.providers import ProviderAuthError
from transcription.providers import ProviderError from transcription.providers import ProviderError
from transcription.providers import ProviderResponseError from transcription.providers import ProviderResponseError
@@ -25,8 +17,6 @@ from transcription.providers import TranscriptionProvider
from transcription.providers import TranscriptionResult from transcription.providers import TranscriptionResult
from transcription.providers import get_transcription_provider from transcription.providers import get_transcription_provider
from .base import ServiceBase
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_PROMPT_FILE = "transcribe_document.md" DEFAULT_PROMPT_FILE = "transcribe_document.md"
@@ -41,131 +31,6 @@ class TranscriptionError(AppError):
"""Raised when transcription execution fails.""" """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(transcript=result.to_transcript(job_id=job_id), session=session)
async def upsert_transcript_by_job(
self,
*,
job_id: UUID,
text: str | None,
error_detail: str | None,
provider: str | None = None,
prompt_name: str = DEFAULT_PROMPT_FILE,
session: AsyncSession | None = None,
) -> Transcript:
"""Create or update a transcript for a job id."""
async with self._session_scope(session) as _session:
transcript = (await _session.exec(select(Transcript).where(Transcript.job_id == job_id))).first()
if transcript is None:
transcript = Transcript(
job_id=job_id,
provider=provider or self.settings.provider.value,
prompt_name=prompt_name,
)
transcript.text = text
transcript.error_detail = error_detail
if provider is not None:
transcript.provider = provider
transcript.prompt_name = prompt_name
_session.add(transcript)
await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
return transcript
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: def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settings | None = None) -> str:
"""Load and validate prompt text from PROMPT_DIR.""" """Load and validate prompt text from PROMPT_DIR."""
runtime_settings = settings or get_settings() runtime_settings = settings or get_settings()
@@ -222,11 +87,27 @@ def load_image_payload(image_path: str | Path) -> tuple[bytes, str]:
return path.read_bytes(), mime_type return path.read_bytes(), mime_type
@contextmanager def transcribe_document_image(
def handle_transcription_errors(): image_path: str | Path,
"""Context manager to handle transcription errors.""" *,
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)
try: try:
yield result = adapter.transcribe(
prompt_text=prompt_text,
image_bytes=image_bytes,
mime_type=mime_type,
)
except ProviderAuthError as exc: except ProviderAuthError as exc:
raise TranscriptionError( raise TranscriptionError(
"Provider authentication failed", "Provider authentication failed",
@@ -247,3 +128,6 @@ def handle_transcription_errors():
suggestion="Retry the transcription from jobs. If repeated, check provider availability.", suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
retriable=True, retriable=True,
) from exc ) from exc
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
return result
@@ -1,19 +1,23 @@
"""Upload service for storing files and creating queued transcription jobs."""
from __future__ import annotations from __future__ import annotations
import logging import logging
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.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings from transcription.config import Settings
from transcription.config import get_settings from transcription.config import get_settings
from transcription.db import get_session
from transcription.errors import AppError from transcription.errors import AppError
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
from transcription.models import Document
from ..models import Document from transcription.models import Job
from ..models import Job from transcription.models import JobStatus
from .documents import UploadJobResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,26 +28,60 @@ class UploadError(AppError):
"""Raised when uploaded content cannot be persisted safely.""" """Raised when uploaded content cannot be persisted safely."""
@dataclass(frozen=True)
class UploadJobResult:
"""Summary of created upload records."""
document_id: UUID
job_id: UUID
stored_path: Path
original_filename: str
async def create_upload_job( async def create_upload_job(
*, *,
filename: str, filename: str,
file_bytes: bytes, file_bytes: bytes,
session: AsyncSession, session: AsyncSession | None = None,
settings: Settings | None = None, settings: Settings | None = None,
) -> UploadJobResult: ) -> UploadJobResult:
"""Create upload-backed document and queued job records.""" """Persist an uploaded file and create document/job records."""
runtime_settings = settings or get_settings() runtime_settings = settings or get_settings()
stored_path = store_file( _validate_upload(
filename=filename, filename=filename,
file_bytes=file_bytes, file_bytes=file_bytes,
settings=runtime_settings, max_upload_bytes=runtime_settings.max_upload_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:
document, job = await _create_upload_records( stored_path.write_bytes(file_bytes)
session=session, except OSError as exc:
original_filename=filename, raise UploadError(
stored_path=stored_path, "Failed to persist upload file",
) category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Check upload directory permissions and available disk space, then retry.",
) from exc
try:
if session is not None:
document, job = await _create_upload_records(
session=session,
original_filename=filename,
stored_path=stored_path,
)
else:
async with get_session() as local_session:
document, job = await _create_upload_records(
session=local_session,
original_filename=filename,
stored_path=stored_path,
)
except Exception as exc: except Exception as exc:
_best_effort_delete(stored_path) _best_effort_delete(stored_path)
raise UploadError( raise UploadError(
@@ -62,60 +100,7 @@ async def create_upload_job(
) )
async def _create_upload_records( def _validate_upload(*, filename: str, file_bytes: bytes, max_upload_bytes: int) -> None:
*,
session: AsyncSession,
original_filename: str,
stored_path: Path,
) -> tuple[Document, Job]:
document = Document(
filename=Path(original_filename).name,
file_path=str(stored_path),
)
session.add(document)
await session.flush()
job = Job(document_id=document.id)
session.add(job)
await session.commit()
await session.refresh(document)
await session.refresh(job)
return document, job
def _best_effort_delete(path: Path) -> None:
try:
if path.exists():
path.unlink()
except OSError:
logger.warning("Failed to clean up upload file after DB error: %s", path)
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
"""Persist an uploaded file to the configured upload directory."""
runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes)
upload_dir = runtime_settings.upload_dir
upload_dir.mkdir(parents=True, exist_ok=True)
stored_name = _build_stored_filename(filename)
stored_path = upload_dir / stored_name
try:
stored_path.write_bytes(file_bytes)
except OSError as exc:
raise UploadError(
"Failed to persist upload file",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Check upload directory permissions and available disk space, then retry.",
) from exc
logger.info("Stored uploaded file: %s", stored_path)
return stored_path
def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
if not file_bytes: if not file_bytes:
raise UploadError( raise UploadError(
"Upload payload is empty", "Upload payload is empty",
@@ -123,6 +108,13 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
suggestion="Select a non-empty file and try again.", suggestion="Select a non-empty file and try again.",
) )
if len(file_bytes) > max_upload_bytes:
raise UploadError(
f"Upload exceeds maximum allowed size ({max_upload_bytes} bytes)",
category=ErrorCategory.USER_INPUT,
suggestion="Upload a smaller file or increase MAX_UPLOAD_BYTES for this deployment.",
)
safe_name = Path(filename).name safe_name = Path(filename).name
if not safe_name: if not safe_name:
raise UploadError( raise UploadError(
@@ -143,3 +135,35 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
def _build_stored_filename(filename: str) -> str: def _build_stored_filename(filename: str) -> str:
safe_name = Path(filename).name safe_name = Path(filename).name
return f"{uuid4()}_{safe_name}" return f"{uuid4()}_{safe_name}"
async def _create_upload_records(
*,
session: AsyncSession,
original_filename: str,
stored_path: Path,
) -> tuple[Document, Job]:
document = Document(
filename=Path(original_filename).name,
file_path=str(stored_path),
)
session.add(document)
await session.flush()
job = Job(
document_id=document.id,
status=JobStatus.QUEUED,
)
session.add(job)
await session.commit()
await session.refresh(document)
await session.refresh(job)
return document, job
def _best_effort_delete(path: Path) -> None:
try:
if path.exists():
path.unlink()
except OSError:
logger.warning("Failed to clean up upload file after DB error: %s", path)
-241
View File
@@ -1,241 +0,0 @@
import asyncio
import logging
from sqlmodel.ext.asyncio.session import AsyncSession
from ..config import Settings
from ..config import get_settings
from ..errors import AppError
from ..errors import classify_unexpected_error
from ..errors import format_error_detail
from ..models import Job
from ..models import JobStatus
from ..providers import TranscriptionResult
from . import ServiceBundle
from .transcription import DEFAULT_PROMPT_FILE
from .transcription import transcribe_document_image
logger = logging.getLogger(__name__)
async def advance_job(
job: Job,
services: ServiceBundle,
settings: Settings | None = None,
session: AsyncSession | None = None,
) -> Job | None:
"""Advance a single job by lifecycle status."""
settings = settings or get_settings()
match job.status:
case JobStatus.QUEUED:
return await process_queued_job(job=job, services=services, session=session)
case JobStatus.FAILED:
if job.retry_count < settings.worker_max_retries:
return await services.jobs.update_job_state(
job_id=job.id,
status=JobStatus.QUEUED,
retry_count_increment=1,
session=session,
)
else:
logger.error(f"Job {job.id} has failed and reached max retries.")
return
case _:
return
async def process_queued_job(
*,
job: Job,
services: ServiceBundle,
session: AsyncSession | None = None,
) -> Job | None:
"""Process one complete transcription attempt for a queued job."""
if job.status != JobStatus.QUEUED:
logger.warning(f"Job {job.id} is not queued. Current status: {job.status}")
return
# Transaction A: claim job for processing.
if session is None:
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING)
else:
# If we're sharing the session, need to make sure setting the Job to PROCESSING is committed before we start
# the transcription, otherwise other workers may see the job as still QUEUED and try to process it.
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING, session=session)
await session.commit()
document = job.document
assert document is not None, (
f"Job {job.id} has no associated document or the document failed to be loaded by the job service."
)
try:
result = await transcribe_document_image(document.file_path)
job = await _finalize_transcribed(job=job, services=services, result=result, session=session)
logger.info(
"Job transcribed operation=worker.process_job job_id=%s document_id=%s provider=%s",
job.id,
document.id,
result.provider,
)
except Exception as exc: # noqa: BLE001
match exc:
case AppError() as error:
pass
case _:
error = classify_unexpected_error(exc, operation="worker.process_job")
job = await _finalize_failed(job=job, services=services, error=error, session=session)
logger.error(
"Job failed operation=worker.process_job job_id=%s document_id=%s error_id=%s category=%s",
job.id,
document.id,
error.error_id,
error.category.value,
)
return job
async def process_next_queued_job(
*,
services: ServiceBundle,
settings: Settings | None = None,
session: AsyncSession | None = None,
) -> bool:
"""Process the next queued job if one exists."""
job = await services.jobs.read_next_queued_job(session=session)
if job is None:
return False
await advance_job(job=job, services=services, settings=settings, session=session)
return True
async def _finalize_transcribed(
*,
job: Job,
services: ServiceBundle,
result: TranscriptionResult,
session: AsyncSession | None = None,
) -> Job:
"""Transaction B: transcript + TRANSCRIBED in one commit."""
if session is None:
async with services.jobs._session_scope() as local_session:
await services.transcriptions.upsert_transcript_by_job(
job_id=job.id,
text=result.text,
error_detail=None,
provider=result.provider,
prompt_name=result.prompt_name,
session=local_session,
)
updated_job = await services.jobs.mark_job_status(
job.id,
JobStatus.TRANSCRIBED,
session=local_session,
)
await local_session.commit()
return updated_job
await services.transcriptions.upsert_transcript_by_job(
job_id=job.id,
text=result.text,
error_detail=None,
provider=result.provider,
prompt_name=result.prompt_name,
session=session,
)
updated_job = await services.jobs.mark_job_status(
job.id,
JobStatus.TRANSCRIBED,
session=session,
)
await session.commit()
return updated_job
async def _finalize_retry(
*,
job: Job,
services: ServiceBundle,
error: AppError,
settings: Settings,
session: AsyncSession | None = None,
) -> Job:
"""Transaction C: transcript error + QUEUED + retry increment in one commit."""
if session is None:
async with services.jobs._session_scope() as local_session:
await services.transcriptions.upsert_transcript_by_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.update_job_state(
job_id=job.id,
status=JobStatus.QUEUED,
retry_count_increment=1,
session=local_session,
)
await local_session.commit()
else:
await services.transcriptions.upsert_transcript_by_job(
job_id=job.id,
text=None,
error_detail=format_error_detail(error),
prompt_name=DEFAULT_PROMPT_FILE,
session=session,
)
updated_job = await services.jobs.update_job_state(
job_id=job.id,
status=JobStatus.QUEUED,
retry_count_increment=1,
session=session,
)
await session.commit()
if settings.worker_retry_backoff_seconds > 0:
await asyncio.sleep(settings.worker_retry_backoff_seconds)
return updated_job
async def _finalize_failed(
*,
job: Job,
services: ServiceBundle,
error: AppError,
session: AsyncSession | None = None,
) -> Job:
"""Transaction B: transcript error + FAILED in one commit."""
if session is None:
async with services.jobs._session_scope() as local_session:
await services.transcriptions.upsert_transcript_by_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.upsert_transcript_by_job(
job_id=job.id,
text=None,
error_detail=format_error_detail(error),
prompt_name=DEFAULT_PROMPT_FILE,
session=session,
)
updated_job = await services.jobs.mark_job_status(
job.id,
JobStatus.FAILED,
session=session,
)
await session.commit()
return updated_job
-66
View File
@@ -1,66 +0,0 @@
"""Reusable upload widget for document submission."""
from __future__ import annotations
from collections.abc import Awaitable
from collections.abc import Callable
from nicegui import ui
from nicegui.binding import bindable_dataclass
from nicegui.events import UploadEventArguments
from transcription.errors import AppError
from transcription.services.documents import UploadJobResult
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.error_presenter import summarize_error
from transcription.worker import WorkerNotifier
type UploadSubmitter = Callable[[str, bytes], Awaitable[UploadJobResult]]
@bindable_dataclass
class UploadWidgetState:
"""Simple state container for upload feedback."""
loading: bool = False
message: str = ""
def render_upload_widget(*, submitter: UploadSubmitter, notifier: WorkerNotifier | None = None) -> None:
"""Render upload controls and common status/error handling."""
state = UploadWidgetState()
status_label = ui.label("Upload a document to start transcription.")
status_label.bind_text(state, "message")
async def on_upload(event: UploadEventArguments) -> None:
if state.loading:
ui.notify("Upload already in progress. Please wait.", type="warning")
return
state.loading = True
status_label.text = "Uploading..."
try:
payload = await event.file.read()
result = await submitter(event.file.name, payload)
job_id = result.job_id
state.message = f"Created job {job_id}" if job_id is not None else "Upload complete"
status_label.text = state.message
if notifier is not None:
notifier.notify()
ui.notify(state.message, type="positive")
except AppError as exc:
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
except Exception as exc: # noqa: BLE001
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
finally:
state.loading = False
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select document file",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf"')
+245
View File
@@ -0,0 +1,245 @@
"""Jobs list and detail page registration."""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from nicegui import ui
from sqlmodel import select
from transcription.db import get_session
from transcription.models import Document
from transcription.models import Job
from transcription.models import Transcript
from transcription.services.library import accept_revision
from transcription.services.library import add_revision
from transcription.services.library import export_transcripts
from transcription.services.library import list_revisions
from transcription.services.library import search_accepted_transcripts
from transcription.ui.error_presenter import show_error
from transcription.ui.error_presenter import summarize_error
@dataclass(frozen=True)
class JobView:
"""Read model for rendering job rows in the UI."""
id: UUID
status: str
created_at: str
updated_at: str
def fetch_jobs() -> list[JobView]:
"""Return jobs for display in most-recent-first order."""
with get_session() as session:
jobs = session.exec(select(Job).order_by(Job.created_at.desc())).all()
return [
JobView(
id=job.id,
status=job.status.value,
created_at=job.created_at.isoformat(),
updated_at=job.updated_at.isoformat(),
)
for job in jobs
]
def fetch_job_detail(job_id: UUID) -> tuple[Job | None, Document | None, Transcript | None]:
"""Return job, document, and transcript for detail view."""
with get_session() as session:
job = session.get(Job, job_id)
if job is None:
return None, None, None
document = session.get(Document, job.document_id)
transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
return job, document, transcript
def register_page() -> None:
"""Register jobs list and detail routes."""
@ui.page("/jobs")
def jobs_page() -> None:
ui.label("Transcription Jobs")
status = ui.label("Ready")
table_container = ui.column()
def render_table() -> None:
table_container.clear()
jobs = fetch_jobs()
with table_container:
if not jobs:
ui.label("No jobs yet.")
return
rows = [
{
"id": str(job.id),
"status": job.status,
"created_at": job.created_at,
"updated_at": job.updated_at,
}
for job in jobs
]
ui.table(
columns=[
{"name": "id", "label": "Job ID", "field": "id"},
{"name": "status", "label": "Status", "field": "status"},
{"name": "created_at", "label": "Created", "field": "created_at"},
{"name": "updated_at", "label": "Updated", "field": "updated_at"},
],
rows=rows,
row_key="id",
)
for row in rows:
ui.link(f"Open {row['id']}", f"/jobs/{row['id']}")
def refresh() -> None:
status.text = "Refreshing..."
try:
render_table()
status.text = "Refreshed"
except Exception as exc: # noqa: BLE001
status.text = f"Refresh failed: {summarize_error(exc, operation='jobs.refresh')}"
show_error(exc, title="Jobs refresh failed", operation="jobs.refresh")
ui.button("Refresh", on_click=refresh)
render_table()
ui.link("Back to upload", "/")
@ui.page("/jobs/{job_id}")
def job_detail_page(job_id: str) -> None:
ui.label("Job Detail")
try:
parsed_id = UUID(job_id)
except ValueError:
ui.label("Invalid job id")
ui.link("Back to jobs", "/jobs")
return
job, document, transcript = fetch_job_detail(parsed_id)
if job is None:
ui.label("Job not found")
ui.link("Back to jobs", "/jobs")
return
ui.label(f"Job ID: {job.id}")
ui.label(f"Status: {job.status.value}")
ui.label(f"Created: {job.created_at.isoformat()}")
ui.label(f"Updated: {job.updated_at.isoformat()}")
if document is not None:
ui.label(f"Filename: {document.filename}")
ui.label(f"File path: {document.file_path}")
if transcript is None:
ui.label("Transcript not available yet.")
elif transcript.text:
ui.label("Transcript:")
ui.markdown(transcript.text)
elif transcript.error_detail:
ui.label("Failure detail:")
ui.label(transcript.error_detail)
ui.separator()
ui.label("Revision History")
revisions_container = ui.column()
def render_revisions() -> None:
revisions_container.clear()
with revisions_container:
revisions = list_revisions(job_id=parsed_id)
if not revisions:
ui.label("No revisions yet.")
return
for revision in revisions:
with ui.card().classes("w-full"):
ui.label(
f"Revision {revision.revision_number} | source={revision.source} | accepted={revision.accepted}"
)
ui.markdown(revision.text)
if not revision.accepted:
ui.button(
"Accept revision",
on_click=lambda rev_id=revision.id: _accept_revision(rev_id),
)
def _accept_revision(revision_id):
try:
accept_revision(revision_id=revision_id)
ui.notify("Revision accepted", type="positive")
render_revisions()
except Exception as exc: # noqa: BLE001
show_error(exc, title="Accept revision failed", operation="revisions.accept")
new_revision_text = ui.textarea("Add revision text").props("rows=6")
def _submit_revision() -> None:
try:
add_revision(job_id=parsed_id, text=new_revision_text.value or "", source="user", accepted=False)
new_revision_text.value = ""
ui.notify("Revision added", type="positive")
render_revisions()
except Exception as exc: # noqa: BLE001
show_error(exc, title="Add revision failed", operation="revisions.create")
ui.button("Add revision", on_click=_submit_revision)
render_revisions()
ui.link("Search transcripts", "/search")
ui.link("Export transcripts", "/export")
ui.link("Back to jobs", "/jobs")
@ui.page("/search")
def search_page() -> None:
ui.label("Search Accepted Transcripts")
query_input = ui.input("Search query")
results_container = ui.column()
def run_search() -> None:
results_container.clear()
try:
results = search_accepted_transcripts(query=query_input.value or "")
except Exception as exc: # noqa: BLE001
show_error(exc, title="Search failed", operation="search.run")
return
with results_container:
if not results:
ui.label("No results.")
return
for result in results:
with ui.card().classes("w-full"):
ui.label(f"Job {result.job_id} | Revision {result.revision_number}")
ui.markdown(result.text)
ui.button("Search", on_click=run_search)
ui.link("Back to jobs", "/jobs")
@ui.page("/export")
def export_page() -> None:
ui.label("Export Accepted Transcripts")
results_container = ui.column()
def run_export() -> None:
results_container.clear()
try:
records = export_transcripts(accepted_only=True)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Export failed", operation="export.run")
return
with results_container:
ui.label(f"Exported records: {len(records)}")
for record in records:
with ui.card().classes("w-full"):
ui.label(f"{record['filename']} | Revision {record['revision_number']}")
ui.markdown(str(record["text"]))
ui.button("Run export", on_click=run_export)
ui.link("Back to jobs", "/jobs")
+59 -19
View File
@@ -2,33 +2,73 @@
from __future__ import annotations from __future__ import annotations
from fastapi import Request from dataclasses import dataclass
from nicegui import ui
from transcription.app_state import resolve_session_factory from nicegui import ui
from transcription.db import get_session from nicegui.events import UploadEventArguments
from transcription.services.store import create_upload_job
from transcription.ui.components.upload import render_upload_widget from transcription.services.upload import UploadError
from transcription.worker import resolve_worker_notifier from transcription.services.upload import UploadJobResult
from transcription.services.upload import create_upload_job
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.error_presenter import summarize_error
@dataclass
class UploadPageState:
"""Simple state container for upload page feedback."""
loading: bool = False
message: str = ""
def accepted_upload_types() -> str:
"""Return accepted file type string for upload input."""
return ".jpg,.jpeg,.png,.tif,.tiff,.pdf"
async def submit_upload(*, filename: str, file_bytes: bytes) -> UploadJobResult:
"""Create an upload job from incoming file data."""
return await create_upload_job(filename=filename, file_bytes=file_bytes)
def register_page() -> None: def register_page() -> None:
"""Register the upload page route.""" """Register the upload page route."""
@ui.page("/upload", title="Upload Document") @ui.page("/")
def upload_page(request: Request) -> None: def upload_page() -> None:
session_factory = resolve_session_factory(request.app.state) state = UploadPageState()
status_label = ui.label("Upload a document to start transcription.")
async def submit_upload(filename: str, file_bytes: bytes): async def on_upload(event: UploadEventArguments) -> None:
async with get_session(session_factory=session_factory) as session: if state.loading:
return await create_upload_job( ui.notify("Upload already in progress. Please wait.", type="warning")
filename=filename, return
file_bytes=file_bytes,
session=session,
)
notify_worker = resolve_worker_notifier(request.app.state) state.loading = True
render_upload_widget(submitter=submit_upload, notifier=notify_worker) status_label.text = "Uploading..."
try:
payload = await event.file.read()
result = await submit_upload(filename=event.file.name, file_bytes=payload)
state.message = f"Created job {result.job_id}"
status_label.text = state.message
ui.notify(state.message, type="positive")
except UploadError as exc:
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
except Exception as exc: # noqa: BLE001
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
finally:
state.loading = False
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select document file",
).props(f"accept={accepted_upload_types()}")
with ui.row(): with ui.row():
ui.link("View jobs", "/jobs") ui.link("View jobs", "/jobs")
+162 -155
View File
@@ -4,161 +4,31 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from collections.abc import AsyncGenerator from datetime import UTC
from contextlib import asynccontextmanager from datetime import datetime
from contextlib import contextmanager from threading import Event
from contextlib import suppress
from typing import Protocol
from uuid import UUID
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker
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 get_settings
from transcription.db import get_session from transcription.db import get_session
from transcription.errors import AppError from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.errors import classify_unexpected_error from transcription.errors import classify_unexpected_error
from transcription.errors import format_error_detail
from .services import ServiceBundle from transcription.models import Document
from .services.documents import DocumentService from transcription.models import Job
from .services.jobs import JobService from transcription.models import JobStatus
from .services.transcription import TranscriptionService from transcription.models import Transcript
from .services.workflows import advance_job from transcription.services.transcription import transcribe_document_image
from .services.workflows import process_next_queued_job as process_next_queued_job_workflow
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class WorkerNotifier(Protocol):
"""Abstraction for signaling the worker loop about new work."""
def notify(self) -> None:
"""Signal the worker loop that work may be available."""
class EventWorkerNotifier:
"""Worker notifier backed by an asyncio.Event."""
def __init__(self, wake_event: asyncio.Event):
self._wake_event = wake_event
def notify(self) -> None:
self._wake_event.set()
class NoopWorkerNotifier:
"""Fallback notifier used when worker signaling is unavailable."""
def notify(self) -> None:
return
def resolve_worker_notifier(state: object) -> WorkerNotifier:
"""Resolve notifier from app-like state objects with no-op fallback."""
notifier = getattr(state, "worker_notifier", None)
if isinstance(notifier, NoopWorkerNotifier):
return notifier
if notifier is None:
return NoopWorkerNotifier()
return notifier
@asynccontextmanager
async def worker_consumer_lifespan(
*,
session_factory: async_sessionmaker[AsyncSession] | None = None,
poll_interval_seconds: float = 1.0,
) -> AsyncGenerator[tuple[asyncio.Event, WorkerNotifier]]:
"""Start and stop the worker consumer loop for app lifespan."""
stop_event = asyncio.Event()
wake_event = asyncio.Event()
worker_notifier: WorkerNotifier = EventWorkerNotifier(wake_event)
worker_task = asyncio.create_task(
run_worker_loop(
session_factory=session_factory,
stop_event=stop_event,
wake_event=wake_event,
poll_interval_seconds=poll_interval_seconds,
)
)
worker_notifier.notify()
try:
yield stop_event, worker_notifier
finally:
stop_event.set()
worker_notifier.notify()
try:
await asyncio.wait_for(worker_task, timeout=2.0)
except TimeoutError:
worker_task.cancel()
with suppress(asyncio.CancelledError):
await worker_task
async def queue_consumer_loop(queue: asyncio.Queue[UUID], stop_event: asyncio.Event):
"""Main worker loop that consumes jobs from the queue and processes them.
The queue is for Job UUIDs, and the corresponding documents should already have been uploaded.
"""
service = JobService()
while not stop_event.is_set():
with handle_worker_exceptions():
async with _get_queue_item(queue) as job_id:
job = await service.read_job(job_id)
asyncio.create_task(advance_job(job=job, services=ServiceBundle()))
@contextmanager
def handle_worker_exceptions(operation: str = "worker.loop"):
"""Context manager to log and suppress exceptions in the worker loop."""
try:
yield
except Exception as exc:
error = exc if isinstance(exc, AppError) else classify_unexpected_error(exc, operation=operation)
logger.exception(
"Worker loop exception error_id=%s category=%s",
error.error_id,
error.category.value,
)
@asynccontextmanager
async def _get_queue_item(queue: asyncio.Queue[UUID]) -> AsyncGenerator[UUID]:
"""Context manager to enqueue a job and ensure it is marked done."""
yield await queue.get()
queue.task_done()
async def run_worker_loop(
*,
session_factory: async_sessionmaker[AsyncSession] | None = None,
stop_event: asyncio.Event | None = None,
wake_event: asyncio.Event | None = None,
poll_interval_seconds: float = 1.0,
) -> None:
"""Run worker loop until stop_event is set.
If wake_event is provided, signal activity wakes the loop immediately while
timeout-based wakeups preserve current polling behavior.
"""
while True:
if stop_event is not None and stop_event.is_set():
logger.info("Worker stop event received")
return
if wake_event is not None:
with suppress(TimeoutError):
await asyncio.wait_for(wake_event.wait(), timeout=poll_interval_seconds)
wake_event.clear()
processed_any = False
while await process_next_queued_job(session_factory=session_factory):
processed_any = True
if wake_event is None and not processed_any:
await asyncio.sleep(poll_interval_seconds)
async def process_next_queued_job( async def process_next_queued_job(
*, *,
session: AsyncSession | None = None, session: AsyncSession | None = None,
@@ -168,17 +38,154 @@ async def process_next_queued_job(
Returns True when a job was processed, False when no queued job exists. Returns True when a job was processed, False when no queued job exists.
""" """
if session_factory is None:
services = ServiceBundle()
else:
services = ServiceBundle(
documents=DocumentService(session_factory=session_factory),
jobs=JobService(session_factory=session_factory),
transcriptions=TranscriptionService(session_factory=session_factory),
)
if session is None: if session is None:
async with get_session(session_factory=session_factory) as local_session: async with get_session(session_factory=session_factory) as local_session:
return await process_next_queued_job_workflow(services=services, session=local_session) return await _process_next_queued_job(session=local_session)
return await _process_next_queued_job(session=session)
return await process_next_queued_job_workflow(services=services, session=session)
async def _process_next_queued_job(*, session: AsyncSession) -> bool:
job = (await session.exec(select(Job).where(Job.status == JobStatus.QUEUED).order_by(Job.created_at))).first()
if job is None:
return False
logger.info("Picked queued job operation=worker.pick job_id=%s", job.id)
job.status = JobStatus.PROCESSING
job.updated_at = datetime.now(UTC)
session.add(job)
await session.commit()
await session.refresh(job)
document = await session.get(Document, job.document_id)
if document is None:
error = AppError(
"Document not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Re-upload the source document and retry processing.",
)
_finalize_failed_job(session=session, job=job, error=error)
logger.error(
"Job failed operation=worker.process_job job_id=%s error_id=%s category=%s",
job.id,
error.error_id,
error.category.value,
)
return True
try:
result = transcribe_document_image(document.file_path)
await _upsert_transcript(session=session, job_id=job.id, text=result.text, error_detail=None)
job.status = JobStatus.TRANSCRIBED
job.updated_at = datetime.now(UTC)
session.add(job)
await session.commit()
logger.info(
"Job transcribed operation=worker.process_job job_id=%s document_id=%s provider=%s revision_number=%s",
job.id,
document.id,
result.provider,
revision.revision_number,
)
except Exception as exc:
error = exc if isinstance(exc, AppError) else classify_unexpected_error(exc, operation="worker.process_job")
settings = _get_worker_settings()
if _should_retry(job=job, error=error, settings=settings):
await _requeue_for_retry(session=session, job=job, error=error, settings=settings)
logger.warning(
"Job retried operation=worker.process_job job_id=%s document_id=%s retry_count=%s error_id=%s category=%s",
job.id,
document.id,
job.retry_count,
error.error_id,
error.category.value,
)
else:
await _finalize_failed_job(session=session, job=job, error=error)
logger.exception(
"Job failed operation=worker.process_job job_id=%s document_id=%s error_id=%s category=%s",
job.id,
document.id,
error.error_id,
error.category.value,
)
return True
async def _upsert_transcript(
*, session: AsyncSession, job_id, text: str | None, error_detail: str | None
) -> Transcript:
transcript = (await session.exec(select(Transcript).where(Transcript.job_id == job_id))).first()
if transcript is None:
transcript = Transcript(job_id=job_id)
transcript.text = text
transcript.error_detail = error_detail
session.add(transcript)
await session.commit()
await session.refresh(transcript)
return transcript
def _get_worker_settings() -> Settings:
try:
return get_settings()
except ValidationError:
return Settings(openrouter_api_key="test-key")
def _should_retry(*, job: Job, error: AppError, settings: Settings) -> bool:
return error.retriable and job.retry_count < settings.worker_max_retries
async def _requeue_for_retry(*, session: AsyncSession, job: Job, error: AppError, settings: Settings) -> None:
await _upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error))
job.retry_count += 1
job.status = JobStatus.QUEUED
job.updated_at = datetime.now(UTC)
session.add(job)
await session.commit()
if settings.worker_retry_backoff_seconds > 0:
await asyncio.sleep(settings.worker_retry_backoff_seconds)
async def _finalize_failed_job(*, session: AsyncSession, job: Job, error: AppError) -> None:
await _upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error))
job.status = JobStatus.FAILED
job.updated_at = datetime.now(UTC)
session.add(job)
await session.commit()
async def _run_worker_loop_async(
*,
session_factory: async_sessionmaker[AsyncSession] | None = None,
stop_event: Event | None = None,
poll_interval_seconds: float = 1.0,
) -> None:
"""Run worker polling loop until stop_event is set."""
while True:
if stop_event is not None and stop_event.is_set():
logger.info("Worker stop event received")
return
processed = await process_next_queued_job(session_factory=session_factory)
if not processed:
await asyncio.sleep(poll_interval_seconds)
def run_worker_loop(
*,
session_factory: async_sessionmaker[AsyncSession] | None = None,
stop_event: Event | None = None,
poll_interval_seconds: float = 1.0,
) -> None:
"""Synchronous thread entrypoint that runs the async worker loop."""
asyncio.run(
_run_worker_loop_async(
session_factory=session_factory,
stop_event=stop_event,
poll_interval_seconds=poll_interval_seconds,
)
)
+129
View File
@@ -0,0 +1,129 @@
"""Tests for Step 5 operator access control behavior."""
from __future__ import annotations
import base64
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from transcription.api.errors import register_error_handlers
from transcription.errors import build_error_envelope
from transcription.security import AccessDeniedError
from transcription.security import enforce_request_access
def _basic_header(username: str, password: str) -> str:
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
return f"Basic {token}"
def _build_app(*, settings) -> FastAPI:
app = FastAPI()
register_error_handlers(app)
@app.middleware("http")
async def operator_access_middleware(request, call_next):
try:
enforce_request_access(request=request, settings=settings)
except AccessDeniedError as exc:
envelope = build_error_envelope(exc)
headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None
return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers)
return await call_next(request)
@app.get("/healthz")
def healthz():
return {"status": "ok"}
@app.get("/api/jobs")
def get_jobs():
return [{"id": "demo"}]
@app.get("/ui")
def ui_root():
return {"ok": True}
return app
@pytest.mark.integration
class TestAccessControl:
"""Verify protected routes enforce operator auth when enabled."""
def test_protected_api_requires_credentials(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get("/api/jobs")
assert response.status_code == 401
assert response.headers.get("WWW-Authenticate") == "Basic"
payload = response.json()
assert payload["category"] == "user_input_error"
assert payload["suggestion"]
def test_protected_api_rejects_invalid_credentials(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get(
"/api/jobs",
headers={"Authorization": _basic_header("operator", "wrong")},
)
assert response.status_code == 401
payload = response.json()
assert payload["message"] == "Invalid operator credentials"
def test_protected_api_allows_valid_credentials(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get(
"/api/jobs",
headers={"Authorization": _basic_header("operator", "secret")},
)
assert response.status_code == 200
assert response.json() == [{"id": "demo"}]
def test_protected_ui_path_requires_credentials(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get("/ui")
assert response.status_code == 401
def test_healthz_is_not_protected(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
+3 -2
View File
@@ -1,11 +1,12 @@
"""Tests for API error response envelope handlers.""" """Tests for API error response envelope handlers."""
import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import pytest
from transcription.api.errors import register_error_handlers from transcription.api.errors import register_error_handlers
from transcription.errors import AppError, ErrorCategory from transcription.errors import AppError
from transcription.errors import ErrorCategory
@pytest.mark.integration @pytest.mark.integration
+119
View File
@@ -0,0 +1,119 @@
"""Tests for Step 3 functional API routes."""
from datetime import UTC
from datetime import datetime
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from transcription.api.errors import register_error_handlers
from transcription.api.routes import router
def _build_app() -> FastAPI:
app = FastAPI()
register_error_handlers(app)
app.include_router(router)
return app
@pytest.mark.integration
class TestFunctionalRoutes:
"""Verify jobs/revisions/search/export route behavior."""
def test_get_jobs_returns_serialized_rows(self, monkeypatch):
"""GET /api/jobs returns normalized job rows."""
now = datetime.now(UTC)
job = SimpleNamespace(
id=uuid4(),
document_id=uuid4(),
status=SimpleNamespace(value="queued"),
created_at=now,
updated_at=now,
)
monkeypatch.setattr("transcription.api.routes.list_jobs", lambda: [job])
client = TestClient(_build_app())
response = client.get("/api/jobs")
assert response.status_code == 200
payload = response.json()
assert len(payload) == 1
assert payload[0]["id"] == str(job.id)
assert payload[0]["status"] == "queued"
def test_create_revision_returns_revision_payload(self, monkeypatch):
"""POST /api/jobs/{job_id}/revisions returns created revision fields."""
revision = SimpleNamespace(
id=uuid4(),
job_id=uuid4(),
revision_number=2,
text="edited text",
source="user",
accepted=False,
created_at=datetime.now(UTC),
)
monkeypatch.setattr("transcription.api.routes.add_revision", lambda **_kwargs: revision)
client = TestClient(_build_app())
response = client.post(
f"/api/jobs/{revision.job_id}/revisions",
json={"text": "edited text", "source": "user", "accepted": False},
)
assert response.status_code == 200
payload = response.json()
assert payload["id"] == str(revision.id)
assert payload["revision_number"] == 2
assert payload["text"] == "edited text"
def test_search_returns_results(self, monkeypatch):
"""GET /api/search returns accepted transcript matches."""
result = SimpleNamespace(
id=uuid4(),
job_id=uuid4(),
revision_number=1,
text="family archive",
source="user",
accepted=True,
created_at=datetime.now(UTC),
)
monkeypatch.setattr("transcription.api.routes.search_accepted_transcripts", lambda query: [result])
client = TestClient(_build_app())
response = client.get("/api/search", params={"query": "archive"})
assert response.status_code == 200
payload = response.json()
assert len(payload) == 1
assert payload[0]["revision_id"] == str(result.id)
assert payload[0]["accepted"] is True
def test_export_returns_count_and_records(self, monkeypatch):
"""GET /api/export returns record count and payload list."""
records = [
{
"job_id": str(uuid4()),
"document_id": str(uuid4()),
"filename": "letter.jpg",
"revision_id": str(uuid4()),
"revision_number": 1,
"accepted": True,
"source": "user",
"text": "exported",
"created_at": datetime.now(UTC).isoformat(),
}
]
monkeypatch.setattr("transcription.api.routes.export_transcripts", lambda accepted_only=True: records)
client = TestClient(_build_app())
response = client.get("/api/export")
assert response.status_code == 200
payload = response.json()
assert payload["count"] == 1
assert payload["accepted_only"] is True
assert payload["records"] == records
@@ -11,11 +11,11 @@ BOOK 1 had 54 pages; 14 chapters. BOOK 2 has 70 pages; 18 chapters. BOOK 1 con-
sisted largely of first generation family history. BOOK 2 throws more light on sisted largely of first generation family history. BOOK 2 throws more light on
the second generation. Sidney promises a BOOK 3 and that may begin to do justice the second generation. Sidney promises a BOOK 3 and that may begin to do justice
to the third generation. We suggest that Sidney get the help of Louis Shinn to the third generation. We suggest that Sidney get the help of Louis Shinn
who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumeeq who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumecq
Plains. Louis has the gift of seeing, recalling and telling. One sentence in Plains. Louis has the gift of seeing, recalling and telling. One sentence in
his chapter gives a great tribute to the Doumeeqers - so far as he knows no one his chapter gives a great tribute to the Doumecqers--so far as he knows no one
on the Doumeeq Plains went on relief during the depression. That in a nutshell on the Doumecq Plains went on relief during the depression. That in a nutshell
shows the sturdy character of the residents of the Doumeeq Plains. shows the sturdy character of the residents of the Doumecq Plains.
We promised in BOOK 1 that in BOOK 2 we would give the story of the trip of We promised in BOOK 1 that in BOOK 2 we would give the story of the trip of
John E. Cochran and wife to Tennessee, Cuba and the Panama Canal. You will see John E. Cochran and wife to Tennessee, Cuba and the Panama Canal. You will see
@@ -32,7 +32,7 @@ enough pictures but we had to take only part of them. We think there are great
possibilities in reproducing old pictures. We wish we had a Pickard group. Some possibilities in reproducing old pictures. We wish we had a Pickard group. Some
Pickard descendant may wish to make a collection. Pickard descendant may wish to make a collection.
We are much impressed with the future possibilities of getting a complete geneol- We are much impressed with the future possibilities of getting a complete geneol-[sic]
ogy of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but ogy of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but
to date we have not had the pleasure of finding all of the family dates. We had to date we have not had the pleasure of finding all of the family dates. We had
intended to give more family data in this book but it takes time to get the intended to give more family data in this book but it takes time to get the
@@ -6,9 +6,7 @@ JOHN E. COCHRAN
FAMILY ASSOCIATION FAMILY ASSOCIATION
Family Only Family Only
Home | Sibling's Stories | 1st Cousins | Ancestor's Stories | Reunion History | Next Reunion | JECMEF Home | Sibling's Stories | 1st Cousins | Ancestor's Stories | Reunion History | Next Reunion | JECMEF
OMIE WRITES HOME OMIE WRITES HOME
Ed. Note: The following letter was written by Omie Cochran in Nome, Alaska and sent to her Ed. Note: The following letter was written by Omie Cochran in Nome, Alaska and sent to her
sister, Ethel Shinn, in Canfield, Idaho in 1923. It has been stored away these 63 years in the sister, Ethel Shinn, in Canfield, Idaho in 1923. It has been stored away these 63 years in the
original envelope with its 2 cent stamp. The letter has a number of references to the Shinn original envelope with its 2 cent stamp. The letter has a number of references to the Shinn
@@ -17,6 +15,7 @@ Miss Saville was the nurse at the Nome Hospital that was mentioned in the articl
the family newsletter two years ago. the family newsletter two years ago.
Nome Alaska August 26, 1923 Nome Alaska August 26, 1923
My Dear Ethel et al. My Dear Ethel et al.
I don't know when I did write or when you did I don't know when I did write or when you did
@@ -27,7 +26,7 @@ and Polly sit up and listen and that little black
rascal of yours would fairly sparkle with rascal of yours would fairly sparkle with
listening. Can't I see him listening now to all the listening. Can't I see him listening now to all the
yarns we told last summer? yarns we told last summer?
[photo of people on ice with kayak and dog sled]
You see, we-Miss Saville and I, took a trip north You see, we-Miss Saville and I, took a trip north
on the Buford and it was very interesting. We on the Buford and it was very interesting. We
went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain
@@ -38,19 +37,18 @@ along the icebergs and the walrus were 'heisted' on board by the use of the cran
tons of freight and the beasts were so huge that they made the pulleys just creak. They were tons of freight and the beasts were so huge that they made the pulleys just creak. They were
over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or
flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles
all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are
the most repulsive looking animals imaginable and tho I have always read about them I never the most repulsive looking animals imaginable and tho I have always read about them I never
expect such disagreeable looking creatures. They had a rough brown hairy skin and some of expect such disagreeable looking creatures. They had a rough brown hairy skin and some of
them looked warty. They must have weighed two ton at least. Ere we got them back to Nome them looked warty. They must have weighed two ton at least. Ere we got them back to Nome
to the natives they were getting extremely odiferousin fact, you could scarcely stay on the to the natives they were getting extremely odiferousin fact, you could scarcely stay on the
ship with any degree of comfort unless you had per chance lost your sense of smell. ship with any degree of comfort unless you had per chance lost your sense of smell.
Then we went north to a few minutes beyond the 70th degree of latitude and thot [sic] for awhile Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile
we would go to Wrangell Island where some men from Stefflonsons [sic] ship were supposed to be we would go to Wrangell Island where some men from Steffonsons ship were supposed to be
stranded but we didn't get there and instead stopped at a small native village at Cape Serdz [sic] in stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in
Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23 Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23
years. He was a Swedewho else could. Their houses were circular and built up with dirt 2 or years. He was a Swede--who else could. Their houses were circular and built up with dirt 2 or
3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room 3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room
was partitioned off at the sides with skins for sleeping quarters. In the main part they had the was partitioned off at the sides with skins for sleeping quarters. In the main part they had the
fire on the ground and the fish drying on lines and the skins hanging around and the dogs and fire on the ground and the fish drying on lines and the skins hanging around and the dogs and
@@ -65,23 +63,22 @@ The other place we stopped was at Whalen, a trading post in Siberia. There these
went wild. They rushed helter-skelter, hither and thither, here and there, trying to find went wild. They rushed helter-skelter, hither and thither, here and there, trying to find
something to buy. Prices raised right before your eyes. One would but something for $1.00 something to buy. Prices raised right before your eyes. One would but something for $1.00
and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to
have it. One man I was sort of taking care of, tho [sic] he had his son along for the purpose, have it. One man I was sort of taking care of, tho he had his son along for the purpose,
bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billi[illegible]s, 6 or 8 ivory and silver rings, one bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billikens, 6 or 8 ivory and silver rings, one
fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the
little natives throw them at the flocks of geese and ducks which fly close over the village and little natives throw them at the flocks of geese and ducks which fly close over the village and
the slings entangle their wings and legs, sometimes more than one, and they can't fly. They the slings entangle their wings and legs, sometimes more than one, and they can't fly. They
come down and the natives capture them. There was more junk brot [sic] aboard than baggage, I come down and the natives capture them. There was more junk brot aboard than baggage, I
do believe. And they say that at the first stop it was worse than here. The red flag was flying do believe. And they say that at the first stop it was worse than here. The red flag was flying
over Whalen and the Russian soldiers were therea few, one or two or three, I forget the over Whalen and the Russian soldiers were therea few, one or two or three, I forget the
number. number.
We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out
until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let
us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is
all I had to write about and I know Buster'd [sic] listen anyway and I'd soak ole Peter's head if he all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he
didn't and Polly would in my lap and I don't know much about the youngest one of yours so didn't and Polly would in my lap and I don't know much about the youngest one of yours so
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf [sic]. likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf.
I expect there were 150 passengers on board and almost or more of the crew and helpers. We I expect there were 150 passengers on board and almost or more of the crew and helpers. We
had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times. had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times.
@@ -109,7 +106,7 @@ Ome
Reprinted from Cochran Chronicles, Volume 9, Number 1, November 1986 Reprinted from Cochran Chronicles, Volume 9, Number 1, November 1986
© [inserted: JECFA] 1986 © JECFA 1986
Up Up
@@ -2,30 +2,28 @@ source: Rod Moser Letter - p1.jpg
provider: openrouter provider: openrouter
model: google/gemini-2.5-flash model: google/gemini-2.5-flash
--- ---
JOHN ISBILL JOHN ISBILL R. T. MOSER
R. T. MOSER
ISBILL & MOSER ISBILL & MOSER
DEALERS IN DEALERS IN
GENERAL MERCHANDISE GENERAL MERCHANDISE
Vonore, Tenn., Jany 27- 1913 Vonore, Tenn. January 27 - 1913
Dear Much Aunt Louie Dear Uncle [sic] Aun[t Adeline?]
How are you a Was at home a
few nights ago I sewed a few nights ago & saw a
letter from your folks, so letter from you folks, so
I decided to write you I decided to write you
a few lines myself ok a few lines myself &
I am contemplateing a I am contemplating a
trip out west next summer trip out west next summ[er]
& I want Some Olders to go & I want both of fillers [sic] to go
where I and them. when I am [to] them.
Am getting
I am getting up in years & unmarried,
up in years & unmarried
so you see the object of so you see the object of
my trip, is to get a bunch my trip, is to get a wife
of Young & old maids & I hear is a lot old maids
& widows out there. I & widows out there. I
want you to kiss them want you to see them
at my fans [sic] mug as they at my land my [sic] at there [sic]
as soon as I get there as soon as I get there
+3 -28
View File
@@ -5,45 +5,20 @@ isolated, fast, and leave no artifacts on disk.
""" """
import pytest import pytest
import pytest_asyncio
from sqlmodel import Session from sqlmodel import Session
from sqlmodel import SQLModel from sqlmodel import SQLModel
from sqlmodel import create_engine from sqlmodel import create_engine
from sqlmodel.pool import StaticPool from sqlmodel.pool import StaticPool
from transcription.config import Settings
from transcription.config import get_settings
from transcription.db.operations import create_all
from transcription.db.runtime import dispose_database_runtime
from transcription.db.runtime import get_engine
from transcription.db.runtime import get_session
@pytest.fixture @pytest.fixture
def session(): def session():
"""Provide a clean synchronous database session for sync tests.""" """Provide a clean database session for each test."""
engine = create_engine( engine = create_engine(
"sqlite://", "sqlite://",
connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False},
poolclass=StaticPool, poolclass=StaticPool,
) )
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
with Session(engine) as sync_session: with Session(engine) as session:
yield sync_session yield session
@pytest_asyncio.fixture
async def default_settings():
"""Provide default settings for tests."""
settings = get_settings(database_url="sqlite:///:memory:")
await create_all(engine=get_engine(settings=settings))
return settings
@pytest_asyncio.fixture
async def async_session(default_settings: Settings):
"""Provide a clean asynchronous database session for async tests."""
async with get_session(settings=default_settings) as async_session:
yield async_session
await dispose_database_runtime()
+4 -2
View File
@@ -6,7 +6,9 @@ import pytest
from sqlmodel import select from sqlmodel import select
from transcription.config import Settings from transcription.config import Settings
from transcription.models import Job, JobStatus, Transcript from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.providers.base import TranscriptionResult from transcription.providers.base import TranscriptionResult
from transcription.services.upload import create_upload_job from transcription.services.upload import create_upload_job
from transcription.worker import process_next_queued_job from transcription.worker import process_next_queued_job
@@ -71,6 +73,6 @@ class TestPipelineFailureFlow:
assert job.status == JobStatus.FAILED assert job.status == JobStatus.FAILED
assert transcript is not None assert transcript is not None
assert transcript.text is None assert transcript.text is None
assert "pipeline provider failure" in transcript.error_detail assert "pipeline provider failure" not in transcript.error_detail
assert "[internal_unexpected_error]" in transcript.error_detail assert "[internal_unexpected_error]" in transcript.error_detail
assert "error_id=" in transcript.error_detail assert "error_id=" in transcript.error_detail
+4 -2
View File
@@ -5,8 +5,10 @@ from types import SimpleNamespace
import pytest import pytest
from transcription.config import Settings from transcription.config import Settings
from transcription.providers.base import ProviderError, ProviderResponseError from transcription.providers.base import ProviderError
from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL, OpenRouterTranscriptionProvider from transcription.providers.base import ProviderResponseError
from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
class _FakeChat: class _FakeChat:
-88
View File
@@ -1,88 +0,0 @@
from uuid import uuid4
import pytest
from transcription.models import Document
from transcription.models import Job
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService
from transcription.services.jobs import JobStatus
class TestJobService:
class TestBasicCRUD:
@pytest.mark.asyncio
async def test_create_job(self, job_service: JobService):
"""Test creating a job."""
def fake_job_factory():
return Job(document_id=uuid4())
await job_service.create_job(job=fake_job_factory())
async with job_service._session_scope() as session:
for _ in range(10):
await job_service.create_job(job=fake_job_factory(), session=session)
@pytest.mark.asyncio
async def test_backpropagation(self, job_service: JobService, document_service: DocumentService):
"""Test that creating a job backpropagates to the related document."""
doc_id = uuid4()
document = Document(
id=doc_id,
filename="test.txt",
file_path="/path/to/test.txt",
)
await document_service.create_document(document=document)
job = Job(document_id=doc_id)
await job_service.create_job(job=job)
read_job = await job_service.read_job(job_id=job.id)
assert isinstance(read_job.document, Document)
assert read_job.document.id == document.id
@pytest.mark.asyncio
async def test_reading_job(self, job_service: JobService):
"""Test reading a job."""
uuid = uuid4()
await job_service.create_job(job=Job(id=uuid, document_id=uuid4()))
job = await job_service.read_job(job_id=uuid)
assert job.id == uuid
@pytest.mark.asyncio
async def test_updating_job(self, job_service: JobService):
"""Test updating a job."""
uuid = uuid4()
job = Job(id=uuid, document_id=uuid4())
async with job_service._session_scope() as session:
await job_service.create_job(job=job, session=session)
job.status = JobStatus.PROCESSING
await job_service.update_job(job=job, session=session)
read_job = await job_service.read_job(job_id=uuid, session=session)
assert read_job == job
@pytest.mark.asyncio
async def test_deleting_job(self, job_service: JobService):
"""Test deleting a job."""
class TestServiceMethods:
@pytest.mark.asyncio
async def test_query_jobs(self, job_service: JobService):
"""Test querying jobs."""
await job_service.create_job(job=Job(document_id=uuid4(), status=JobStatus.PROCESSING))
result = await job_service.query_jobs(status=JobStatus.PROCESSING)
jobs = {str(job.id).split("-")[0]: job.status for job in result}
assert len(jobs) == 1
@pytest.mark.asyncio
async def test_list_jobs(self, job_service: JobService):
"""Test listing jobs."""
n = 5
for _ in range(n):
await job_service.create_job(job=Job(document_id=uuid4()))
jobs = await job_service.list_jobs()
assert len(jobs) == n
@pytest.mark.asyncio
async def test_mark_job_status(self, job_service: JobService):
"""Test marking a job with a new status."""
+98
View File
@@ -0,0 +1,98 @@
"""Tests for Step 3 library services (revisions, search, export)."""
import pytest
from sqlmodel import select
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.models import TranscriptRevision
from transcription.services.library import accept_revision
from transcription.services.library import add_revision
from transcription.services.library import export_transcripts
from transcription.services.library import list_revisions
from transcription.services.library import search_accepted_transcripts
def _create_job(session) -> Job:
document = Document(filename="letter.jpg", file_path="uploads/letter.jpg")
session.add(document)
session.commit()
session.refresh(document)
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED)
session.add(job)
session.commit()
session.refresh(job)
return job
@pytest.mark.integration
class TestRevisionHistoryBehavior:
"""Verify revision append/accept behavior."""
def test_add_revision_appends_incrementing_revision_numbers(self, session):
"""add_revision creates immutable incrementing revisions per job."""
job = _create_job(session)
r1 = add_revision(job_id=job.id, text="first", session=session)
r2 = add_revision(job_id=job.id, text="second", session=session)
revisions = list_revisions(job_id=job.id, session=session)
assert r1.revision_number == 1
assert r2.revision_number == 2
assert [revision.revision_number for revision in revisions] == [1, 2]
def test_accept_revision_marks_selected_revision_and_sets_job_completed(self, session):
"""accept_revision marks one revision accepted and transitions job to completed."""
job = _create_job(session)
r1 = add_revision(job_id=job.id, text="v1", session=session)
r2 = add_revision(job_id=job.id, text="v2", session=session)
accepted = accept_revision(revision_id=r2.id, session=session)
session.refresh(job)
all_revisions = list(session.exec(select(TranscriptRevision).where(TranscriptRevision.job_id == job.id)).all())
accepted_flags = {revision.id: revision.accepted for revision in all_revisions}
transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
assert accepted.id == r2.id
assert accepted_flags[r1.id] is False
assert accepted_flags[r2.id] is True
assert job.status == JobStatus.COMPLETED
assert transcript is not None
assert transcript.text == "v2"
@pytest.mark.integration
class TestSearchAndExportBehavior:
"""Verify accepted-only search and export semantics."""
def test_search_returns_only_accepted_revisions(self, session):
"""search_accepted_transcripts filters out non-accepted revisions."""
job = _create_job(session)
draft = add_revision(job_id=job.id, text="family archive draft", session=session)
accepted = add_revision(job_id=job.id, text="family archive final", accepted=True, session=session)
results = search_accepted_transcripts(query="archive", session=session)
assert results
result_ids = {result.id for result in results}
assert accepted.id in result_ids
assert draft.id not in result_ids
def test_export_returns_serializable_records_for_accepted_revisions(self, session):
"""export_transcripts returns expected fields for accepted-only export."""
job = _create_job(session)
accepted = add_revision(job_id=job.id, text="export me", accepted=True, session=session)
records = export_transcripts(accepted_only=True, session=session)
assert len(records) == 1
record = records[0]
assert record["job_id"] == str(job.id)
assert record["revision_id"] == str(accepted.id)
assert record["accepted"] is True
assert record["text"] == "export me"
-36
View File
@@ -1,36 +0,0 @@
import pytest
class TestServiceBase:
class TestInitialization:
def test_initializes_with_defaults(self):
"""Test initialization with default session factory and queue."""
def test_initializes_with_custom_session_factory(self):
"""Test initialization with a provided session factory."""
def test_initializes_with_custom_queue(self):
"""Test initialization with a provided queue."""
class TestSessionScope:
@pytest.mark.asyncio
async def test_uses_provided_session(self):
"""Test that session scope reuses a provided session."""
@pytest.mark.asyncio
async def test_creates_new_session_when_none_provided(self):
"""Test that session scope creates a new session when none is provided."""
class TestContextManagerBehavior:
@pytest.mark.asyncio
async def test_yields_session(self):
"""Test that session scope yields a usable session object."""
@pytest.mark.asyncio
async def test_multiple_operations(self):
"""Test multiple operations within a single session scope."""
class TestEdgeCases:
@pytest.mark.asyncio
async def test_handles_exception_propagation(self):
"""Test exception propagation behavior inside session scope."""
+136
View File
@@ -0,0 +1,136 @@
"""Tests for transcription.services.transcription."""
from pathlib import Path
import pytest
from transcription.config import Settings
from transcription.providers.base import ProviderError
from transcription.providers.base import TranscriptionResult
from transcription.services.transcription import PromptLoadError
from transcription.services.transcription import TranscriptionError
from transcription.services.transcription import load_image_payload
from transcription.services.transcription import load_prompt_text
from transcription.services.transcription import transcribe_document_image
class _FakeProvider:
def __init__(self, *, result: TranscriptionResult | None = None, error: Exception | None = None):
self._result = result or TranscriptionResult(
text="Transcript output",
provider="openrouter",
model="test-model",
)
self._error = error
self.calls: list[dict[str, object]] = []
def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
self.calls.append(
{
"prompt_text": prompt_text,
"image_bytes": image_bytes,
"mime_type": mime_type,
}
)
if self._error:
raise self._error
return self._result
@pytest.mark.unit
class TestPromptLoading:
"""Verify prompt artifact loading and validation."""
def test_loads_prompt_text_from_prompt_dir(self, tmp_path: Path):
"""Prompt loader returns canonical prompt text from configured prompt directory."""
prompt_dir = tmp_path / "prompts"
prompt_dir.mkdir()
prompt_file = prompt_dir / "transcribe_document.md"
prompt_file.write_text("Prompt body", encoding="utf-8")
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
text = load_prompt_text(settings=settings)
assert text == "Prompt body"
def test_missing_prompt_raises_error(self, tmp_path: Path):
"""Prompt loader raises PromptLoadError when the file is missing."""
prompt_dir = tmp_path / "prompts"
prompt_dir.mkdir()
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
with pytest.raises(PromptLoadError) as exc_info:
load_prompt_text(settings=settings)
assert exc_info.value.category.value == "infrastructure_persistent_error"
assert "verify prompt_dir" in exc_info.value.suggestion.lower()
@pytest.mark.unit
class TestImageLoading:
"""Verify local image payload loading and mime detection."""
def test_load_image_payload_reads_bytes_and_mime_type(self, tmp_path: Path):
"""Image loader returns file bytes and a detected MIME type for supported files."""
image_path = tmp_path / "sample.png"
image_bytes = b"\x89PNG\r\n\x1a\n"
image_path.write_bytes(image_bytes)
loaded_bytes, mime_type = load_image_payload(image_path)
assert loaded_bytes == image_bytes
assert mime_type == "image/png"
def test_missing_image_raises_error(self, tmp_path: Path):
"""Image loader raises TranscriptionError when image file does not exist."""
missing = tmp_path / "missing.png"
with pytest.raises(TranscriptionError) as exc_info:
load_image_payload(missing)
assert exc_info.value.category.value == "not_found_error"
assert "verify" in exc_info.value.suggestion.lower()
@pytest.mark.unit
class TestTranscriptionService:
"""Verify service orchestration across prompt, image, and provider calls."""
def test_transcribe_document_image_calls_provider_once(self, tmp_path: Path):
"""Service loads prompt and image, then invokes provider exactly once."""
prompt_dir = tmp_path / "prompts"
prompt_dir.mkdir()
(prompt_dir / "transcribe_document.md").write_text("Prompt body", encoding="utf-8")
image_path = tmp_path / "document.jpg"
image_path.write_bytes(b"jpeg-bytes")
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
provider = _FakeProvider()
result = transcribe_document_image(image_path, settings=settings, provider=provider)
assert result.text == "Transcript output"
assert len(provider.calls) == 1
assert provider.calls[0]["prompt_text"] == "Prompt body"
assert provider.calls[0]["image_bytes"] == b"jpeg-bytes"
assert provider.calls[0]["mime_type"] == "image/jpeg"
def test_provider_error_is_wrapped(self, tmp_path: Path):
"""Service wraps provider failures in TranscriptionError."""
prompt_dir = tmp_path / "prompts"
prompt_dir.mkdir()
(prompt_dir / "transcribe_document.md").write_text("Prompt body", encoding="utf-8")
image_path = tmp_path / "document.png"
image_path.write_bytes(b"png-bytes")
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
provider = _FakeProvider(error=ProviderError("upstream failure"))
with pytest.raises(TranscriptionError) as exc_info:
transcribe_document_image(image_path, settings=settings, provider=provider)
assert exc_info.value.category.value == "external_provider_error"
assert exc_info.value.retriable is True
assert "retry" in exc_info.value.suggestion.lower()
@@ -7,7 +7,6 @@ import pytest
from transcription.services.transcription import transcribe_document_image from transcription.services.transcription import transcribe_document_image
HAS_OPENROUTER_KEY = bool(os.getenv("OPENROUTER_API_KEY")) HAS_OPENROUTER_KEY = bool(os.getenv("OPENROUTER_API_KEY"))
REAL_IMAGES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "real" REAL_IMAGES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "real"
+125
View File
@@ -0,0 +1,125 @@
"""Tests for transcription.services.upload."""
from pathlib import Path
import pytest
from transcription.config import Settings
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.services.upload import UploadError
from transcription.services.upload import create_upload_job
@pytest.mark.unit
class TestUploadValidation:
"""Verify upload validation behavior."""
def test_rejects_empty_bytes(self, session, tmp_path: Path):
"""create_upload_job rejects an empty upload payload."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
with pytest.raises(UploadError) as exc_info:
create_upload_job(
filename="letter.jpg",
file_bytes=b"",
session=session,
settings=settings,
)
assert exc_info.value.category.value == "validation_error"
assert "non-empty" in exc_info.value.suggestion.lower()
def test_rejects_unsupported_extension(self, session, tmp_path: Path):
"""create_upload_job rejects unsupported filename extensions."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
with pytest.raises(UploadError) as exc_info:
create_upload_job(
filename="notes.txt",
file_bytes=b"content",
session=session,
settings=settings,
)
assert exc_info.value.category.value == "user_input_error"
assert "jpg" in exc_info.value.suggestion.lower()
def test_rejects_payload_exceeding_max_upload_bytes(self, session, tmp_path: Path):
"""create_upload_job rejects payloads above configured size limit."""
settings = Settings(
openrouter_api_key="test-key",
upload_dir=tmp_path,
max_upload_bytes=3,
)
with pytest.raises(UploadError) as exc_info:
create_upload_job(
filename="scan.jpg",
file_bytes=b"1234",
session=session,
settings=settings,
)
assert exc_info.value.category.value == "user_input_error"
assert "smaller file" in exc_info.value.suggestion.lower()
@pytest.mark.integration
class TestUploadPersistence:
"""Verify upload file and record persistence behavior."""
def test_writes_file_and_creates_records(self, session, tmp_path: Path):
"""create_upload_job writes file and creates document/job records."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
result = create_upload_job(
filename="letter.jpg",
file_bytes=b"image-bytes",
session=session,
settings=settings,
)
assert result.stored_path.exists()
assert result.stored_path.read_bytes() == b"image-bytes"
document = session.get(Document, result.document_id)
job = session.get(Job, result.job_id)
assert document is not None
assert job is not None
assert document.filename == "letter.jpg"
assert document.file_path == str(result.stored_path)
def test_uses_unique_stored_filename(self, session, tmp_path: Path):
"""create_upload_job stores uploads with unique filenames."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
first = create_upload_job(
filename="duplicate.jpg",
file_bytes=b"first",
session=session,
settings=settings,
)
second = create_upload_job(
filename="duplicate.jpg",
file_bytes=b"second",
session=session,
settings=settings,
)
assert first.stored_path != second.stored_path
assert first.stored_path.exists()
assert second.stored_path.exists()
def test_sets_job_status_queued(self, session, tmp_path: Path):
"""create_upload_job persists a job with queued status."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
result = create_upload_job(
filename="queued.pdf",
file_bytes=b"%PDF-1.4",
session=session,
settings=settings,
)
job = session.get(Job, result.job_id)
assert job is not None
assert job.status == JobStatus.QUEUED
+247
View File
@@ -0,0 +1,247 @@
"""Tests for transcription.worker."""
from pathlib import Path
from threading import Event
import pytest
from sqlmodel import select
from transcription.config import Settings
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.models import TranscriptRevision
from transcription.providers.base import TranscriptionResult
from transcription.worker import process_next_queued_job
from transcription.worker import run_worker_loop
def _create_queued_job(session, *, filename: str = "doc.jpg", file_path: str = "uploads/doc.jpg") -> Job:
document = Document(filename=filename, file_path=file_path)
session.add(document)
session.commit()
session.refresh(document)
job = Job(document_id=document.id, status=JobStatus.QUEUED)
session.add(job)
session.commit()
session.refresh(job)
return job
@pytest.mark.integration
class TestWorkerQueueBehavior:
"""Verify worker behavior when selecting queued jobs."""
def test_returns_false_when_queue_empty(self, session):
"""process_next_queued_job returns False when there are no queued jobs."""
processed = process_next_queued_job(session=session)
assert processed is False
@pytest.mark.integration
class TestWorkerSuccessPath:
"""Verify worker success-path lifecycle transitions and transcript persistence."""
def test_transitions_processing_to_transcribed(self, session, monkeypatch, tmp_path: Path):
"""process_next_queued_job transitions queued jobs to transcribed on success."""
job = _create_queued_job(session)
def _fake_transcribe(_path):
return TranscriptionResult(text="ok", provider="openrouter", model="test-model")
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
monkeypatch.setattr(
"transcription.worker.get_settings",
lambda: Settings(openrouter_api_key="test-key", prompt_dir=tmp_path),
)
processed = process_next_queued_job(session=session)
session.refresh(job)
assert processed is True
assert job.status == JobStatus.TRANSCRIBED
def test_persists_transcript_text_on_success(self, session, monkeypatch, tmp_path: Path):
"""process_next_queued_job stores transcript text for successful jobs."""
job = _create_queued_job(session)
def _fake_transcribe(_path):
return TranscriptionResult(text="Transcript body", provider="openrouter", model="test-model")
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
monkeypatch.setattr(
"transcription.worker.get_settings",
lambda: Settings(openrouter_api_key="test-key", prompt_dir=tmp_path),
)
process_next_queued_job(session=session)
transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
revision = session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.job_id == job.id)
.order_by(TranscriptRevision.revision_number)
).first()
assert transcript is not None
assert transcript.text == "Transcript body"
assert transcript.error_detail is None
assert revision is not None
assert revision.revision_number == 1
assert revision.text == "Transcript body"
assert revision.source == "worker"
assert revision.accepted is False
@pytest.mark.integration
class TestWorkerFailurePath:
"""Verify worker failure-path lifecycle transitions and error persistence."""
def test_sets_failed_and_error_detail_on_failure(self, session, monkeypatch, tmp_path: Path):
"""process_next_queued_job marks failed and stores error detail on exception."""
job = _create_queued_job(session)
def _fake_transcribe(_path):
raise RuntimeError("provider failure")
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
monkeypatch.setattr(
"transcription.worker.get_settings",
lambda: Settings(openrouter_api_key="test-key", prompt_dir=tmp_path),
)
processed = process_next_queued_job(session=session)
session.refresh(job)
transcript = session.exec(
select(Transcript).where(Transcript.job_id == job.id)
).first()
assert processed is True
assert job.status == JobStatus.FAILED
assert transcript is not None
assert transcript.text is None
assert "provider failure" not in transcript.error_detail
assert "[internal_unexpected_error]" in transcript.error_detail
assert "error_id=" in transcript.error_detail
assert "suggestion=" in transcript.error_detail
def test_updates_existing_transcript_if_present(self, session, monkeypatch, tmp_path: Path):
"""process_next_queued_job updates existing transcript instead of duplicating."""
job = _create_queued_job(session)
existing = Transcript(job_id=job.id, text="old", error_detail=None)
session.add(existing)
session.commit()
session.refresh(existing)
def _fake_transcribe(_path):
raise RuntimeError("provider failure")
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
monkeypatch.setattr(
"transcription.worker.get_settings",
lambda: Settings(openrouter_api_key="test-key", prompt_dir=tmp_path),
)
process_next_queued_job(session=session)
transcripts = session.exec(
select(Transcript).where(Transcript.job_id == job.id)
).all()
assert len(transcripts) == 1
assert transcripts[0].id == existing.id
assert transcripts[0].text is None
assert "provider failure" not in transcripts[0].error_detail
assert "[internal_unexpected_error]" in transcripts[0].error_detail
assert "error_id=" in transcripts[0].error_detail
@pytest.mark.integration
class TestWorkerRetryBehavior:
"""Verify worker retry and terminal failure policies."""
def test_retriable_failure_requeues_until_limit(self, session, monkeypatch, tmp_path: Path):
"""Retriable failures requeue jobs while retry budget remains."""
job = _create_queued_job(session)
def _fake_transcribe(_path):
raise AppError(
"temporary upstream outage",
category=ErrorCategory.EXTERNAL_PROVIDER,
suggestion="Retry from jobs page.",
retriable=True,
)
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
monkeypatch.setattr(
"transcription.worker.get_settings",
lambda: Settings(
openrouter_api_key="test-key",
prompt_dir=tmp_path,
worker_max_retries=1,
worker_retry_backoff_seconds=0.0,
),
)
processed = process_next_queued_job(session=session)
session.refresh(job)
assert processed is True
assert job.status == JobStatus.QUEUED
assert job.retry_count == 1
def test_retriable_failure_exhaustion_sets_failed(self, session, monkeypatch, tmp_path: Path):
"""Retriable failures transition to failed when retry budget is exhausted."""
job = _create_queued_job(session)
job.retry_count = 1
session.add(job)
session.commit()
def _fake_transcribe(_path):
raise AppError(
"temporary upstream outage",
category=ErrorCategory.EXTERNAL_PROVIDER,
suggestion="Retry from jobs page.",
retriable=True,
)
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
monkeypatch.setattr(
"transcription.worker.get_settings",
lambda: Settings(
openrouter_api_key="test-key",
prompt_dir=tmp_path,
worker_max_retries=1,
worker_retry_backoff_seconds=0.0,
),
)
process_next_queued_job(session=session)
session.refresh(job)
assert job.status == JobStatus.FAILED
assert job.retry_count == 1
@pytest.mark.unit
class TestWorkerLoopControl:
"""Verify worker loop start/stop behavior."""
def test_stops_when_stop_event_is_set(self, monkeypatch):
"""run_worker_loop exits when a stop event is set."""
stop_event = Event()
stop_event.set()
called = {"value": False}
def _fake_process_next_queued_job(**_kwargs):
called["value"] = True
return False
monkeypatch.setattr("transcription.worker.process_next_queued_job", _fake_process_next_queued_job)
run_worker_loop(stop_event=stop_event, poll_interval_seconds=0.01)
assert called["value"] is False
+12
View File
@@ -26,6 +26,8 @@ class TestAppLifespan:
calls = [] calls = []
monkeypatch.setattr("transcription.app.setup_logging", lambda: calls.append("logging")) monkeypatch.setattr("transcription.app.setup_logging", lambda: calls.append("logging"))
monkeypatch.setattr("transcription.app.get_settings", lambda: object())
monkeypatch.setattr("transcription.app.enforce_request_access", lambda **_kwargs: None)
monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: calls.append("schema")) monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: calls.append("schema"))
monkeypatch.setattr( monkeypatch.setattr(
"transcription.app.initialize_database_runtime", "transcription.app.initialize_database_runtime",
@@ -35,6 +37,8 @@ class TestAppLifespan:
monkeypatch.setattr("transcription.app.should_bootstrap_schema", lambda _settings: True) monkeypatch.setattr("transcription.app.should_bootstrap_schema", lambda _settings: True)
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker")) monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker")) monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
monkeypatch.setattr("transcription.app.apply_pending_migrations", lambda **_kwargs: calls.append("migrate"))
monkeypatch.setattr("transcription.app.validate_schema_compatibility", lambda **_kwargs: [])
class _Dir: class _Dir:
def mkdir(self, parents: bool, exist_ok: bool): def mkdir(self, parents: bool, exist_ok: bool):
@@ -43,6 +47,8 @@ class TestAppLifespan:
class _Settings: class _Settings:
upload_dir = _Dir() upload_dir = _Dir()
prompt_dir = _Dir() prompt_dir = _Dir()
migration_auto_apply_on_startup = False
validate_schema_on_startup = True
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings()) monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
@@ -61,6 +67,8 @@ class TestAppLifespan:
calls = [] calls = []
monkeypatch.setattr("transcription.app.setup_logging", lambda: None) monkeypatch.setattr("transcription.app.setup_logging", lambda: None)
monkeypatch.setattr("transcription.app.get_settings", lambda: object())
monkeypatch.setattr("transcription.app.enforce_request_access", lambda **_kwargs: None)
monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: None) monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: None)
monkeypatch.setattr( monkeypatch.setattr(
"transcription.app.initialize_database_runtime", "transcription.app.initialize_database_runtime",
@@ -70,6 +78,8 @@ class TestAppLifespan:
monkeypatch.setattr("transcription.app.should_bootstrap_schema", lambda _settings: True) monkeypatch.setattr("transcription.app.should_bootstrap_schema", lambda _settings: True)
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker")) monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker")) monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
monkeypatch.setattr("transcription.app.apply_pending_migrations", lambda **_kwargs: calls.append("migrate"))
monkeypatch.setattr("transcription.app.validate_schema_compatibility", lambda **_kwargs: [])
class _Dir: class _Dir:
def mkdir(self, parents: bool, exist_ok: bool): def mkdir(self, parents: bool, exist_ok: bool):
@@ -78,6 +88,8 @@ class TestAppLifespan:
class _Settings: class _Settings:
upload_dir = _Dir() upload_dir = _Dir()
prompt_dir = _Dir() prompt_dir = _Dir()
migration_auto_apply_on_startup = False
validate_schema_on_startup = True
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings()) monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
+29 -1
View File
@@ -5,7 +5,8 @@ from pathlib import Path
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from transcription.config import Provider, Settings from transcription.config import Provider
from transcription.config import Settings
def _make_settings(**overrides) -> Settings: def _make_settings(**overrides) -> Settings:
@@ -63,6 +64,33 @@ class TestPathSettings:
assert isinstance(settings.prompt_dir, Path) assert isinstance(settings.prompt_dir, Path)
class TestMigrationSafetySettings:
"""Verify migration safety settings defaults."""
def test_migration_safety_defaults(self):
"""Migration auto-apply is off and startup schema validation is on by default."""
settings = _make_settings()
assert settings.migration_auto_apply_on_startup is False
assert settings.validate_schema_on_startup is True
class TestSecuritySettings:
"""Verify Step 5 security-related settings behavior."""
def test_security_defaults(self):
"""Security controls default to disabled auth and bounded upload size."""
settings = _make_settings()
assert settings.max_upload_bytes == 15 * 1024 * 1024
assert settings.operator_access_enabled is False
assert settings.operator_username == "operator"
assert settings.operator_password is None
def test_operator_password_required_when_access_enabled(self):
"""Enabling operator access requires OPERATOR_PASSWORD."""
with pytest.raises(ValidationError):
_make_settings(operator_access_enabled=True, operator_password=None)
class TestWorkerReliabilitySettings: class TestWorkerReliabilitySettings:
"""Verify worker retry settings defaults.""" """Verify worker retry settings defaults."""
+22 -5
View File
@@ -1,7 +1,10 @@
"""Tests for transcription.db — schema bootstrap and session factory.""" """Tests for transcription.db — schema bootstrap and session factory."""
from sqlalchemy import inspect, text from sqlalchemy import inspect
from sqlmodel import Session, SQLModel, create_engine from sqlalchemy import text
from sqlmodel import Session
from sqlmodel import SQLModel
from sqlmodel import create_engine
from sqlmodel.pool import StaticPool from sqlmodel.pool import StaticPool
@@ -18,12 +21,14 @@ class TestSchemaBootstrap:
"""Verify create_all produces the expected table set.""" """Verify create_all produces the expected table set."""
def test_create_all_creates_expected_tables(self): def test_create_all_creates_expected_tables(self):
"""After create_all(), document, job, and transcript tables exist.""" """After create_all(), core V1 tables exist."""
engine = _in_memory_engine() engine = _in_memory_engine()
# Ensure models are imported so metadata is populated # Ensure models are imported so metadata is populated
from transcription.models import Document, Job, Transcript # noqa: F401
import transcription.db as db_module import transcription.db as db_module
from transcription.models import Document # noqa: F401
from transcription.models import Job # noqa: F401
from transcription.models import Transcript # noqa: F401
from transcription.models import TranscriptRevision # noqa: F401
db_module.create_all(engine=engine) db_module.create_all(engine=engine)
@@ -32,6 +37,18 @@ class TestSchemaBootstrap:
assert "document" in table_names assert "document" in table_names
assert "job" in table_names assert "job" in table_names
assert "transcript" in table_names assert "transcript" in table_names
assert "transcriptrevision" in table_names
def test_validate_schema_compatibility_returns_no_issues_for_fresh_schema(self):
"""validate_schema_compatibility reports no issues on fresh schema."""
engine = _in_memory_engine()
import transcription.db as db_module
db_module.create_all(engine=engine)
issues = db_module.validate_schema_compatibility(engine=engine)
assert issues == []
class TestSessionFactory: class TestSessionFactory:
+5 -2
View File
@@ -2,7 +2,10 @@
import pytest import pytest
from transcription.errors import AppError, ErrorCategory, classify_unexpected_error, new_error_id from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.errors import classify_unexpected_error
from transcription.errors import new_error_id
@pytest.mark.unit @pytest.mark.unit
@@ -38,6 +41,6 @@ class TestAppErrorHelpers:
assert isinstance(err, AppError) assert isinstance(err, AppError)
assert err.category == ErrorCategory.INTERNAL_UNEXPECTED assert err.category == ErrorCategory.INTERNAL_UNEXPECTED
assert "unit.test" in err.message assert "unit.test" in err.message
assert "boom" in err.message assert "boom" not in err.message
assert err.suggestion assert err.suggestion
assert err.error_id assert err.error_id
+108
View File
@@ -0,0 +1,108 @@
"""Tests for transcription.migrations — explicit Step 4 migration safety behavior."""
from sqlalchemy import inspect
from sqlalchemy import text
from sqlmodel import create_engine
from sqlmodel.pool import StaticPool
from transcription.migrations import apply_pending_migrations
from transcription.migrations import list_pending_migrations
def _in_memory_engine():
"""Create isolated in-memory SQLite engine."""
return create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
class TestMigrations:
"""Verify migration listing and application behavior."""
def test_list_pending_returns_all_before_apply(self):
"""All known migrations are pending on a fresh legacy-shaped database."""
engine = _in_memory_engine()
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE job (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
)
pending = list_pending_migrations(engine=engine)
assert [migration.revision_id for migration in pending] == [
"0001_add_retry_count_to_job",
"0002_create_transcriptrevision_table",
]
def test_apply_pending_migrations_records_history_and_schema(self):
"""Applying pending migrations mutates schema and records revision history."""
engine = _in_memory_engine()
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE job (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
)
applied = apply_pending_migrations(engine=engine)
assert applied == [
"0001_add_retry_count_to_job",
"0002_create_transcriptrevision_table",
]
inspector = inspect(engine)
job_columns = {column["name"] for column in inspector.get_columns("job")}
assert "retry_count" in job_columns
assert "transcriptrevision" in set(inspector.get_table_names())
with engine.begin() as connection:
rows = connection.execute(
text("SELECT revision_id FROM schema_migration_history ORDER BY revision_id")
).fetchall()
assert [row[0] for row in rows] == applied
def test_apply_pending_migrations_is_idempotent(self):
"""Re-running apply_pending_migrations with no pending revisions is a no-op."""
engine = _in_memory_engine()
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE job (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
)
first_apply = apply_pending_migrations(engine=engine)
second_apply = apply_pending_migrations(engine=engine)
assert len(first_apply) == 2
assert second_apply == []
+44 -1
View File
@@ -5,7 +5,11 @@ from uuid import UUID
import pytest import pytest
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from transcription.models import Document, Job, JobStatus, Transcript from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.models import TranscriptRevision
def _make_document(**overrides) -> Document: def _make_document(**overrides) -> Document:
@@ -89,6 +93,12 @@ class TestJobModel:
session.refresh(job) session.refresh(job)
assert job.status == JobStatus.TRANSCRIBED assert job.status == JobStatus.TRANSCRIBED
job.status = JobStatus.COMPLETED
session.add(job)
session.commit()
session.refresh(job)
assert job.status == JobStatus.COMPLETED
def test_transitions_to_failed(self, session): def test_transitions_to_failed(self, session):
"""Status updates from processing to failed.""" """Status updates from processing to failed."""
doc = _persist_document(session) doc = _persist_document(session)
@@ -152,6 +162,27 @@ class TestTranscriptModel:
session.commit() session.commit()
class TestTranscriptRevisionModel:
"""Verify transcript revision persistence and defaults."""
def test_revision_defaults_and_persistence(self, session):
"""Revision records persist with revision metadata and defaults."""
doc = _persist_document(session)
job = _persist_job(session, doc)
revision = TranscriptRevision(job_id=job.id, revision_number=1, text="Rev text")
session.add(revision)
session.commit()
session.refresh(revision)
fetched = session.get(TranscriptRevision, revision.id)
assert fetched is not None
assert fetched.revision_number == 1
assert fetched.text == "Rev text"
assert fetched.source == "worker"
assert fetched.accepted is False
class TestRelationships: class TestRelationships:
"""Verify SQLModel relationship navigation between models.""" """Verify SQLModel relationship navigation between models."""
@@ -177,3 +208,15 @@ class TestRelationships:
assert job.transcript is not None assert job.transcript is not None
assert isinstance(job.transcript, Transcript) assert isinstance(job.transcript, Transcript)
assert job.transcript.text == "Transcribed text" assert job.transcript.text == "Transcribed text"
def test_job_exposes_revisions(self, session):
"""job.revisions returns revision history linked to the Job."""
doc = _persist_document(session)
job = _persist_job(session, doc)
session.add(TranscriptRevision(job_id=job.id, revision_number=1, text="v1"))
session.add(TranscriptRevision(job_id=job.id, revision_number=2, text="v2", accepted=True))
session.commit()
session.refresh(job)
assert len(job.revisions) == 2
assert all(isinstance(revision, TranscriptRevision) for revision in job.revisions)
-1
View File
@@ -2,7 +2,6 @@
from pathlib import Path from pathlib import Path
PROMPT_PATH = Path("prompts/transcribe_document.md") PROMPT_PATH = Path("prompts/transcribe_document.md")
+6
View File
@@ -19,14 +19,17 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
"REQ-3": [ "REQ-3": [
"tests/services/test_worker.py", "tests/services/test_worker.py",
"tests/ui/test_jobs_page.py", "tests/ui/test_jobs_page.py",
"tests/services/test_library.py",
], ],
"REQ-4": [ "REQ-4": [
"tests/services/test_worker.py", "tests/services/test_worker.py",
"tests/integration/test_pipeline_flow.py", "tests/integration/test_pipeline_flow.py",
"tests/services/test_library.py",
], ],
"REQ-5": [ "REQ-5": [
"tests/ui/test_jobs_page.py", "tests/ui/test_jobs_page.py",
"tests/ui/test_pages_registration.py", "tests/ui/test_pages_registration.py",
"tests/api/test_routes.py",
], ],
"REQ-6": [ "REQ-6": [
"tests/test_app.py", "tests/test_app.py",
@@ -36,6 +39,9 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
"tests/test_app.py", "tests/test_app.py",
"tests/test_config.py", "tests/test_config.py",
], ],
"REQ-11": [
"tests/services/test_library.py",
],
"REQ-12": [ "REQ-12": [
"tests/test_prompts.py", "tests/test_prompts.py",
"tests/services/test_transcription.py", "tests/services/test_transcription.py",
+5 -2
View File
@@ -4,8 +4,11 @@ from uuid import uuid4
import pytest import pytest
from transcription.models import Document, Job, Transcript from transcription.models import Document
from transcription.ui.jobs_page import fetch_job_detail, fetch_jobs from transcription.models import Job
from transcription.models import Transcript
from transcription.ui.jobs_page import fetch_job_detail
from transcription.ui.jobs_page import fetch_jobs
@pytest.mark.integration @pytest.mark.integration
+1 -1
View File
@@ -1,8 +1,8 @@
"""Tests for UI page registration wiring.""" """Tests for UI page registration wiring."""
import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import pytest
from transcription.ui import register_pages from transcription.ui import register_pages
+2 -1
View File
@@ -5,7 +5,8 @@ from uuid import uuid4
import pytest import pytest
from transcription.services.upload import UploadError, UploadJobResult from transcription.services.upload import UploadError
from transcription.services.upload import UploadJobResult
from transcription.ui import upload_page from transcription.ui import upload_page
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Generated
+1056 -1854
View File
File diff suppressed because it is too large Load Diff