generated from john/python-template
Compare commits
11
Commits
3a141bd4cc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf5d7d8c7b | ||
|
|
939b0e46e9 | ||
|
|
15af11ecb5 | ||
|
|
6dc58a8d50 | ||
|
|
e90dbe4958 | ||
|
|
c9682b0399 | ||
|
|
dd80cd60cf | ||
|
|
754a273ead | ||
|
|
06bb4290be | ||
|
|
21478a904c | ||
|
|
b94d5d2863 |
@@ -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.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
description: Copilot rules for modifying the UI
|
||||
applyTo: 'src/transcription/ui/**/*.py'
|
||||
---
|
||||
|
||||
The UI is forbidden from directly touching the database. All interactions should be done using the [services](../../src/transcription/services/)
|
||||
Vendored
-27
@@ -1,27 +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",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"8080"
|
||||
],
|
||||
"justMyCode": true,
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/src"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-47
@@ -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"]
|
||||
@@ -34,19 +34,62 @@ Optional settings (defaults shown):
|
||||
DATABASE_URL=sqlite:///./transcription.db
|
||||
UPLOAD_DIR=./uploads
|
||||
PROMPT_DIR=./prompts
|
||||
MAX_UPLOAD_BYTES=15728640
|
||||
OPERATOR_ACCESS_ENABLED=false
|
||||
OPERATOR_USERNAME=operator
|
||||
# OPERATOR_PASSWORD=replace_with_secure_value
|
||||
```
|
||||
|
||||
|
||||
### 3) Run the app
|
||||
|
||||
```bash
|
||||
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)
|
||||
- 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
|
||||
|
||||
- **Upload page** (`/ui`)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -6,7 +6,7 @@ This project is a production application for transcribing and preserving histori
|
||||
|
||||
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:
|
||||
|
||||
@@ -41,10 +41,8 @@ This operating model keeps deployment and maintenance simple while preserving cl
|
||||
|
||||
## Documentation Map
|
||||
|
||||
- Architecture and technical design: [architecture.md](architecture.md)
|
||||
- Version 1 implementation plan: [ver1/ver1.md](ver1/ver1.md)
|
||||
- Version 1 Step 1 plan: [ver1/ver1-step1.md](ver1/ver1-step1.md)
|
||||
- Version 1 Step 1 results: [ver1/ver1-step1-results.md](ver1/ver1-step1-results.md)
|
||||
- Architecture and technical design: [architecture.md](architecture.md)
|
||||
- Architecture decision records (ADR index): [adr/README.md](adr/README.md)
|
||||
- Runtime and deployment requirements: [requirements.md](requirements.md)
|
||||
- Error handling policy and operational guidance: [error_handling.md](error_handling.md)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 1–3 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
|
||||
@@ -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.
|
||||
@@ -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 1–4 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
|
||||
@@ -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 don’t 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
@@ -1,40 +1,41 @@
|
||||
# Version 1 Implementation Plan
|
||||
|
||||
This plan defines the path from MVP to **Version 1 complete**.
|
||||
The objective is to deliver the full scoped product with production readiness, while explicitly separating refinements/enhancements into a future document.
|
||||
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)
|
||||
|
||||
**Goal:** Keep execution focused on V1 completion, not optimization/perfection.
|
||||
**Goal:** Keep execution focused on V1 completion and avoid unnecessary process overhead.
|
||||
|
||||
### Implementation Steps
|
||||
1. Create and maintain a **V1 Traceability Matrix**:
|
||||
- Requirement ID
|
||||
- Current status (`done`, `partial`, `not started`)
|
||||
- Owner
|
||||
- Validation method
|
||||
2. Define V1 completion gates:
|
||||
- Functional complete
|
||||
- Operationally complete
|
||||
- Production-ready complete
|
||||
- Personal-deployment ready
|
||||
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
|
||||
- `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
|
||||
- Every in-scope requirement has explicit ownership and status.
|
||||
- Scope-change process is agreed and followed.
|
||||
- Every in-scope requirement has explicit status and validation evidence.
|
||||
- Scope-change discipline is followed consistently.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
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
|
||||
- Missing interfaces
|
||||
- Placeholder services/components
|
||||
3. Resolve high-risk architectural gaps first.
|
||||
4. Record key decisions and tradeoffs in ADRs.
|
||||
3. Resolve architecture gaps that threaten reliability, maintainability, or clear boundaries.
|
||||
4. Record material decisions and tradeoffs in ADRs.
|
||||
|
||||
### Deliverables
|
||||
- Updated architecture diagrams and boundaries
|
||||
- ADR entries for major decisions
|
||||
- ADR entries for material decisions
|
||||
|
||||
### Exit Criteria
|
||||
- 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
|
||||
|
||||
**Goal:** Ensure predictable, safe behavior under failure conditions.
|
||||
**Goal:** Ensure predictable, diagnosable behavior under expected failure conditions.
|
||||
|
||||
### 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:
|
||||
- User-facing errors
|
||||
- Internal/system errors
|
||||
- User-facing safe messages
|
||||
- Internal diagnostic detail
|
||||
- Retryable vs non-retryable failures
|
||||
3. Add resilience controls where needed:
|
||||
3. Implement practical resilience controls where needed:
|
||||
- Timeouts
|
||||
- Retries with backoff
|
||||
- Circuit breaking / fallback logic
|
||||
- Bounded retries with backoff
|
||||
- Explicit terminal failure states
|
||||
4. Add failure-path tests for critical workflows.
|
||||
|
||||
### Deliverables
|
||||
- Error code catalog/reference
|
||||
- Failure mode test coverage for critical paths
|
||||
- Error handling reference aligned with `docs/error_handling.md`
|
||||
- Failure-mode test coverage for critical paths
|
||||
|
||||
### Exit Criteria
|
||||
- Error behavior is consistent across major flows.
|
||||
- Known failure scenarios are tested and pass.
|
||||
- Failed jobs include actionable, traceable failure detail.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
1. Business-critical end-user flows
|
||||
2. Data integrity and consistency capabilities
|
||||
3. Admin/operational controls
|
||||
4. Lower-priority UX and quality-of-life items that are in V1 scope
|
||||
1. End-user core flows (upload → transcribe → review)
|
||||
2. Data integrity and persistence behavior
|
||||
3. Minimal operator controls needed for personal use
|
||||
4. In-scope UX quality improvements
|
||||
|
||||
### Implementation Steps
|
||||
For each requirement slice:
|
||||
1. Finalize contract/schema
|
||||
2. Implement domain logic
|
||||
3. Implement persistence/state changes
|
||||
4. Integrate API/UI
|
||||
5. Add automated tests
|
||||
6. Update docs
|
||||
1. Confirm contract/schema
|
||||
2. Implement service/domain logic
|
||||
3. Implement persistence/state transitions
|
||||
4. Integrate API/UI behavior
|
||||
5. Add or update automated tests
|
||||
6. Update relevant docs
|
||||
|
||||
### Deliverables
|
||||
- Requirement completion report with validation evidence
|
||||
- Requirement completion report with validation evidence linked to REQ IDs
|
||||
|
||||
### 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
|
||||
1. Validate schema against final V1 domain needs.
|
||||
2. Implement forward-safe migrations.
|
||||
3. Define rollback/mitigation plans for migration failures.
|
||||
4. Build and verify backfill scripts (if needed).
|
||||
5. Add migration rehearsal in staging with representative data.
|
||||
1. Validate schema against finalized V1 domain needs.
|
||||
2. Implement forward-safe migrations for expected upgrades.
|
||||
3. Define a simple rollback/mitigation path for migration failures.
|
||||
4. Add backfill scripts only where truly required.
|
||||
5. Rehearse migration + rollback locally using representative sample data.
|
||||
|
||||
### Deliverables
|
||||
- Migration runbook
|
||||
- Backfill verification checklist
|
||||
- Migration and rollback runbook
|
||||
- Backfill checklist (if applicable)
|
||||
|
||||
### Exit Criteria
|
||||
- Migration plan validated in staging.
|
||||
- No unresolved data-loss risk for V1 rollout.
|
||||
- Migration path is tested and documented.
|
||||
- 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
|
||||
1. Complete authn/authz coverage for all routes/actions.
|
||||
2. Enforce input validation and output sanitization.
|
||||
3. Verify secret management and credential rotation process.
|
||||
4. Add audit logging for sensitive operations.
|
||||
5. Run dependency/security scanning in CI and remediate findings.
|
||||
1. Enforce private-network deployment assumptions in docs and configuration.
|
||||
2. Ensure basic single-operator access control for UI/API actions.
|
||||
3. Enforce input validation and safe error output behavior.
|
||||
4. Keep secrets out of source control; document local secret handling.
|
||||
5. Run lightweight dependency/security scanning and resolve high-risk findings.
|
||||
|
||||
### Deliverables
|
||||
- Security checklist with status
|
||||
- Threat/risk update for V1 scope
|
||||
- Security assumptions checklist (private network, single operator)
|
||||
- Basic risk update for V1 scope
|
||||
|
||||
### Exit Criteria
|
||||
- No unresolved critical/high vulnerabilities for V1 launch.
|
||||
- Access control behavior verified by tests.
|
||||
- No unresolved critical vulnerabilities.
|
||||
- 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
|
||||
1. Standardize structured logging and correlation IDs.
|
||||
2. Add core metrics:
|
||||
- Latency
|
||||
- Throughput
|
||||
- Error rates
|
||||
- Resource saturation
|
||||
3. Add tracing for critical request/workflow paths.
|
||||
4. Define SLOs/SLIs and alert thresholds.
|
||||
5. Prepare incident response and rollback runbooks.
|
||||
1. Standardize structured logging across UI/API/service/worker boundaries.
|
||||
2. Ensure logged errors include category and error reference IDs per `error_handling.md`.
|
||||
3. Add lightweight health/startup checks.
|
||||
4. Document a concise operator runbook:
|
||||
- start/stop
|
||||
- log locations
|
||||
- common failure patterns and recovery steps
|
||||
5. Add minimal counters/timings only where they clearly improve diagnosis.
|
||||
|
||||
### Deliverables
|
||||
- Dashboards and alerts
|
||||
- Operations runbooks
|
||||
- Logging and error-traceability baseline
|
||||
- Operator runbook
|
||||
|
||||
### Exit Criteria
|
||||
- Team can detect, triage, and remediate incidents quickly.
|
||||
- Core production signals are available and reliable.
|
||||
- Operator can diagnose common failures using logs + runbook.
|
||||
- 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
|
||||
1. Expand unit and integration tests across V1 features.
|
||||
2. Add contract tests between key components/services.
|
||||
3. Add end-to-end tests for critical user journeys.
|
||||
4. Add non-functional tests where relevant:
|
||||
- Performance/load
|
||||
- Soak
|
||||
- Failure-injection scenarios
|
||||
5. Enforce CI quality gates (tests, lint, type checks, security scans).
|
||||
1. Expand unit and integration tests for all V1 requirement slices.
|
||||
2. Add end-to-end tests for critical journeys:
|
||||
- upload
|
||||
- process/transcribe
|
||||
- view result
|
||||
- failure visibility
|
||||
3. Add targeted contract tests where adapter boundaries are error-prone.
|
||||
4. Keep CI gates focused on high-value checks (tests, lint, type checks, dependency scan).
|
||||
|
||||
### Deliverables
|
||||
- Test matrix with ownership
|
||||
- CI gate definition and thresholds
|
||||
- V1 test matrix mapped to requirements and critical flows
|
||||
- CI quality-gate checklist
|
||||
|
||||
### Exit Criteria
|
||||
- Critical-path regressions are blocked automatically.
|
||||
- Test coverage and reliability thresholds meet V1 targets.
|
||||
- Critical-path regressions are automatically detected.
|
||||
- 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
|
||||
1. Define performance budgets per key flow.
|
||||
2. Benchmark current behavior in staging.
|
||||
3. Optimize bottlenecks (queries, caching, concurrency, etc.).
|
||||
4. Re-test after each optimization and compare against budget.
|
||||
5. Document known limits and safe operating bounds.
|
||||
1. Define practical performance expectations for key flows.
|
||||
2. Run representative tests using real document samples.
|
||||
3. Address obvious bottlenecks in queries, file handling, or worker concurrency.
|
||||
4. Document known limits and expected operating bounds.
|
||||
|
||||
### Deliverables
|
||||
- Performance benchmark report
|
||||
- Optimization log
|
||||
- Short performance validation note
|
||||
- Known-limits summary
|
||||
|
||||
### 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
|
||||
1. Harden CI/CD pipeline with clear promotion gates.
|
||||
2. Ensure config parity and consistency across environments.
|
||||
3. Define rollout strategy (phased/canary/limited release as applicable).
|
||||
4. Validate rollback procedures in staging.
|
||||
5. Produce release checklist and ownership model.
|
||||
1. Define a simple release checklist:
|
||||
- run tests
|
||||
- run one end-to-end transcription check
|
||||
- verify migration compatibility
|
||||
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
|
||||
- Release playbook
|
||||
- Environment readiness checklist
|
||||
- Release checklist
|
||||
- Environment and rollback guide
|
||||
|
||||
### Exit Criteria
|
||||
- Deployment and rollback are rehearsed and reliable.
|
||||
- Release process is executable without tribal knowledge.
|
||||
- Deployment/rollback is rehearsed and documented.
|
||||
- Operator can release safely without hidden steps.
|
||||
|
||||
---
|
||||
|
||||
@@ -252,7 +254,7 @@ For each requirement slice:
|
||||
- Index/navigation
|
||||
- Intent alignment summary
|
||||
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.
|
||||
|
||||
### Deliverables
|
||||
@@ -260,55 +262,55 @@ For each requirement slice:
|
||||
- V1 release notes
|
||||
|
||||
### 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
|
||||
1. Run full-system acceptance validation against the V1 traceability matrix.
|
||||
2. Conduct stakeholder UAT and capture sign-off.
|
||||
3. Execute production readiness review.
|
||||
4. Launch in controlled phases and monitor key signals.
|
||||
1. Run end-to-end acceptance validation against the V1 traceability matrix.
|
||||
2. Complete operator acceptance checks on representative real documents.
|
||||
3. Execute launch checklist (including backup, migration, and rollback readiness).
|
||||
4. Launch and monitor logs/status closely during initial use.
|
||||
|
||||
### Deliverables
|
||||
- UAT/PRR sign-off records
|
||||
- Launch checklist and monitoring plan
|
||||
- Acceptance validation record
|
||||
- Launch checklist completion record
|
||||
|
||||
### Exit Criteria
|
||||
- Stakeholder approval achieved.
|
||||
- Launch metrics are stable within defined thresholds.
|
||||
- V1 requirements are validated.
|
||||
- Initial launch behavior is stable and recoverable.
|
||||
|
||||
---
|
||||
|
||||
## 12) Post-Launch Stabilization (30–60 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
|
||||
1. Track incidents, defects, and user feedback.
|
||||
2. Prioritize stabilization fixes with short cycle times.
|
||||
3. Remove temporary flags/mitigations introduced during launch.
|
||||
4. Produce post-launch retrospective and handoff to standard roadmap cadence.
|
||||
1. Track defects and operational pain points observed after launch.
|
||||
2. Prioritize short-cycle stabilization fixes.
|
||||
3. Remove temporary launch-only workarounds when safe.
|
||||
4. Capture a brief retrospective and update the next-phase backlog.
|
||||
|
||||
### Deliverables
|
||||
- Stabilization report
|
||||
- Prioritized backlog update
|
||||
- Stabilization summary
|
||||
- Updated backlog for post-V1 enhancements
|
||||
|
||||
### Exit Criteria
|
||||
- Incident/error rates converge to steady-state targets.
|
||||
- V1 transitions from launch mode to normal operations.
|
||||
- Major launch issues are resolved.
|
||||
- System transitions to steady personal-use operation.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Execution Rhythm
|
||||
|
||||
- **Weekly:** Requirement closure + risk review
|
||||
- **Biweekly:** Release train with quality gates
|
||||
- **Milestone reviews:** After phases 2, 6, 9, and 11
|
||||
- **As needed (small batch releases):** Run release checklist and deploy
|
||||
- **Milestone check-ins:** After phases 2, 6, 9, and 11
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-12
@@ -12,29 +12,18 @@ description = "Historical document transcription system"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"aiosqlite>=0.21.0",
|
||||
"asyncpg>=0.31.0",
|
||||
"fastapi>=0.138.0",
|
||||
"nicegui==3.13.0",
|
||||
"openrouter>=0.7.0",
|
||||
"psycopg2-binary>=2.9.12",
|
||||
"pydantic>=2.13.4",
|
||||
"pydantic-settings>=2.9.1",
|
||||
"sqlmodel>=0.0.25",
|
||||
]
|
||||
|
||||
|
||||
[dependency-groups]
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.25",
|
||||
"httpx2>=2.5.0",
|
||||
"ipykernel>=7.3.0",
|
||||
"ipywidgets>=8.1.8",
|
||||
"pre-commit>=4.6.0",
|
||||
"rich>=15.0.0",
|
||||
"ruff>=0.15.20",
|
||||
"ty>=0.0.54",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
@@ -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"
|
||||
@@ -34,6 +34,12 @@ def _status_for(error: AppError) -> int:
|
||||
def register_error_handlers(app: FastAPI) -> None:
|
||||
"""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)
|
||||
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
||||
envelope = build_error_envelope(exc)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+54
-34
@@ -2,74 +2,94 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import AsyncExitStack
|
||||
from contextlib import asynccontextmanager
|
||||
from threading import Event
|
||||
from threading import Thread
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .api.errors import register_error_handlers
|
||||
from .api.health import router as health_router
|
||||
from .config import configure_logging
|
||||
from .config import get_settings
|
||||
from .db import cleanup_database
|
||||
from .db import create_all
|
||||
from .db import dispose_database_runtime
|
||||
from .db import initialize_database_runtime
|
||||
from .services import ServiceBundle
|
||||
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
|
||||
async def _lifespan(app: FastAPI):
|
||||
configure_logging()
|
||||
|
||||
settings = getattr(app.state, "settings", None) or get_settings()
|
||||
settings = get_settings()
|
||||
app.state.settings = settings
|
||||
app.state.services = ServiceBundle()
|
||||
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||
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:
|
||||
await create_all(engine=app.state.runtime.engine)
|
||||
await create_all(engine=runtime.engine)
|
||||
|
||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
stack.push_async_callback(dispose_database_runtime)
|
||||
stop_event, worker_notifier = await stack.enter_async_context(
|
||||
worker_consumer_lifespan(
|
||||
session_factory=app.state.runtime.session_factory,
|
||||
poll_interval_seconds=1.0,
|
||||
)
|
||||
)
|
||||
app.state.worker_stop_event = stop_event
|
||||
app.state.worker_notifier = worker_notifier
|
||||
_start_worker(app)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_stop_worker(app)
|
||||
await cleanup_database()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
||||
settings = get_settings()
|
||||
app.state.settings = settings
|
||||
app.mount(
|
||||
"/uploads",
|
||||
StaticFiles(directory=settings.upload_dir, check_dir=False),
|
||||
name="uploads",
|
||||
)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root_redirect() -> RedirectResponse:
|
||||
return RedirectResponse(url="/ui", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||
@app.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)
|
||||
|
||||
@app.get("/ui", include_in_schema=False)
|
||||
async def ui_redirect() -> RedirectResponse:
|
||||
return RedirectResponse(url="/ui/upload", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||
return await call_next(request)
|
||||
|
||||
register_error_handlers(app)
|
||||
register_pages(app)
|
||||
app.include_router(health_router)
|
||||
app.include_router(transcription_router)
|
||||
return app
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Helpers for accessing lifespan-owned application state resources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from transcription.db.runtime import DatabaseRuntime
|
||||
from transcription.db.runtime import get_session_factory
|
||||
from transcription.worker import WorkerNotifier
|
||||
from transcription.worker import resolve_worker_notifier
|
||||
|
||||
|
||||
def resolve_database_runtime(state: object) -> DatabaseRuntime | None:
|
||||
"""Return database runtime from app-like state objects when available."""
|
||||
runtime = getattr(state, "runtime", None)
|
||||
return runtime if isinstance(runtime, DatabaseRuntime) else None
|
||||
|
||||
|
||||
def require_database_runtime(state: object) -> DatabaseRuntime:
|
||||
"""Return database runtime or raise when app lifespan has not initialized it."""
|
||||
runtime = resolve_database_runtime(state)
|
||||
if runtime is None:
|
||||
raise RuntimeError("Database runtime is not initialized on application state")
|
||||
return runtime
|
||||
|
||||
|
||||
def resolve_session_factory(state: object) -> async_sessionmaker[AsyncSession]:
|
||||
"""Return DB session factory from state when available, otherwise shared runtime."""
|
||||
runtime = resolve_database_runtime(state)
|
||||
if runtime is not None:
|
||||
return runtime.session_factory
|
||||
return get_session_factory()
|
||||
|
||||
|
||||
def get_worker_notifier(app: FastAPI) -> WorkerNotifier:
|
||||
"""Return app worker notifier, or a no-op fallback when unavailable."""
|
||||
return resolve_worker_notifier(app.state)
|
||||
@@ -41,12 +41,21 @@ class Settings(BaseSettings):
|
||||
# --- persistence ---
|
||||
database_url: str = "sqlite:///./transcription.db"
|
||||
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 ---
|
||||
upload_dir: Path = Path("./uploads")
|
||||
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_max_retries: int = 0
|
||||
worker_retry_backoff_seconds: float = 0.0
|
||||
@@ -62,10 +71,10 @@ class Settings(BaseSettings):
|
||||
_settings: ContextVar[Settings | None] = ContextVar("settings", default=None)
|
||||
|
||||
|
||||
def get_settings(**kwargs) -> Settings:
|
||||
def get_settings() -> Settings:
|
||||
settings = _settings.get()
|
||||
if settings is None:
|
||||
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
settings = Settings() # pyright: ignore[reportCallIssue]
|
||||
_settings.set(settings)
|
||||
return settings
|
||||
|
||||
@@ -75,7 +84,7 @@ LOGGING_CONFIG: dict[str, object] = {
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"standard": {
|
||||
"format": "%(asctime)s %(levelname)-8s | %(message)s",
|
||||
"format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -17,7 +17,6 @@ class ErrorCategory(StrEnum):
|
||||
NOT_FOUND = "not_found_error"
|
||||
CONFLICT = "conflict_error"
|
||||
EXTERNAL_PROVIDER = "external_provider_error"
|
||||
PROCESSING = "processing_error"
|
||||
INFRA_TRANSIENT = "infrastructure_transient_error"
|
||||
INFRA_PERSISTENT = "infrastructure_persistent_error"
|
||||
INTERNAL_UNEXPECTED = "internal_unexpected_error"
|
||||
@@ -72,8 +71,9 @@ def build_error_envelope(error: AppError) -> ErrorEnvelope:
|
||||
|
||||
def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
|
||||
"""Normalize unknown exceptions into internal_unexpected_error."""
|
||||
_ = exc
|
||||
return AppError(
|
||||
f"Unexpected error during {operation}: {exc}",
|
||||
f"Unexpected error during {operation}",
|
||||
category=ErrorCategory.INTERNAL_UNEXPECTED,
|
||||
suggestion="Retry once. If it persists, review logs and report the error reference id.",
|
||||
retriable=False,
|
||||
@@ -82,4 +82,7 @@ def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
|
||||
|
||||
def format_error_detail(error: AppError) -> str:
|
||||
"""Return a compact persisted failure string for transcript.error_detail."""
|
||||
return f"[{error.category.value}] {error.message} | suggestion={error.suggestion} | error_id={error.error_id}"
|
||||
return (
|
||||
f"[{error.category.value}] {error.message} | "
|
||||
f"suggestion={error.suggestion} | error_id={error.error_id}"
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
+22
-25
@@ -1,16 +1,12 @@
|
||||
"""SQLModel domain models for the transcription system.
|
||||
|
||||
Three models capture the MVP lifecycle:
|
||||
Document -> one-to-many -> Job -> one-to-many -> Transcript
|
||||
"""
|
||||
"""SQLModel domain models for the transcription system."""
|
||||
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlmodel import Field
|
||||
from sqlmodel import Relationship
|
||||
from sqlmodel import SQLModel
|
||||
@@ -20,6 +16,7 @@ class JobStatus(StrEnum):
|
||||
QUEUED = "queued"
|
||||
PROCESSING = "processing"
|
||||
TRANSCRIBED = "transcribed"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@@ -47,33 +44,33 @@ class Job(SQLModel, table=True):
|
||||
|
||||
# --- relationships ---
|
||||
document: Document = Relationship(back_populates="jobs")
|
||||
transcripts: list["Transcript"] = Relationship(back_populates="job")
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
"""Return the filename of the associated document."""
|
||||
return self.document.filename if self.document else "unknown"
|
||||
transcript: Optional["Transcript"] = Relationship(back_populates="job")
|
||||
revisions: list["TranscriptRevision"] = Relationship(back_populates="job")
|
||||
|
||||
|
||||
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)
|
||||
job_id: UUID = Field(foreign_key="job.id")
|
||||
"""ID for the associated job."""
|
||||
revision: int = Field(default=0, ge=0)
|
||||
"""Revision number for this job's transcript history, starting at 0."""
|
||||
provider: str
|
||||
"""Name of the transcription provider used to generate this transcript."""
|
||||
prompt_name: str
|
||||
"""Name of the prompt used to generate this transcript."""
|
||||
job_id: UUID = Field(foreign_key="job.id", unique=True)
|
||||
text: str | None = None
|
||||
"""The transcribed text. This may be None if the job failed or is still in progress."""
|
||||
error_detail: str | None = None
|
||||
"""Details of any error that occurred during transcription."""
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
__table_args__ = (UniqueConstraint("job_id", "revision", name="uq_transcript_job_revision"),)
|
||||
# --- relationships ---
|
||||
job: Job = Relationship(back_populates="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="transcripts")
|
||||
job: Job = Relationship(back_populates="revisions")
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from ..models import Transcript
|
||||
|
||||
|
||||
class ProviderError(RuntimeError):
|
||||
@@ -25,22 +22,11 @@ class TranscriptionResult:
|
||||
|
||||
text: str
|
||||
provider: str
|
||||
prompt_name: 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):
|
||||
"""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."""
|
||||
...
|
||||
|
||||
@@ -6,10 +6,8 @@ import base64
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
|
||||
from openrouter import OpenRouter
|
||||
from openrouter.components.chatmessages import ChatMessagesTypedDict
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.config import get_settings
|
||||
@@ -46,12 +44,12 @@ class OpenRouterTranscriptionProvider:
|
||||
"""Return the resolved OpenRouter model slug."""
|
||||
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."""
|
||||
request = self._build_request(prompt_text=prompt_text, image_bytes=image_bytes, mime_type=mime_type)
|
||||
try:
|
||||
response = await self._client.chat.send_async(
|
||||
messages=cast(list[ChatMessagesTypedDict], request.messages),
|
||||
response = self._client.chat.send(
|
||||
messages=request.messages,
|
||||
model=request.model,
|
||||
http_referer=request.http_referer,
|
||||
x_open_router_title=request.x_open_router_title,
|
||||
@@ -65,7 +63,7 @@ class OpenRouterTranscriptionProvider:
|
||||
text = self._extract_text(response)
|
||||
model = self._get_optional_attr(response, "model") or self.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:
|
||||
image_b64 = base64.b64encode(image_bytes).decode("ascii")
|
||||
|
||||
@@ -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
|
||||
@@ -1,19 +1,25 @@
|
||||
"""Service layer exports."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from transcription.services.transcription import DEFAULT_PROMPT_FILE
|
||||
from transcription.services.transcription import PromptLoadError
|
||||
from transcription.services.transcription import TranscriptionError
|
||||
from transcription.services.transcription import load_image_payload
|
||||
from transcription.services.transcription import load_prompt_text
|
||||
from transcription.services.transcription import transcribe_document_image
|
||||
from transcription.services.upload import SUPPORTED_UPLOAD_EXTENSIONS
|
||||
from transcription.services.upload import UploadError
|
||||
from transcription.services.upload import UploadJobResult
|
||||
from transcription.services.upload import create_upload_job
|
||||
|
||||
from .documents import DocumentService
|
||||
from .jobs import JobService
|
||||
from .transcription import TranscriptionService
|
||||
|
||||
__all__ = ["DocumentService", "JobService", "ServiceBundle", "TranscriptionService"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ServiceBundle:
|
||||
"""Container for all service instances."""
|
||||
|
||||
documents: DocumentService = field(default_factory=DocumentService)
|
||||
jobs: JobService = field(default_factory=JobService)
|
||||
transcriptions: TranscriptionService = field(default_factory=TranscriptionService)
|
||||
__all__ = [
|
||||
"DEFAULT_PROMPT_FILE",
|
||||
"SUPPORTED_UPLOAD_EXTENSIONS",
|
||||
"PromptLoadError",
|
||||
"TranscriptionError",
|
||||
"UploadError",
|
||||
"UploadJobResult",
|
||||
"create_upload_job",
|
||||
"load_image_payload",
|
||||
"load_prompt_text",
|
||||
"transcribe_document_image",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -4,22 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
from collections.abc import Sequence
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.config import get_settings
|
||||
from transcription.errors import AppError
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.models import Transcript
|
||||
from transcription.providers import ProviderAuthError
|
||||
from transcription.providers import ProviderError
|
||||
from transcription.providers import ProviderResponseError
|
||||
@@ -27,8 +17,6 @@ from transcription.providers import TranscriptionProvider
|
||||
from transcription.providers import TranscriptionResult
|
||||
from transcription.providers import get_transcription_provider
|
||||
|
||||
from .base import ServiceBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PROMPT_FILE = "transcribe_document.md"
|
||||
@@ -43,169 +31,6 @@ class TranscriptionError(AppError):
|
||||
"""Raised when transcription execution fails."""
|
||||
|
||||
|
||||
class TranscriptionNotFoundError(TranscriptionError):
|
||||
"""Raised when a transcription is not found in the database."""
|
||||
|
||||
|
||||
class TranscriptionService(ServiceBase):
|
||||
"""Service class for managing transcription operations.
|
||||
|
||||
This is the top-level service that composes functionality from the other services."""
|
||||
|
||||
provider: TranscriptionProvider
|
||||
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession] | None = None):
|
||||
super().__init__(session_factory=session_factory)
|
||||
self.provider = get_transcription_provider(settings=self.settings)
|
||||
|
||||
async def create_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> Transcript:
|
||||
"""Create a new transcript in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
_session.add(transcript)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
|
||||
return transcript
|
||||
|
||||
async def read_transcript(self, transcript_id: UUID, *, session: AsyncSession | None = None) -> Transcript:
|
||||
"""Read an existing transcript from the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
transcript = await _session.get(
|
||||
Transcript,
|
||||
transcript_id,
|
||||
# Makes the full Job model object available in the return Transcript object
|
||||
options=(selectinload(Transcript.job),), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
if transcript is None:
|
||||
raise TranscriptionNotFoundError(
|
||||
f"Transcript with id {transcript_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the transcript id and retry.",
|
||||
)
|
||||
return transcript
|
||||
|
||||
async def update_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> Transcript:
|
||||
"""Update an existing transcript in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
merged = await _session.merge(transcript)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||
return merged
|
||||
|
||||
async def delete_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> None:
|
||||
"""Delete a transcript from the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
await _session.delete(transcript)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
async def transcribe_document(
|
||||
self,
|
||||
image_path: str | Path,
|
||||
job_id: UUID,
|
||||
*,
|
||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
||||
session: AsyncSession | None = None,
|
||||
):
|
||||
"""Transcribe a local image using the configured prompt and provider."""
|
||||
result = await transcribe_document_image(
|
||||
image_path=image_path,
|
||||
prompt_name=prompt_name,
|
||||
settings=self.settings,
|
||||
provider=self.provider,
|
||||
)
|
||||
await self.create_transcript_for_job(
|
||||
job_id=job_id,
|
||||
text=result.text,
|
||||
provider=result.provider,
|
||||
prompt_name=result.prompt_name,
|
||||
session=session,
|
||||
)
|
||||
|
||||
async def create_transcript_for_job(
|
||||
self,
|
||||
*,
|
||||
job_id: UUID,
|
||||
text: str | None,
|
||||
error_detail: str | None = None,
|
||||
provider: str | None = None,
|
||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Transcript:
|
||||
"""Create a new transcript revision for a job id."""
|
||||
async with self._session_scope(session) as _session:
|
||||
rev_query = select(func.max(Transcript.revision)).where(Transcript.job_id == job_id)
|
||||
rev_result = await _session.exec(rev_query)
|
||||
max_revision = -1 if (rev := rev_result.one_or_none()) is None else rev
|
||||
next_revision = max_revision + 1
|
||||
|
||||
transcript = Transcript(
|
||||
job_id=job_id,
|
||||
revision=next_revision,
|
||||
provider=provider or self.settings.provider.value,
|
||||
prompt_name=prompt_name,
|
||||
text=text,
|
||||
error_detail=error_detail,
|
||||
)
|
||||
_session.add(transcript)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
|
||||
return transcript
|
||||
|
||||
async def read_latest_transcript_by_job(
|
||||
self,
|
||||
job_id: UUID,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Transcript | None:
|
||||
"""Read the latest transcript revision for a job id."""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = _transcript_job_query(job_id=job_id).limit(1)
|
||||
result = await _session.exec(query)
|
||||
return result.one_or_none()
|
||||
|
||||
async def list_transcripts_by_job(
|
||||
self,
|
||||
job_id: UUID,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Sequence[Transcript]:
|
||||
"""List transcript revisions for a job id in ascending revision order."""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = _transcript_job_query(job_id=job_id)
|
||||
result = await _session.exec(query)
|
||||
return result.all()
|
||||
|
||||
|
||||
def _transcript_job_query(job_id: UUID):
|
||||
return (
|
||||
select(Transcript)
|
||||
.where(Transcript.job_id == job_id)
|
||||
.options(selectinload(Transcript.job)) # pyright: ignore[reportArgumentType]
|
||||
.order_by(Transcript.revision) # pyright: ignore[reportArgumentType]
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
async def transcribe_document_image(
|
||||
image_path: str | Path,
|
||||
*,
|
||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
||||
settings: Settings | None = None,
|
||||
provider: TranscriptionProvider | None = None,
|
||||
) -> TranscriptionResult:
|
||||
"""Transcribe a local image using the configured prompt and provider."""
|
||||
runtime_settings = settings or get_settings()
|
||||
prompt_text = load_prompt_text(prompt_name=prompt_name, settings=runtime_settings)
|
||||
image_bytes, mime_type = load_image_payload(image_path)
|
||||
|
||||
adapter = provider or get_transcription_provider(settings=runtime_settings)
|
||||
logger.info("Starting transcription for image=%s mime_type=%s", image_path, mime_type)
|
||||
|
||||
with handle_transcription_errors():
|
||||
result = await adapter.transcribe(
|
||||
prompt_text=prompt_text,
|
||||
image_bytes=image_bytes,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
|
||||
return result
|
||||
|
||||
|
||||
def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settings | None = None) -> str:
|
||||
"""Load and validate prompt text from PROMPT_DIR."""
|
||||
runtime_settings = settings or get_settings()
|
||||
@@ -262,11 +87,27 @@ def load_image_payload(image_path: str | Path) -> tuple[bytes, str]:
|
||||
return path.read_bytes(), mime_type
|
||||
|
||||
|
||||
@contextmanager
|
||||
def handle_transcription_errors():
|
||||
"""Context manager to handle transcription errors."""
|
||||
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)
|
||||
|
||||
try:
|
||||
yield
|
||||
result = adapter.transcribe(
|
||||
prompt_text=prompt_text,
|
||||
image_bytes=image_bytes,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
except ProviderAuthError as exc:
|
||||
raise TranscriptionError(
|
||||
"Provider authentication failed",
|
||||
@@ -287,3 +128,6 @@ def handle_transcription_errors():
|
||||
suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
|
||||
retriable=True,
|
||||
) from exc
|
||||
|
||||
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
|
||||
return result
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
"""Upload service for storing files and creating queued transcription jobs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.config import get_settings
|
||||
from transcription.db import get_session
|
||||
from transcription.errors import AppError
|
||||
from transcription.errors import ErrorCategory
|
||||
|
||||
from ..models import Document
|
||||
from ..models import Job
|
||||
from .documents import UploadJobResult
|
||||
from transcription.models import Document
|
||||
from transcription.models import Job
|
||||
from transcription.models import JobStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,26 +28,60 @@ class UploadError(AppError):
|
||||
"""Raised when uploaded content cannot be persisted safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UploadJobResult:
|
||||
"""Summary of created upload records."""
|
||||
|
||||
document_id: UUID
|
||||
job_id: UUID
|
||||
stored_path: Path
|
||||
original_filename: str
|
||||
|
||||
|
||||
async def create_upload_job(
|
||||
*,
|
||||
filename: str,
|
||||
file_bytes: bytes,
|
||||
session: AsyncSession,
|
||||
session: AsyncSession | None = None,
|
||||
settings: Settings | None = None,
|
||||
) -> UploadJobResult:
|
||||
"""Create upload-backed document and queued job records."""
|
||||
"""Persist an uploaded file and create document/job records."""
|
||||
runtime_settings = settings or get_settings()
|
||||
stored_path = store_file(
|
||||
_validate_upload(
|
||||
filename=filename,
|
||||
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:
|
||||
document, job = await _create_upload_records(
|
||||
session=session,
|
||||
original_filename=filename,
|
||||
stored_path=stored_path,
|
||||
)
|
||||
stored_path.write_bytes(file_bytes)
|
||||
except OSError as exc:
|
||||
raise UploadError(
|
||||
"Failed to persist upload file",
|
||||
category=ErrorCategory.INFRA_PERSISTENT,
|
||||
suggestion="Check upload directory permissions and available disk space, then retry.",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
if session is not None:
|
||||
document, job = await _create_upload_records(
|
||||
session=session,
|
||||
original_filename=filename,
|
||||
stored_path=stored_path,
|
||||
)
|
||||
else:
|
||||
async with get_session() as local_session:
|
||||
document, job = await _create_upload_records(
|
||||
session=local_session,
|
||||
original_filename=filename,
|
||||
stored_path=stored_path,
|
||||
)
|
||||
except Exception as exc:
|
||||
_best_effort_delete(stored_path)
|
||||
raise UploadError(
|
||||
@@ -62,60 +100,7 @@ async def create_upload_job(
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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:
|
||||
def _validate_upload(*, filename: str, file_bytes: bytes, max_upload_bytes: int) -> None:
|
||||
if not file_bytes:
|
||||
raise UploadError(
|
||||
"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.",
|
||||
)
|
||||
|
||||
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
|
||||
if not safe_name:
|
||||
raise UploadError(
|
||||
@@ -143,3 +135,35 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
|
||||
def _build_stored_filename(filename: str) -> str:
|
||||
safe_name = Path(filename).name
|
||||
return f"{uuid4()}_{safe_name}"
|
||||
|
||||
|
||||
async def _create_upload_records(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
original_filename: str,
|
||||
stored_path: Path,
|
||||
) -> tuple[Document, Job]:
|
||||
document = Document(
|
||||
filename=Path(original_filename).name,
|
||||
file_path=str(stored_path),
|
||||
)
|
||||
session.add(document)
|
||||
await session.flush()
|
||||
|
||||
job = Job(
|
||||
document_id=document.id,
|
||||
status=JobStatus.QUEUED,
|
||||
)
|
||||
session.add(job)
|
||||
await session.commit()
|
||||
await session.refresh(document)
|
||||
await session.refresh(job)
|
||||
return document, job
|
||||
|
||||
|
||||
def _best_effort_delete(path: Path) -> None:
|
||||
try:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to clean up upload file after DB error: %s", path)
|
||||
@@ -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.create_transcript_for_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.create_transcript_for_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.create_transcript_for_job(
|
||||
job_id=job.id,
|
||||
text=None,
|
||||
error_detail=format_error_detail(error),
|
||||
prompt_name=DEFAULT_PROMPT_FILE,
|
||||
session=local_session,
|
||||
)
|
||||
updated_job = await services.jobs.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.create_transcript_for_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.create_transcript_for_job(
|
||||
job_id=job.id,
|
||||
text=None,
|
||||
error_detail=format_error_detail(error),
|
||||
prompt_name=DEFAULT_PROMPT_FILE,
|
||||
session=local_session,
|
||||
)
|
||||
updated_job = await services.jobs.mark_job_status(
|
||||
job.id,
|
||||
JobStatus.FAILED,
|
||||
session=local_session,
|
||||
)
|
||||
await local_session.commit()
|
||||
return updated_job
|
||||
|
||||
await services.transcriptions.create_transcript_for_job(
|
||||
job_id=job.id,
|
||||
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
|
||||
@@ -1,45 +1,14 @@
|
||||
"""UI page registration exports."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from nicegui import app as nicegui_app
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
||||
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
||||
|
||||
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
|
||||
|
||||
_THEME_COLORS: dict[str, str] = {
|
||||
"primary": "#6f97e8",
|
||||
"secondary": "#92b5f5",
|
||||
"accent": "#7fc0de",
|
||||
"dark": "#22304a",
|
||||
"dark_page": "#1a2538",
|
||||
"positive": "#86c8ad",
|
||||
"negative": "#d98a9a",
|
||||
"info": "#7ebdda",
|
||||
"warning": "#e2c083",
|
||||
}
|
||||
|
||||
|
||||
def _register_global_styles(app: FastAPI) -> None:
|
||||
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
|
||||
return
|
||||
|
||||
nicegui_app.colors(**_THEME_COLORS)
|
||||
|
||||
css_path = Path(__file__).resolve().parent / "static" / "colors.css"
|
||||
if css_path.exists():
|
||||
ui.add_css(css_path, shared=True)
|
||||
|
||||
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
|
||||
|
||||
|
||||
def register_pages(app: FastAPI) -> None:
|
||||
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
||||
_register_global_styles(app)
|
||||
register_upload_page()
|
||||
register_jobs_page()
|
||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=True)
|
||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False)
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"""Reusable UI component exports."""
|
||||
|
||||
from transcription.ui.components.app_shell import NAV_ITEMS
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||
|
||||
__all__ = ["NAV_ITEMS", "render_document_panzoom", "render_navigation_header"]
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Reusable app shell primitives for page-level layout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||
("Upload", "/upload", "upload_file"),
|
||||
("Jobs", "/jobs", "work_history"),
|
||||
)
|
||||
|
||||
|
||||
def _is_active_path(*, current_path: str, item_path: str) -> bool:
|
||||
if item_path == "/jobs":
|
||||
return current_path == "/jobs" or current_path.startswith("/jobs/")
|
||||
return current_path == item_path
|
||||
|
||||
|
||||
def _button_props(*, icon: str, is_active: bool) -> str:
|
||||
if is_active:
|
||||
return f"icon={icon} no-caps unelevated color=primary text-color=white"
|
||||
return f"icon={icon} no-caps outline color=secondary text-color=secondary"
|
||||
|
||||
|
||||
def _button_classes(*, is_active: bool) -> str:
|
||||
base = "w-full sm:w-auto min-h-[40px] px-3 rounded-lg text-body2 text-weight-medium"
|
||||
if is_active:
|
||||
return f"{base}"
|
||||
return f"{base}"
|
||||
|
||||
|
||||
def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) -> None:
|
||||
is_active = _is_active_path(current_path=current_path, item_path=path)
|
||||
button = ui.button(
|
||||
label,
|
||||
icon=icon,
|
||||
on_click=lambda _=None, route=path: ui.navigate.to(route),
|
||||
)
|
||||
button.props(_button_props(icon=icon, is_active=is_active)).classes(_button_classes(is_active=is_active))
|
||||
|
||||
|
||||
def _normalize_path(current_path: str | None) -> str:
|
||||
normalized = (current_path or "").strip()
|
||||
if not normalized:
|
||||
return "/upload"
|
||||
return normalized.rstrip("/") or "/"
|
||||
|
||||
|
||||
def render_navigation_header(*, current_path: str | None = None) -> None:
|
||||
"""Render a shared app header with links for top-level pages."""
|
||||
normalized_path = _normalize_path(current_path)
|
||||
|
||||
with (
|
||||
ui.header(elevated=True).props("bordered").classes("bg-dark text-white q-px-sm q-py-xs"),
|
||||
ui.row().classes("w-full items-center justify-end q-gutter-xs"),
|
||||
ui.element("div").classes("w-full grid grid-cols-2 gap-2 sm:flex sm:justify-start sm:gap-2"),
|
||||
):
|
||||
for label, path, icon in NAV_ITEMS:
|
||||
_render_nav_button(label=label, path=path, icon=icon, current_path=normalized_path)
|
||||
@@ -1,235 +0,0 @@
|
||||
"""Panzoom-backed document preview component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from uuid import uuid4
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.config import get_settings
|
||||
from transcription.models import Document
|
||||
|
||||
PANGOZOOM_CDN_URL = "https://unpkg.com/@panzoom/[email protected]/dist/panzoom.min.js"
|
||||
UPLOADS_URL_PREFIX = "/uploads"
|
||||
|
||||
|
||||
def _document_url(document: Document) -> str:
|
||||
file_path = Path(document.file_path)
|
||||
upload_dir = get_settings().upload_dir
|
||||
|
||||
relative_path: Path
|
||||
try:
|
||||
relative_path = file_path.resolve().relative_to(upload_dir.resolve())
|
||||
except ValueError:
|
||||
parts = file_path.parts
|
||||
if "uploads" in parts:
|
||||
uploads_index = parts.index("uploads")
|
||||
relative_path = Path(*parts[uploads_index + 1 :])
|
||||
else:
|
||||
relative_path = Path(file_path.name)
|
||||
|
||||
encoded_relative_path = "/".join(quote(part) for part in relative_path.parts)
|
||||
return f"{UPLOADS_URL_PREFIX}/{encoded_relative_path}"
|
||||
|
||||
|
||||
def _document_kind(document: Document) -> str:
|
||||
suffix = Path(document.file_path).suffix.lower()
|
||||
if suffix == ".pdf":
|
||||
return "pdf"
|
||||
return "image"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _register_panzoom_assets() -> None:
|
||||
ui.add_head_html(
|
||||
f'<script src="{PANGOZOOM_CDN_URL}"></script>',
|
||||
shared=True,
|
||||
)
|
||||
ui.add_head_html(
|
||||
"""
|
||||
<style>
|
||||
.document-panzoom-host {
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.document-panzoom-surface {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.document-panzoom-media {
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: block;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.document-panzoom-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
pointer-events: none;
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
""",
|
||||
shared=True,
|
||||
)
|
||||
|
||||
|
||||
def _attach_panzoom(host_id: str) -> None:
|
||||
ui.run_javascript(
|
||||
f"""
|
||||
(function() {{
|
||||
if (!window.Panzoom) return;
|
||||
window.__transcriptionPanzoom = window.__transcriptionPanzoom || {{}};
|
||||
const host = document.getElementById({host_id!r});
|
||||
if (!host) return;
|
||||
const target = host.querySelector('[data-panzoom-target]');
|
||||
const media = host.querySelector('[data-panzoom-media]');
|
||||
if (!target) return;
|
||||
|
||||
const computeFitScale = () => {{
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
if (hostRect.width <= 0 || hostRect.height <= 0) return 1;
|
||||
|
||||
if (media && media.tagName === 'IMG') {{
|
||||
if (media.naturalWidth <= 0 || media.naturalHeight <= 0) return 1;
|
||||
return Math.min(
|
||||
hostRect.width / media.naturalWidth,
|
||||
hostRect.height / media.naturalHeight
|
||||
);
|
||||
}}
|
||||
|
||||
if (target.tagName === 'IFRAME') {{
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
if (targetRect.width <= 0 || targetRect.height <= 0) return 1;
|
||||
return hostRect.height / targetRect.height;
|
||||
}}
|
||||
|
||||
return 1;
|
||||
}};
|
||||
|
||||
const initPanzoom = () => {{
|
||||
if (window.__transcriptionPanzoom[{host_id!r}]) {{
|
||||
window.__transcriptionPanzoom[{host_id!r}].destroy();
|
||||
}}
|
||||
|
||||
if (host.__transcriptionWheelHandler) {{
|
||||
host.removeEventListener('wheel', host.__transcriptionWheelHandler);
|
||||
host.__transcriptionWheelHandler = null;
|
||||
}}
|
||||
|
||||
let fitScale = computeFitScale();
|
||||
|
||||
if (!Number.isFinite(fitScale) || fitScale <= 0) {{
|
||||
fitScale = 1;
|
||||
}}
|
||||
|
||||
const startScale = fitScale;
|
||||
const minScale = 0.01;
|
||||
const instance = Panzoom(target, {{
|
||||
maxScale: 32,
|
||||
minScale: minScale,
|
||||
startScale: startScale,
|
||||
step: 0.18,
|
||||
contain: 'inside',
|
||||
roundPixels: true,
|
||||
overflow: 'hidden',
|
||||
}});
|
||||
|
||||
window.__transcriptionPanzoom[{host_id!r}] = instance;
|
||||
|
||||
requestAnimationFrame(() => {{
|
||||
instance.reset({{ animate: false, force: true }});
|
||||
instance.setOptions({{ contain: undefined }});
|
||||
}});
|
||||
|
||||
const wheelHandler = (event) => instance.zoomWithWheel(event);
|
||||
host.__transcriptionWheelHandler = wheelHandler;
|
||||
host.addEventListener('wheel', wheelHandler, {{ passive: false }});
|
||||
}};
|
||||
|
||||
if (media && media.tagName === 'IMG' && !media.complete) {{
|
||||
media.addEventListener('load', initPanzoom, {{ once: true }});
|
||||
return;
|
||||
}}
|
||||
|
||||
if ((media && media.tagName === 'IMG' && (host.clientWidth <= 0 || host.clientHeight <= 0)) ||
|
||||
(target.tagName === 'IFRAME' && (host.clientWidth <= 0 || host.clientHeight <= 0))) {{
|
||||
requestAnimationFrame(initPanzoom);
|
||||
return;
|
||||
}}
|
||||
|
||||
initPanzoom();
|
||||
}})();
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def render_document_panzoom(*, document: Document, height: str = "640px") -> None:
|
||||
"""Render a document preview with pan and zoom interactions."""
|
||||
_register_panzoom_assets()
|
||||
|
||||
host_id = f"document-panzoom-{uuid4().hex}"
|
||||
document_url = _document_url(document)
|
||||
document_kind = _document_kind(document)
|
||||
|
||||
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
|
||||
with ui.row().classes("w-full items-center justify-between q-gutter-sm"):
|
||||
with ui.column().classes("q-gutter-none"):
|
||||
ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
|
||||
ui.label(document.filename).classes("text-caption text-grey-4")
|
||||
|
||||
with ui.button_group().props("flat outline"):
|
||||
ui.button(
|
||||
"Zoom In",
|
||||
icon="add",
|
||||
on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.zoomIn()"),
|
||||
)
|
||||
ui.button(
|
||||
"Zoom Out",
|
||||
icon="remove",
|
||||
on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.zoomOut()"),
|
||||
)
|
||||
ui.button(
|
||||
"Reset",
|
||||
icon="restart_alt",
|
||||
on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.reset()"),
|
||||
)
|
||||
|
||||
ui.label("Scroll, pinch, or use the buttons to inspect the document.").classes(
|
||||
"text-caption text-grey-4 q-mt-sm"
|
||||
)
|
||||
|
||||
with (
|
||||
ui.element("div")
|
||||
.classes("w-full document-panzoom-host bg-blue-grey-9 rounded-borders q-mt-md")
|
||||
.style(f"height: {height};") as host
|
||||
):
|
||||
host.props(f"id={host_id}")
|
||||
with ui.element("div").classes("document-panzoom-surface"):
|
||||
if document_kind == "pdf":
|
||||
ui.html(
|
||||
f'<iframe class="document-panzoom-iframe" '
|
||||
f'src="{document_url}" title="{document.filename}" '
|
||||
"data-panzoom-target></iframe>"
|
||||
)
|
||||
else:
|
||||
ui.html(
|
||||
f'<img class="document-panzoom-media" '
|
||||
f'src="{document_url}" alt="{document.filename}" '
|
||||
"data-panzoom-target data-panzoom-media />"
|
||||
)
|
||||
|
||||
_attach_panzoom(host_id)
|
||||
@@ -2,98 +2,29 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.models import Document
|
||||
from transcription.models import Job
|
||||
from transcription.models import Transcript
|
||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||
|
||||
|
||||
def _status_chip_classes(status: str) -> str:
|
||||
if status == "queued":
|
||||
return "bg-blue-1 text-blue-10"
|
||||
if status == "processing":
|
||||
return "bg-amber-1 text-amber-10"
|
||||
if status == "transcribed":
|
||||
return "bg-green-1 text-green-10"
|
||||
if status == "failed":
|
||||
return "bg-red-1 text-red-10"
|
||||
return "bg-grey-2 text-grey-9"
|
||||
|
||||
|
||||
def _metadata_row(label: str, value: str) -> None:
|
||||
with ui.row().classes("w-full items-start justify-between q-gutter-x-md"):
|
||||
ui.label(label).classes("text-caption text-grey-5 text-uppercase w-28")
|
||||
ui.label(value).classes("text-body2 text-right text-grey-1 break-all")
|
||||
|
||||
|
||||
def _render_document_section(document: Document) -> None:
|
||||
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
|
||||
ui.label("Document").classes("text-subtitle1 text-weight-medium")
|
||||
ui.separator().classes("q-my-sm bg-blue-grey-7")
|
||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||
_metadata_row("Filename", document.filename)
|
||||
_metadata_row("File path", document.file_path)
|
||||
|
||||
ui.separator().classes("q-my-md bg-blue-grey-7")
|
||||
render_document_panzoom(document=document)
|
||||
|
||||
|
||||
def _render_transcript_section(transcripts: Sequence[Transcript]) -> None:
|
||||
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
|
||||
ui.label("Transcripts").classes("text-subtitle1 text-weight-medium")
|
||||
ui.separator().classes("q-my-sm bg-blue-grey-7")
|
||||
|
||||
if not transcripts:
|
||||
ui.label("Transcript history is not available yet.").classes("text-body2 text-grey-3")
|
||||
return
|
||||
|
||||
for transcript in transcripts:
|
||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||
_metadata_row("Revision", str(transcript.revision))
|
||||
_metadata_row("Provider", transcript.provider)
|
||||
_metadata_row("Prompt", transcript.prompt_name)
|
||||
_metadata_row("Created", transcript.created_at.isoformat())
|
||||
|
||||
if transcript.text:
|
||||
ui.separator().classes("q-my-sm bg-blue-grey-7")
|
||||
with ui.card().classes("w-full bg-blue-grey-9 text-grey-1 q-pa-sm"):
|
||||
ui.markdown(transcript.text).classes("text-grey-1")
|
||||
elif transcript.error_detail:
|
||||
ui.separator().classes("q-my-sm bg-blue-grey-7")
|
||||
with ui.card().classes("w-full bg-red-1 text-red-10 q-pa-sm"):
|
||||
ui.label("Failure detail").classes("text-caption text-uppercase")
|
||||
ui.label(transcript.error_detail).classes("text-body2")
|
||||
|
||||
ui.separator().classes("q-my-md bg-blue-grey-7")
|
||||
|
||||
|
||||
def render_job_detail(*, job: Job, document: Document | None, transcripts: Sequence[Transcript]) -> None:
|
||||
def render_job_detail(*, job: Job, document: Document | None, transcript: Transcript | None) -> None:
|
||||
"""Render all sections for the job detail page."""
|
||||
status_text = job.status.value
|
||||
with ui.column().classes("w-full max-w-4xl q-gutter-md"):
|
||||
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-lg"):
|
||||
with ui.row().classes("w-full items-center justify-between q-gutter-md"):
|
||||
with ui.column().classes("q-gutter-none"):
|
||||
ui.label("Job overview").classes("text-h6 text-weight-bold")
|
||||
ui.label(str(job.id)).classes("text-caption text-grey-5")
|
||||
status_chip_classes = (
|
||||
"q-px-sm q-py-xs rounded-borders "
|
||||
"text-weight-medium text-capitalize "
|
||||
f"{_status_chip_classes(status_text)}"
|
||||
)
|
||||
ui.label(status_text).classes(status_chip_classes)
|
||||
ui.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()}")
|
||||
|
||||
ui.separator().classes("q-my-md bg-blue-grey-7")
|
||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||
_metadata_row("Created", job.created_at.isoformat())
|
||||
_metadata_row("Updated", job.updated_at.isoformat())
|
||||
_metadata_row("Retries", str(job.retry_count))
|
||||
if document is not None:
|
||||
ui.label(f"Filename: {document.filename}")
|
||||
ui.label(f"File path: {document.file_path}")
|
||||
|
||||
if document is not None:
|
||||
_render_document_section(document)
|
||||
|
||||
_render_transcript_section(transcripts)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Reusable jobs table rendering helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JobTableRow:
|
||||
"""Read model consumed by the shared jobs table component."""
|
||||
|
||||
id: UUID
|
||||
status: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, str]]:
|
||||
"""Convert typed rows into table-compatible dictionaries."""
|
||||
return [
|
||||
{
|
||||
"id": str(row.id),
|
||||
"status": row.status,
|
||||
"created_at": row.created_at,
|
||||
"updated_at": row.updated_at,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
|
||||
"""Render jobs table and per-row detail links."""
|
||||
if not rows:
|
||||
ui.label("No jobs yet.")
|
||||
return
|
||||
|
||||
serialized_rows = _serialize_rows(rows)
|
||||
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=serialized_rows,
|
||||
row_key="id",
|
||||
).classes("w-full")
|
||||
|
||||
with ui.column().classes("gap-1"):
|
||||
for row in serialized_rows:
|
||||
ui.link(f"Open {row['id']}", f"/jobs/{row['id']}")
|
||||
@@ -1,4 +0,0 @@
|
||||
from .jobs import JobTableRow
|
||||
from .jobs import render_jobs_table
|
||||
|
||||
__all__ = ["JobTableRow", "render_jobs_table"]
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Common logic for generating table widgets."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from nicegui import events
|
||||
from nicegui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_row_id(args: Any) -> str | None:
|
||||
if isinstance(args, dict):
|
||||
if isinstance(args.get("row"), dict):
|
||||
row_id = args["row"].get("id")
|
||||
return str(row_id) if row_id is not None else None
|
||||
row_id = args.get("id")
|
||||
return str(row_id) if row_id is not None else None
|
||||
|
||||
if isinstance(args, list):
|
||||
for value in args:
|
||||
if isinstance(value, dict):
|
||||
row_id = value.get("id")
|
||||
if row_id is not None:
|
||||
return str(row_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _bind_row_click_handler(
|
||||
table: Any,
|
||||
*,
|
||||
on_row_click_id: Callable[[str], None],
|
||||
) -> None:
|
||||
def handle_row_click(event: events.GenericEventArguments) -> None:
|
||||
row_id = _extract_row_id(event.args)
|
||||
if row_id is None:
|
||||
return
|
||||
on_row_click_id(row_id)
|
||||
|
||||
table.on("rowClick", handle_row_click)
|
||||
logger.debug("Row click handler bound to table")
|
||||
|
||||
|
||||
def build_table(
|
||||
rows: list[dict[str, Any]],
|
||||
columns: list[dict[str, Any]],
|
||||
*,
|
||||
default_sort_by: str | None = None,
|
||||
default_descending: bool = False,
|
||||
classes: str = "app-table",
|
||||
on_row_click_id: Callable[[str], None] | None = None,
|
||||
) -> Any:
|
||||
pagination: dict[str, Any] = {"rowsPerPage": 25}
|
||||
if default_sort_by is not None:
|
||||
pagination["sortBy"] = default_sort_by
|
||||
pagination["descending"] = default_descending
|
||||
|
||||
table = (
|
||||
ui.table(
|
||||
rows=rows,
|
||||
columns=columns,
|
||||
row_key="id",
|
||||
pagination=pagination,
|
||||
)
|
||||
.classes(classes)
|
||||
.props('table-style="table-layout: fixed; width: 100%;"')
|
||||
)
|
||||
logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
|
||||
if on_row_click_id is not None:
|
||||
_bind_row_click_handler(table, on_row_click_id=on_row_click_id)
|
||||
return table
|
||||
@@ -1,75 +0,0 @@
|
||||
"""Jobs table rendering helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from .common import build_table
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class JobTableRow:
|
||||
"""Read model consumed by the jobs table component."""
|
||||
|
||||
id: UUID
|
||||
status: str
|
||||
filename: str
|
||||
retry_count: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
def _format_timestamp(value: str) -> str:
|
||||
"""Return a friendly UTC timestamp for table display."""
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return value
|
||||
parsed = parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
|
||||
return parsed.astimezone().strftime("%b %d, %I:%M %p")
|
||||
|
||||
|
||||
def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": str(row.id),
|
||||
"status": row.status,
|
||||
"filename": row.filename,
|
||||
"retry_count": row.retry_count,
|
||||
"created_at": _format_timestamp(row.created_at),
|
||||
"updated_at": _format_timestamp(row.updated_at),
|
||||
"created_sort": row.created_at,
|
||||
"updated_sort": row.updated_at,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
|
||||
"""Render jobs table and open a detail page when clicking a row."""
|
||||
if not rows:
|
||||
ui.label("No jobs yet.")
|
||||
return
|
||||
|
||||
build_table(
|
||||
rows=_serialize_rows(rows),
|
||||
columns=[
|
||||
{"name": "id", "label": "Job ID", "field": "id", "sortable": True},
|
||||
{"name": "status", "label": "Status", "field": "status", "sortable": True},
|
||||
{"name": "filename", "label": "Filename", "field": "filename", "sortable": True},
|
||||
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
|
||||
{"name": "created_at", "label": "Created", "field": "created_at", "sortable": True},
|
||||
{"name": "updated_at", "label": "Updated", "field": "updated_at", "sortable": True},
|
||||
],
|
||||
default_sort_by="created_sort",
|
||||
default_descending=True,
|
||||
classes="app-table w-full",
|
||||
on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"),
|
||||
)
|
||||
@@ -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"')
|
||||
@@ -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")
|
||||
@@ -4,53 +4,76 @@ from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from nicegui import ui
|
||||
from sqlmodel import desc
|
||||
from sqlmodel import select
|
||||
|
||||
from transcription.app_state import resolve_session_factory
|
||||
from transcription.services.jobs import JobService
|
||||
from transcription.services.transcription import TranscriptionService
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.db import get_session
|
||||
from transcription.models import Document
|
||||
from transcription.models import Job
|
||||
from transcription.models import Transcript
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
from transcription.ui.components.error_presenter import summarize_error
|
||||
from transcription.ui.components.job_detail import render_job_detail
|
||||
from transcription.ui.components.table.jobs import render_jobs_table
|
||||
from transcription.ui.components.job_table import JobTableRow
|
||||
from transcription.ui.components.job_table import render_jobs_table
|
||||
|
||||
from ..components.table.jobs import JobTableRow
|
||||
|
||||
async def fetch_jobs() -> list[JobTableRow]:
|
||||
"""Return jobs for display in most-recent-first order."""
|
||||
async with get_session() as session:
|
||||
jobs = (await session.exec(select(Job).order_by(desc(Job.created_at)))).all()
|
||||
return [
|
||||
JobTableRow(
|
||||
id=job.id,
|
||||
status=job.status.value,
|
||||
created_at=job.created_at.isoformat(),
|
||||
updated_at=job.updated_at.isoformat(),
|
||||
)
|
||||
for job in jobs
|
||||
]
|
||||
|
||||
|
||||
async def fetch_job_detail(job_id: UUID) -> tuple[Job | None, Document | None, Transcript | None]:
|
||||
"""Return job, document, and transcript for detail view."""
|
||||
async with get_session() as session:
|
||||
job = await session.get(Job, job_id)
|
||||
if job is None:
|
||||
return None, None, None
|
||||
document = await session.get(Document, job.document_id)
|
||||
transcript = (await session.exec(select(Transcript).where(Transcript.job_id == job.id))).first()
|
||||
return job, document, transcript
|
||||
|
||||
|
||||
def register_page() -> None:
|
||||
"""Register jobs list and detail routes."""
|
||||
|
||||
@ui.page("/jobs")
|
||||
async def jobs_page(request: Request) -> None:
|
||||
session_factory = resolve_session_factory(request.app.state)
|
||||
jobs_service = JobService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/jobs")
|
||||
async def jobs_page() -> None:
|
||||
ui.label("Transcription Jobs")
|
||||
status = ui.label("Ready")
|
||||
|
||||
@ui.refreshable
|
||||
async def render_table() -> None:
|
||||
jobs = [
|
||||
JobTableRow(
|
||||
id=job.id,
|
||||
status=job.status.value,
|
||||
filename=job.filename,
|
||||
retry_count=job.retry_count,
|
||||
created_at=job.created_at.isoformat(),
|
||||
updated_at=job.updated_at.isoformat(),
|
||||
)
|
||||
for job in await jobs_service.list_jobs()
|
||||
]
|
||||
jobs = await fetch_jobs()
|
||||
render_jobs_table(jobs)
|
||||
|
||||
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
|
||||
async def refresh() -> None:
|
||||
status.text = "Refreshing..."
|
||||
try:
|
||||
await render_table.refresh()
|
||||
status.text = "Refreshed"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status.text = f"Refresh failed: {summarize_error(exc, operation='jobs.refresh')}"
|
||||
show_error(exc, title="Jobs refresh failed", operation="jobs.refresh")
|
||||
|
||||
ui.button("Refresh", on_click=refresh)
|
||||
await render_table()
|
||||
ui.link("Back to upload", "/")
|
||||
|
||||
@ui.page("/jobs/{job_id}")
|
||||
async def job_detail_page(job_id: str, request: Request) -> None:
|
||||
session_factory = resolve_session_factory(request.app.state)
|
||||
jobs_service = JobService(session_factory=session_factory)
|
||||
transcription_service = TranscriptionService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/jobs")
|
||||
ui.button(icon="arrow_back", on_click=ui.navigate.back)
|
||||
async def job_detail_page(job_id: str) -> None:
|
||||
ui.label("Job Detail")
|
||||
try:
|
||||
parsed_id = UUID(job_id)
|
||||
except ValueError:
|
||||
@@ -58,14 +81,12 @@ def register_page() -> None:
|
||||
ui.link("Back to jobs", "/jobs")
|
||||
return
|
||||
|
||||
try:
|
||||
job = await jobs_service.read_job(job_id=parsed_id)
|
||||
except ValueError:
|
||||
job, document, transcript = await fetch_job_detail(parsed_id)
|
||||
if job is None:
|
||||
ui.label("Job not found")
|
||||
ui.link("Back to jobs", "/jobs")
|
||||
return
|
||||
|
||||
transcripts = list(await transcription_service.list_transcripts_by_job(parsed_id))
|
||||
render_job_detail(job=job, document=job.document, transcripts=transcripts)
|
||||
render_job_detail(job=job, document=document, transcript=transcript)
|
||||
|
||||
ui.link("Back to jobs", "/jobs")
|
||||
|
||||
@@ -2,32 +2,73 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
from nicegui import ui
|
||||
from dataclasses import dataclass
|
||||
|
||||
from transcription.app_state import resolve_session_factory
|
||||
from transcription.db import get_session
|
||||
from transcription.services.store import create_upload_job
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.upload import render_upload_widget
|
||||
from transcription.worker import resolve_worker_notifier
|
||||
from nicegui import ui
|
||||
from nicegui.events import UploadEventArguments
|
||||
|
||||
from transcription.services.upload import UploadError
|
||||
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:
|
||||
"""Register the upload page route."""
|
||||
|
||||
@ui.page("/upload", title="Upload Document")
|
||||
def upload_page(request: Request) -> None:
|
||||
render_navigation_header(current_path="/upload")
|
||||
session_factory = resolve_session_factory(request.app.state)
|
||||
@ui.page("/")
|
||||
def upload_page() -> None:
|
||||
state = UploadPageState()
|
||||
status_label = ui.label("Upload a document to start transcription.")
|
||||
|
||||
async def submit_upload(filename: str, file_bytes: bytes):
|
||||
async with get_session(session_factory=session_factory) as session:
|
||||
return await create_upload_job(
|
||||
filename=filename,
|
||||
file_bytes=file_bytes,
|
||||
session=session,
|
||||
)
|
||||
async def on_upload(event: UploadEventArguments) -> None:
|
||||
if state.loading:
|
||||
ui.notify("Upload already in progress. Please wait.", type="warning")
|
||||
return
|
||||
|
||||
notify_worker = resolve_worker_notifier(request.app.state)
|
||||
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
|
||||
state.loading = True
|
||||
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():
|
||||
ui.link("View jobs", "/jobs")
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
:root {
|
||||
/* Soft blue-night palette tokens */
|
||||
--ctp-rosewater: #f2dde5;
|
||||
--ctp-flamingo: #edcfd8;
|
||||
--ctp-pink: #dcc7de;
|
||||
--ctp-mauve: #a9bde5;
|
||||
--ctp-red: #d98a9a;
|
||||
--ctp-maroon: #d39aa5;
|
||||
--ctp-peach: #d7af8c;
|
||||
--ctp-yellow: #e2c083;
|
||||
--ctp-green: #86c8ad;
|
||||
--ctp-teal: #77bfbe;
|
||||
--ctp-sky: #7ebdda;
|
||||
--ctp-sapphire: #74aed0;
|
||||
--ctp-blue: #92b5f5;
|
||||
--ctp-lavender: #6f97e8;
|
||||
--ctp-text: #d8e2f5;
|
||||
--ctp-subtext1: #bfcae0;
|
||||
--ctp-subtext0: #a9b6cf;
|
||||
--ctp-overlay2: #95a3bf;
|
||||
--ctp-overlay1: #7c8ca9;
|
||||
--ctp-overlay0: #657490;
|
||||
--ctp-surface2: #4d5f7c;
|
||||
--ctp-surface1: #394a65;
|
||||
--ctp-surface0: #2a3954;
|
||||
--ctp-base: #1f2b42;
|
||||
--ctp-mantle: #1a2538;
|
||||
--ctp-crust: #141e30;
|
||||
|
||||
}
|
||||
+162
-155
@@ -4,161 +4,31 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import contextmanager
|
||||
from contextlib import suppress
|
||||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from threading import Event
|
||||
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.config import get_settings
|
||||
from transcription.db import get_session
|
||||
from transcription.errors import AppError
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.errors import classify_unexpected_error
|
||||
|
||||
from .services import ServiceBundle
|
||||
from .services.documents import DocumentService
|
||||
from .services.jobs import JobService
|
||||
from .services.transcription import TranscriptionService
|
||||
from .services.workflows import advance_job
|
||||
from .services.workflows import process_next_queued_job as process_next_queued_job_workflow
|
||||
from transcription.errors import format_error_detail
|
||||
from transcription.models import Document
|
||||
from transcription.models import Job
|
||||
from transcription.models import JobStatus
|
||||
from transcription.models import Transcript
|
||||
from transcription.services.transcription import transcribe_document_image
|
||||
|
||||
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(
|
||||
*,
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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"}
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Tests for API error response envelope handlers."""
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
his chapter gives a great tribute to the Doumeeqers - so far as he knows no one
|
||||
on the Doumeeq Plains went on relief during the depression. That in a nutshell
|
||||
shows the sturdy character of the residents of the Doumeeq Plains.
|
||||
his chapter gives a great tribute to the Doumecqers--so far as he knows no one
|
||||
on the Doumecq Plains went on relief during the depression. That in a nutshell
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -6,9 +6,7 @@ JOHN E. COCHRAN
|
||||
FAMILY ASSOCIATION
|
||||
Family Only
|
||||
Home | Sibling's Stories | 1st Cousins | Ancestor's Stories | Reunion History | Next Reunion | JECMEF
|
||||
|
||||
OMIE WRITES HOME
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
Nome Alaska August 26, 1923
|
||||
|
||||
My Dear Ethel et al.
|
||||
|
||||
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
|
||||
listening. Can't I see him listening now to all the
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
to the natives they were getting extremely odiferous–in fact, you could scarcely stay on the
|
||||
to the natives they were getting extremely odiferous—in fact, you could scarcely stay on the
|
||||
ship with any degree of comfort unless you had per chance lost your sense of smell.
|
||||
|
||||
Then we went north to a few minutes beyond the 70th degree of latitude and thot [sic] for awhile
|
||||
we would go to Wrangell Island where some men from Stefflonsons [sic] 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
|
||||
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 Steffonsons ship were supposed to be
|
||||
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
|
||||
years. He was a Swede–who else could. Their houses were circular and built up with dirt 2 or
|
||||
years. He was a Swede--who else could. Their houses were circular and built up with dirt 2 or
|
||||
3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
have it. One man I was sort of taking care of, tho [sic] 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
|
||||
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 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
|
||||
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
|
||||
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
|
||||
over Whalen and the Russian soldiers were there–a few, one or two or three, I forget the
|
||||
over Whalen and the Russian soldiers were there—a few, one or two or three, I forget the
|
||||
number.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
© [inserted: JECFA] 1986
|
||||
© JECFA 1986
|
||||
|
||||
Up
|
||||
|
||||
|
||||
@@ -2,30 +2,28 @@ source: Rod Moser Letter - p1.jpg
|
||||
provider: openrouter
|
||||
model: google/gemini-2.5-flash
|
||||
---
|
||||
JOHN ISBILL
|
||||
R. T. MOSER
|
||||
JOHN ISBILL R. T. MOSER
|
||||
ISBILL & MOSER
|
||||
DEALERS IN
|
||||
GENERAL MERCHANDISE
|
||||
|
||||
Vonore, Tenn., Jany 27- 1913
|
||||
Dear Much Aunt Louie
|
||||
How are you a
|
||||
few nights ago I sewed a
|
||||
letter from your folks, so
|
||||
Vonore, Tenn. January 27 - 1913
|
||||
Dear Uncle [sic] Aun[t Adeline?]
|
||||
Was at home a
|
||||
few nights ago & saw a
|
||||
letter from you folks, so
|
||||
I decided to write you
|
||||
a few lines myself ok
|
||||
I am contemplateing a
|
||||
trip out west next summer
|
||||
& I want Some Olders to go
|
||||
where I and them.
|
||||
|
||||
I am getting
|
||||
up in years & unmarried
|
||||
a few lines myself &
|
||||
I am contemplating a
|
||||
trip out west next summ[er]
|
||||
& I want both of fillers [sic] to go
|
||||
when I am [to] them.
|
||||
Am getting
|
||||
up in years & unmarried,
|
||||
so you see the object of
|
||||
my trip, is to get a bunch
|
||||
of Young & old maids
|
||||
my trip, is to get a wife
|
||||
& I hear is a lot old maids
|
||||
& widows out there. I
|
||||
want you to kiss them
|
||||
at my fans [sic] mug as they
|
||||
want you to see them
|
||||
at my land my [sic] at there [sic]
|
||||
as soon as I get there
|
||||
|
||||
+3
-50
@@ -5,67 +5,20 @@ isolated, fast, and leave no artifacts on disk.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlmodel import Session
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel import create_engine
|
||||
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
|
||||
from transcription.db.runtime import get_session_factory
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.services.jobs import JobService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
"""Provide a clean synchronous database session for sync tests."""
|
||||
"""Provide a clean database session for each test."""
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as sync_session:
|
||||
yield sync_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()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_session_factory(default_settings: Settings):
|
||||
"""Provide a base fixture for tests that require database access."""
|
||||
session_factory = get_session_factory(settings=default_settings)
|
||||
return session_factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def job_service(default_session_factory) -> JobService:
|
||||
"""Provide a JobService instance for testing."""
|
||||
return JobService(session_factory=default_session_factory)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def document_service(default_session_factory) -> DocumentService:
|
||||
"""Provide a DocumentService instance for testing."""
|
||||
return DocumentService(session_factory=default_session_factory)
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
@@ -6,7 +6,9 @@ import pytest
|
||||
from sqlmodel import select
|
||||
|
||||
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.services.upload import create_upload_job
|
||||
from transcription.worker import process_next_queued_job
|
||||
@@ -71,6 +73,6 @@ class TestPipelineFailureFlow:
|
||||
assert job.status == JobStatus.FAILED
|
||||
assert transcript is not 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 "error_id=" in transcript.error_detail
|
||||
|
||||
@@ -5,8 +5,10 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.providers.base import ProviderError, ProviderResponseError
|
||||
from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL, OpenRouterTranscriptionProvider
|
||||
from transcription.providers.base import ProviderError
|
||||
from transcription.providers.base import ProviderResponseError
|
||||
from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL
|
||||
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
|
||||
|
||||
|
||||
class _FakeChat:
|
||||
|
||||
@@ -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."""
|
||||
@@ -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"
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
|
||||
|
||||
HAS_OPENROUTER_KEY = bool(os.getenv("OPENROUTER_API_KEY"))
|
||||
|
||||
REAL_IMAGES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "real"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -26,6 +26,8 @@ class TestAppLifespan:
|
||||
calls = []
|
||||
|
||||
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.initialize_database_runtime",
|
||||
@@ -35,6 +37,8 @@ class TestAppLifespan:
|
||||
monkeypatch.setattr("transcription.app.should_bootstrap_schema", lambda _settings: True)
|
||||
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
|
||||
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
|
||||
monkeypatch.setattr("transcription.app.apply_pending_migrations", lambda **_kwargs: calls.append("migrate"))
|
||||
monkeypatch.setattr("transcription.app.validate_schema_compatibility", lambda **_kwargs: [])
|
||||
|
||||
class _Dir:
|
||||
def mkdir(self, parents: bool, exist_ok: bool):
|
||||
@@ -43,6 +47,8 @@ class TestAppLifespan:
|
||||
class _Settings:
|
||||
upload_dir = _Dir()
|
||||
prompt_dir = _Dir()
|
||||
migration_auto_apply_on_startup = False
|
||||
validate_schema_on_startup = True
|
||||
|
||||
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
|
||||
|
||||
@@ -61,6 +67,8 @@ class TestAppLifespan:
|
||||
calls = []
|
||||
|
||||
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.initialize_database_runtime",
|
||||
@@ -70,6 +78,8 @@ class TestAppLifespan:
|
||||
monkeypatch.setattr("transcription.app.should_bootstrap_schema", lambda _settings: True)
|
||||
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
|
||||
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
|
||||
monkeypatch.setattr("transcription.app.apply_pending_migrations", lambda **_kwargs: calls.append("migrate"))
|
||||
monkeypatch.setattr("transcription.app.validate_schema_compatibility", lambda **_kwargs: [])
|
||||
|
||||
class _Dir:
|
||||
def mkdir(self, parents: bool, exist_ok: bool):
|
||||
@@ -78,6 +88,8 @@ class TestAppLifespan:
|
||||
class _Settings:
|
||||
upload_dir = _Dir()
|
||||
prompt_dir = _Dir()
|
||||
migration_auto_apply_on_startup = False
|
||||
validate_schema_on_startup = True
|
||||
|
||||
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
|
||||
|
||||
|
||||
+29
-1
@@ -5,7 +5,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
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:
|
||||
@@ -63,6 +64,33 @@ class TestPathSettings:
|
||||
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:
|
||||
"""Verify worker retry settings defaults."""
|
||||
|
||||
|
||||
+22
-5
@@ -1,7 +1,10 @@
|
||||
"""Tests for transcription.db — schema bootstrap and session factory."""
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import Session
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel import create_engine
|
||||
from sqlmodel.pool import StaticPool
|
||||
|
||||
|
||||
@@ -18,12 +21,14 @@ class TestSchemaBootstrap:
|
||||
"""Verify create_all produces the expected table set."""
|
||||
|
||||
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()
|
||||
# Ensure models are imported so metadata is populated
|
||||
from transcription.models import Document, Job, Transcript # noqa: F401
|
||||
|
||||
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)
|
||||
|
||||
@@ -32,6 +37,18 @@ class TestSchemaBootstrap:
|
||||
assert "document" in table_names
|
||||
assert "job" 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:
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
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
|
||||
@@ -38,6 +41,6 @@ class TestAppErrorHelpers:
|
||||
assert isinstance(err, AppError)
|
||||
assert err.category == ErrorCategory.INTERNAL_UNEXPECTED
|
||||
assert "unit.test" in err.message
|
||||
assert "boom" in err.message
|
||||
assert "boom" not in err.message
|
||||
assert err.suggestion
|
||||
assert err.error_id
|
||||
|
||||
@@ -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
@@ -5,7 +5,11 @@ from uuid import UUID
|
||||
import pytest
|
||||
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:
|
||||
@@ -89,6 +93,12 @@ class TestJobModel:
|
||||
session.refresh(job)
|
||||
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):
|
||||
"""Status updates from processing to failed."""
|
||||
doc = _persist_document(session)
|
||||
@@ -152,6 +162,27 @@ class TestTranscriptModel:
|
||||
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:
|
||||
"""Verify SQLModel relationship navigation between models."""
|
||||
|
||||
@@ -177,3 +208,15 @@ class TestRelationships:
|
||||
assert job.transcript is not None
|
||||
assert isinstance(job.transcript, Transcript)
|
||||
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)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROMPT_PATH = Path("prompts/transcribe_document.md")
|
||||
|
||||
|
||||
|
||||
@@ -19,14 +19,17 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
|
||||
"REQ-3": [
|
||||
"tests/services/test_worker.py",
|
||||
"tests/ui/test_jobs_page.py",
|
||||
"tests/services/test_library.py",
|
||||
],
|
||||
"REQ-4": [
|
||||
"tests/services/test_worker.py",
|
||||
"tests/integration/test_pipeline_flow.py",
|
||||
"tests/services/test_library.py",
|
||||
],
|
||||
"REQ-5": [
|
||||
"tests/ui/test_jobs_page.py",
|
||||
"tests/ui/test_pages_registration.py",
|
||||
"tests/api/test_routes.py",
|
||||
],
|
||||
"REQ-6": [
|
||||
"tests/test_app.py",
|
||||
@@ -36,6 +39,9 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
|
||||
"tests/test_app.py",
|
||||
"tests/test_config.py",
|
||||
],
|
||||
"REQ-11": [
|
||||
"tests/services/test_library.py",
|
||||
],
|
||||
"REQ-12": [
|
||||
"tests/test_prompts.py",
|
||||
"tests/services/test_transcription.py",
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Shared fixtures for UI integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from transcription.app import create_app
|
||||
from transcription.config import Settings
|
||||
from transcription.config import _settings
|
||||
from transcription.db import get_session
|
||||
from transcription.models import Document
|
||||
from transcription.models import Job
|
||||
from transcription.models import JobStatus
|
||||
from transcription.models import Transcript
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_client(tmp_path: Path) -> tuple[FastAPI, TestClient]:
|
||||
"""Provide a real application and test client backed by in-memory SQLite."""
|
||||
settings = Settings(
|
||||
openrouter_api_key="test-key",
|
||||
database_url="sqlite:///:memory:",
|
||||
environment="test",
|
||||
upload_dir=tmp_path / "uploads",
|
||||
prompt_dir=tmp_path / "prompts",
|
||||
)
|
||||
_settings.set(settings)
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
yield app, client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
||||
"""Return a helper for inserting a document/job/transcript trio."""
|
||||
app, _ = app_client
|
||||
|
||||
def _seed(
|
||||
*,
|
||||
filename: str = "sample.pdf",
|
||||
status: JobStatus = JobStatus.TRANSCRIBED,
|
||||
transcript_text: str | None = "Sample transcript text",
|
||||
error_detail: str | None = None,
|
||||
) -> UUID:
|
||||
async def _insert() -> UUID:
|
||||
async with get_session(session_factory=app.state.runtime.session_factory) as session:
|
||||
document = Document(filename=filename, file_path=f"uploads/{filename}")
|
||||
session.add(document)
|
||||
await session.flush()
|
||||
|
||||
job = Job(document_id=document.id, status=status, retry_count=0)
|
||||
session.add(job)
|
||||
await session.flush()
|
||||
|
||||
if transcript_text is not None or error_detail is not None:
|
||||
session.add(
|
||||
Transcript(
|
||||
job_id=job.id,
|
||||
provider="openrouter",
|
||||
prompt_name="transcribe_document",
|
||||
text=transcript_text,
|
||||
error_detail=error_detail,
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
return job.id
|
||||
|
||||
return asyncio.run(_insert())
|
||||
|
||||
return _seed
|
||||
+87
-42
@@ -1,53 +1,98 @@
|
||||
"""Tests for the jobs page route."""
|
||||
"""Tests for transcription.ui.jobs_page."""
|
||||
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from transcription.ui import register_pages
|
||||
from transcription.ui.pages import jobs_page
|
||||
from transcription.ui.pages.jobs_page import JobTableRow
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
"""Provide a minimal app client with jobs data patched for rendering."""
|
||||
|
||||
async def _fetch_jobs_stub():
|
||||
return [
|
||||
JobTableRow(
|
||||
id=UUID("00000000-0000-0000-0000-000000000001"),
|
||||
status="queued",
|
||||
filename="sample.pdf",
|
||||
retry_count=2,
|
||||
created_at="2026-01-01T12:00:00+00:00",
|
||||
updated_at="2026-01-01T12:01:00+00:00",
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(jobs_page, "fetch_jobs", _fetch_jobs_stub)
|
||||
|
||||
app = FastAPI()
|
||||
register_pages(app)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
from transcription.models import Document
|
||||
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
|
||||
class TestPageRendering:
|
||||
"""Verify the jobs page is available and includes the main controls."""
|
||||
class TestJobsListBehavior:
|
||||
"""Verify job list data and rendering helpers."""
|
||||
|
||||
def test_jobs_page_renders_expected_controls(self, client, monkeypatch):
|
||||
"""GET /ui/jobs returns the page shell and jobs controls."""
|
||||
response = client.get("/ui/jobs")
|
||||
def test_fetch_jobs_returns_job_view_rows(self, session, monkeypatch):
|
||||
"""fetch_jobs returns normalized JobView rows for UI consumption."""
|
||||
document = Document(filename="letter.jpg", file_path="uploads/letter.jpg")
|
||||
session.add(document)
|
||||
session.commit()
|
||||
session.refresh(document)
|
||||
|
||||
async def _fetch_jobs_empty():
|
||||
return []
|
||||
job = Job(document_id=document.id)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
|
||||
monkeypatch.setattr(jobs_page, "fetch_jobs", _fetch_jobs_empty)
|
||||
response = client.get("/ui/jobs")
|
||||
class _SessionContext:
|
||||
def __enter__(self):
|
||||
return session
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "No jobs yet." in response.text
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
|
||||
|
||||
rows = fetch_jobs()
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0].id == job.id
|
||||
assert rows[0].status == "queued"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestJobDetailBehavior:
|
||||
"""Verify job detail retrieval behavior."""
|
||||
|
||||
def test_fetch_job_detail_returns_related_records_when_present(self, session, monkeypatch):
|
||||
"""fetch_job_detail returns job, document, and transcript when available."""
|
||||
document = Document(filename="typed.jpg", file_path="uploads/typed.jpg")
|
||||
session.add(document)
|
||||
session.commit()
|
||||
session.refresh(document)
|
||||
|
||||
job = Job(document_id=document.id)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
session.refresh(job)
|
||||
|
||||
transcript = Transcript(job_id=job.id, text="Transcript text")
|
||||
session.add(transcript)
|
||||
session.commit()
|
||||
|
||||
class _SessionContext:
|
||||
def __enter__(self):
|
||||
return session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
|
||||
|
||||
fetched_job, fetched_document, fetched_transcript = fetch_job_detail(job.id)
|
||||
|
||||
assert fetched_job is not None
|
||||
assert fetched_document is not None
|
||||
assert fetched_transcript is not None
|
||||
assert fetched_job.id == job.id
|
||||
assert fetched_document.id == document.id
|
||||
assert fetched_transcript.job_id == job.id
|
||||
|
||||
def test_fetch_job_detail_returns_nones_for_missing_job(self, session, monkeypatch):
|
||||
"""fetch_job_detail returns triple None when job does not exist."""
|
||||
class _SessionContext:
|
||||
def __enter__(self):
|
||||
return session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
|
||||
|
||||
fetched_job, fetched_document, fetched_transcript = fetch_job_detail(uuid4())
|
||||
|
||||
assert fetched_job is None
|
||||
assert fetched_document is None
|
||||
assert fetched_transcript is None
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from transcription.ui import register_pages
|
||||
|
||||
@@ -10,30 +11,16 @@ from transcription.ui import register_pages
|
||||
class TestPageRegistration:
|
||||
"""Verify page registration and route wiring."""
|
||||
|
||||
def test_register_pages_wires_upload_jobs_and_mount(self, monkeypatch):
|
||||
"""register_pages registers pages and mounts NiceGUI at /ui."""
|
||||
calls: list[str] = []
|
||||
|
||||
def _record_upload() -> None:
|
||||
calls.append("upload")
|
||||
|
||||
def _record_jobs() -> None:
|
||||
calls.append("jobs")
|
||||
|
||||
def _record_run_with(
|
||||
_app: FastAPI,
|
||||
*,
|
||||
mount_path: str,
|
||||
show_welcome_message: bool,
|
||||
dark: bool,
|
||||
) -> None:
|
||||
calls.append(f"run_with:{mount_path}:{show_welcome_message}:{dark}")
|
||||
|
||||
monkeypatch.setattr("transcription.ui.register_upload_page", _record_upload)
|
||||
monkeypatch.setattr("transcription.ui.register_jobs_page", _record_jobs)
|
||||
monkeypatch.setattr("transcription.ui.ui.run_with", _record_run_with)
|
||||
|
||||
def test_register_pages_adds_expected_routes(self):
|
||||
"""register_pages wires upload and jobs routes into the app."""
|
||||
app = FastAPI()
|
||||
register_pages(app)
|
||||
app.add_api_route("/healthz", lambda: {"status": "ok"}, methods=["GET"])
|
||||
|
||||
assert calls == ["upload", "jobs", "run_with:/ui:False:True"]
|
||||
client = TestClient(app)
|
||||
ui_response = client.get("/ui")
|
||||
health_response = client.get("/healthz")
|
||||
|
||||
assert ui_response.status_code == 200
|
||||
assert health_response.status_code == 200
|
||||
assert health_response.json() == {"status": "ok"}
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
"""Tests for the upload page route."""
|
||||
"""Tests for transcription.ui.upload_page."""
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from transcription.app import create_app
|
||||
from transcription.config import Settings
|
||||
from transcription.config import _settings
|
||||
from transcription.services.upload import UploadError
|
||||
from transcription.services.upload import UploadJobResult
|
||||
from transcription.ui import upload_page
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path):
|
||||
"""Provide a real app client backed by in-memory SQLite."""
|
||||
settings = Settings(
|
||||
openrouter_api_key="test-key",
|
||||
database_url="sqlite:///:memory:",
|
||||
environment="test",
|
||||
upload_dir=tmp_path / "uploads",
|
||||
prompt_dir=tmp_path / "prompts",
|
||||
)
|
||||
_settings.set(settings)
|
||||
@pytest.mark.unit
|
||||
class TestUploadPageBehavior:
|
||||
"""Verify upload page helper and submission behavior."""
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
def test_accepted_upload_types_contains_supported_extensions(self):
|
||||
"""accepted_upload_types includes all MVP-supported upload extensions."""
|
||||
accepted = upload_page.accepted_upload_types()
|
||||
assert ".jpg" in accepted
|
||||
assert ".jpeg" in accepted
|
||||
assert ".png" in accepted
|
||||
assert ".tif" in accepted
|
||||
assert ".tiff" in accepted
|
||||
assert ".pdf" in accepted
|
||||
|
||||
def test_submit_upload_calls_upload_service(self, monkeypatch):
|
||||
"""submit_upload delegates file persistence and job creation to upload service."""
|
||||
expected = UploadJobResult(
|
||||
document_id=uuid4(),
|
||||
job_id=uuid4(),
|
||||
stored_path=Path("uploads/mock.jpg"),
|
||||
original_filename="mock.jpg",
|
||||
)
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestPageRendering:
|
||||
"""Verify the upload page is available and includes the main controls."""
|
||||
def fake_create_upload_job(*, filename: str, file_bytes: bytes):
|
||||
assert filename == "mock.jpg"
|
||||
assert file_bytes == b"bytes"
|
||||
return expected
|
||||
|
||||
def test_root_redirects_to_ui(self, client):
|
||||
"""GET / redirects to the UI mount point."""
|
||||
response = client.get("/", follow_redirects=False)
|
||||
monkeypatch.setattr(upload_page, "create_upload_job", fake_create_upload_job)
|
||||
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"] == "/ui"
|
||||
result = upload_page.submit_upload(filename="mock.jpg", file_bytes=b"bytes")
|
||||
assert result == expected
|
||||
|
||||
def test_ui_redirects_to_upload(self, client):
|
||||
"""GET /ui redirects to the upload page."""
|
||||
response = client.get("/ui", follow_redirects=False)
|
||||
def test_submit_upload_surfaces_upload_error(self, monkeypatch):
|
||||
"""submit_upload raises UploadError for invalid upload payloads."""
|
||||
def fake_create_upload_job(*, filename: str, file_bytes: bytes):
|
||||
raise UploadError("invalid payload")
|
||||
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"] == "/ui/upload"
|
||||
monkeypatch.setattr(upload_page, "create_upload_job", fake_create_upload_job)
|
||||
|
||||
def test_upload_page_renders_expected_controls(self, client):
|
||||
"""GET /ui/upload returns the page shell and upload controls."""
|
||||
response = client.get("/ui/upload")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Upload Document" in response.text
|
||||
assert "Select document file" in response.text
|
||||
assert "Jobs" in response.text
|
||||
with pytest.raises(UploadError):
|
||||
upload_page.submit_upload(filename="bad.jpg", file_bytes=b"")
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user