12 Commits
69 changed files with 4532 additions and 325 deletions
+44 -1
View File
@@ -34,19 +34,62 @@ Optional settings (defaults shown):
DATABASE_URL=sqlite:///./transcription.db DATABASE_URL=sqlite:///./transcription.db
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
PROMPT_DIR=./prompts PROMPT_DIR=./prompts
MAX_UPLOAD_BYTES=15728640
OPERATOR_ACCESS_ENABLED=false
OPERATOR_USERNAME=operator
# OPERATOR_PASSWORD=replace_with_secure_value
``` ```
### 3) Run the app ### 3) Run the app
```bash ```bash
uv run uvicorn transcription.app:create_app --factory --reload uv run uvicorn transcription.app:create_app --factory --reload
``` ```
### 4) Open in browser ### 4) (Optional) Run explicit migrations/checks
Use the migration runner for Step 4 schema safety workflows:
```bash
uv run python -m transcription.migration_runner --list
uv run python -m transcription.migration_runner --apply
uv run python -m transcription.migration_runner --check
```
### 5) Open in browser
- GUI: [http://[IP_ADDRESS]:8000/ui](http://[IP_ADDRESS]:8000/ui) - GUI: [http://[IP_ADDRESS]:8000/ui](http://[IP_ADDRESS]:8000/ui)
- Health check: [http://[IP_ADDRESS]:8000/healthz](http://[IP_ADDRESS]:8000/healthz) - Health check: [http://[IP_ADDRESS]:8000/healthz](http://[IP_ADDRESS]:8000/healthz)
### Schema safety settings
Optional environment settings (defaults shown):
```env
MIGRATION_AUTO_APPLY_ON_STARTUP=false
VALIDATE_SCHEMA_ON_STARTUP=true
```
### Step 5 security settings
Use this baseline for trusted private-network operation:
```env
OPERATOR_ACCESS_ENABLED=true
OPERATOR_USERNAME=operator
OPERATOR_PASSWORD=replace_with_strong_local_secret
MAX_UPLOAD_BYTES=15728640
```
Notes:
- `/healthz` remains unauthenticated for operational checks.
- `/ui` and `/api` require HTTP Basic credentials when operator access is enabled.
- Keep `OPERATOR_PASSWORD` in environment variables only (never commit secrets).
## How to navigate the GUI ## How to navigate the GUI
- **Upload page** (`/ui`) - **Upload page** (`/ui`)
+2 -4
View File
@@ -6,7 +6,7 @@ This project is a production application for transcribing and preserving histori
Read [architecture.md](architecture.md) first. Read [architecture.md](architecture.md) first.
Then review [ver1/ver1.md](ver1/ver1.md) for completion scope and [ver1/ver1-step1-results.md](ver1/ver1-step1-results.md) for current architecture-consolidation status. Then review [ver1/ver1.md](ver1/ver1.md) for completion scope.
The architecture page is the primary technical reference and defines: The architecture page is the primary technical reference and defines:
@@ -41,10 +41,8 @@ This operating model keeps deployment and maintenance simple while preserving cl
## Documentation Map ## Documentation Map
- Architecture and technical design: [architecture.md](architecture.md)
- Version 1 implementation plan: [ver1/ver1.md](ver1/ver1.md) - Version 1 implementation plan: [ver1/ver1.md](ver1/ver1.md)
- Version 1 Step 1 plan: [ver1/ver1-step1.md](ver1/ver1-step1.md) - Architecture and technical design: [architecture.md](architecture.md)
- Version 1 Step 1 results: [ver1/ver1-step1-results.md](ver1/ver1-step1-results.md)
- Architecture decision records (ADR index): [adr/README.md](adr/README.md) - Architecture decision records (ADR index): [adr/README.md](adr/README.md)
- Runtime and deployment requirements: [requirements.md](requirements.md) - Runtime and deployment requirements: [requirements.md](requirements.md)
- Error handling policy and operational guidance: [error_handling.md](error_handling.md) - Error handling policy and operational guidance: [error_handling.md](error_handling.md)
@@ -0,0 +1,73 @@
# Ver1 Step 1/2 Carry-Forward Checklist
## Purpose
Track open Step 1 and Step 2 follow-ups through later V1 steps, with lightweight verification evidence and requirement traceability.
This artifact implements the carry-forward approach defined in:
- `docs/ver1/ver1-step1-2_revised.md`
Historical records remain unchanged:
- `docs/ver1/ver1-step1.md`
- `docs/ver1/ver1-step1-results.md`
- `docs/ver1/ver1-step2.md`
- `docs/ver1/ver1-step2-results.md`
---
## Status Legend
- `not started`
- `in progress`
- `done`
- `deferred`
---
## Carry-Forward Mapping Matrix
| ID | Carry-Forward Task | Source | Related REQ | Owning V1 Step(s) | Validation Method | Status | Evidence Link/Note |
| --- | --- | --- | --- | --- | --- | --- | --- |
| CF-A1 | Confirm remaining implicit/global runtime ownership and lift only high-impact resources to lifespan ownership | Step 1 residual follow-up | REQ-7 | Step 3, Step 9 | Inspection + test | in progress | Step 3 added `services/library.py` and `api/routes.py` using existing service/session access patterns; no new module-global runtime resource ownership introduced. Reconfirm in Step 9 release readiness. |
| CF-A2 | Finalize migration + rollback runbook usage and rehearse on representative local data | Step 1 residual follow-up | REQ-10 | Step 4, Step 9 | Demonstration + test | not started | |
| CF-A3 | Maintain lightweight boundary enforcement (review checklist and/or simple import checks) | Step 1 residual follow-up | REQ-7, REQ-11 | Step 3, Step 7 | Inspection | in progress | Step 3 implementation keeps UI/API composition thin and pushes revision/search/export logic to `services/library.py`; continue with Step 7 checks. |
| CF-B1 | Build compact error-path inventory for major failure paths and category mapping | Step 2 governance follow-up | REQ-2, REQ-3, REQ-4, REQ-5 | Step 6, Step 7 | Inspection | not started | Use `docs/ver1/ver1-step2-error-path-inventory.md` |
| CF-B2 | Standardize required logging fields at critical boundary handoffs | Step 2 residual follow-up | REQ-3, REQ-4, REQ-8 | Step 6 | Inspection + test | not started | |
| CF-B3 | Revisit retry backoff strategy only if observed runtime behavior justifies extra complexity | Step 2 residual follow-up | REQ-2, REQ-6 | Step 6, Step 8 | Analysis + test | deferred | Keep fixed backoff unless evidence suggests change |
| CF-C1 | Integrate Step 1/2 completed outcomes and open follow-ups into V1 traceability tracking | Revision-plan workstream | REQ-0..REQ-12 (traceability) | Step 3, Step 10 | Inspection | done | Step 3 artifacts added: `docs/ver1/ver1-step3.md`, `docs/ver1/ver1-step3-results.md`, and this checklist updated with Step 3 evidence and routing. |
| CF-C2 | Keep carry-forward routing aligned with revised V1 plan (architecture via 3/4/9, reliability via 6/7) | Revision-plan workstream | REQ-0..REQ-12 (execution alignment) | Step 3+ | Inspection | in progress | Step 3 execution followed routing: functional features implemented in Step 3; migration/rollback items remain in Step 4/9; logging/error-path standardization remains Step 6/7. |
---
## Execution Notes
### Step 3 (Functional Completion)
- Use CF-A1 and CF-A3 during requirement-slice implementation reviews.
- Record any discovered boundary/runtime ownership gaps in this checklist.
### Step 4 (Data Model and Migration Safety)
- Execute CF-A2 rehearsal and link evidence (commands, runbook notes, outcomes).
### Step 6 (Minimal Observability & Operability)
- Execute CF-B1 and CF-B2 with focused artifacts and log-field verification.
### Step 7 (Test Coverage and Practical Quality Gates)
- Add/verify tests supporting CF-A3 and CF-B1/B2 where meaningful.
### Step 8 (Performance Validation)
- Reassess CF-B3 only if retries/backoff are observed to cause practical issues.
### Step 9 (Release Readiness)
- Reconfirm CF-A1/A2 readiness in release checklist and rollback drill.
### Step 10 (Documentation Completion)
- Ensure final V1 docs reference outcomes from this checklist where relevant.
---
## Acceptance Check for Carry-Forward Completion
- [ ] Historical Step 1/2 documents remain unchanged.
- [ ] Every open Step 1/2 follow-up has an owning V1 step and validation method.
- [ ] Evidence links are recorded for each completed carry-forward item.
- [ ] No carry-forward item introduces unnecessary complexity for personal-scale operation.
+166
View File
@@ -0,0 +1,166 @@
# Ver1 Step 1 & Step 2 Revision Plan (Additive)
## Purpose
Define a **targeted implementation follow-through plan** for Step 1 and Step 2 outcomes so remaining V1 work stays aligned with `docs/ver1/ver1.md`:
- personal-scale operation
- single operator
- private-network assumptions
- low operational overhead
- practical, testable controls
This document is additive and does **not** replace or revise historical Step 1/Step 2 records.
---
## Source Documents Reviewed
- `docs/ver1/ver1.md`
- `docs/ver1/ver1-step1.md`
- `docs/ver1/ver1-step1-results.md`
- `docs/ver1/ver1-step2.md`
- `docs/ver1/ver1-step2-results.md`
- `docs/architecture.md`
- `docs/error_handling.md`
- `docs/requirements.md`
- `docs/index.md`
- `docs/intent.md`
---
## Revision Goals
1. Preserve all completed Step 1/Step 2 technical hardening work.
2. Keep historical Step 1/Step 2 documents unchanged.
3. Convert residual risks/follow-ups into concrete implementation tasks for subsequent V1 steps.
4. Preserve traceability to requirements and implemented evidence.
5. Maintain alignment with personal-scale architecture and operating model.
---
## Scope
### In Scope
- Define carry-forward implementation tasks based on Step 1/2 residual risks and open items.
- Map carry-forward tasks to later V1 steps (especially Steps 3, 4, 6, 7, and 9).
- Define lightweight verification evidence expected for each carry-forward task.
- Update V1 traceability references to include completed Step 1/2 outcomes and deferred follow-ups.
### Out of Scope
- Simplifying tone/structure of existing Step 1/2 documents
- Clarifying or rewriting historical Step 1/2 plan/results content
- Editing `docs/ver1/ver1-step1.md`
- Editing `docs/ver1/ver1-step1-results.md`
- Editing `docs/ver1/ver1-step2.md`
- Editing `docs/ver1/ver1-step2-results.md`
- Re-implementing Step 1/2 code changes
- Rewriting `docs/ver1/ver1.md`
- Deleting historical sections/results
- Altering requirements IDs or architecture principles
---
## Carry-Forward Implementation Plan
## Workstream A — Close Step 1 follow-ups through later V1 steps
### A1) Runtime ownership completion (REQ-7 continuity)
- Confirm whether any remaining runtime resources still use implicit/global ownership.
- Move only high-impact remaining resources to explicit lifespan ownership when needed.
- Keep ownership model simple and documented.
### A2) Schema/migration operations readiness (REQ-10 continuity)
- Finalize practical migration + rollback runbook usage in Step 4 execution.
- Rehearse upgrade and rollback on representative local data.
- Keep production startup free from implicit schema mutation.
### A3) Boundary enforcement (lightweight only)
- Keep architecture boundary checks lightweight (review checklist and/or simple import checks).
- Avoid heavy governance tooling unless clear recurring drift appears.
### Expected Outcome
Step 1 architecture hardening remains intact and is completed pragmatically where open items remain.
---
## Workstream B — Close Step 2 follow-ups through later V1 steps
### B1) Error-path inventory and coverage visibility
- Create a compact error-path inventory artifact (or equivalent matrix section) covering major failure paths.
- Ensure each critical path maps to category, retriable policy, and surfaced behavior.
### B2) Logging field consistency at key boundaries
- Standardize required fields at critical failure handoffs (`error_id`, `category`, `operation`, identifiers when available).
- Prioritize worker/API/service boundaries first.
### B3) Retry policy refinement (only if needed)
- Keep current bounded retry baseline.
- Revisit richer backoff strategy only if observed behavior justifies added complexity.
### Expected Outcome
Step 2 reliability behavior stays stable, diagnosable, and right-sized for personal-scale operation.
---
## Workstream C — Integrate Step 1/2 outputs into ongoing V1 governance
### C1) Traceability integration
- Link completed Step 1/2 outcomes and deferred follow-ups to the V1 traceability matrix.
- Ensure open follow-ups have owning step and validation method.
### C2) Execution alignment with revised V1 plan
- Route architecture follow-ups primarily through Steps 3/4/9.
- Route reliability/diagnostics follow-ups primarily through Steps 6/7.
### Expected Outcome
Step 1/2 work is fully carried forward without revising historical documents.
## Deliverables
1. This document (`docs/ver1/ver1-step1-2_revised.md`) as the carry-forward implementation plan.
2. A compact Step 1/2 carry-forward checklist linked to V1 steps and validation methods.
3. Traceability updates showing where each open Step 1/2 follow-up will be closed.
4. Optional new artifact for error-path inventory (if created during Step 6/7 execution).
---
## Acceptance Criteria
- Historical Step 1/Step 2 documents remain unchanged.
- Open Step 1/2 follow-ups are explicitly mapped to later V1 steps with validation expectations.
- No loss of core technical intent (REQ-7, REQ-10, error taxonomy, retry safety, traceability).
- No conflicts introduced with `docs/architecture.md`, `docs/error_handling.md`, or `docs/ver1/ver1.md`.
- Carry-forward tasks remain right-sized for personal-scale operation.
---
## Implementation Order
1. Keep existing Step 1/Step 2 docs unchanged as historical records.
2. Define carry-forward tasks and owning V1 steps in this document.
3. Create and maintain carry-forward traceability artifacts:
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md`
- `docs/ver1/ver1-step2-error-path-inventory.md`
4. Execute carry-forward tasks during Steps 3+ and capture evidence in step results docs.
5. Perform final consistency pass across `docs/ver1/*` references.
---
## Risks and Mitigations
1. **Risk:** Open Step 1/2 items are forgotten as Step 3+ work proceeds.
**Mitigation:** Track each follow-up in the V1 traceability matrix with owning step and evidence expectation.
2. **Risk:** Carry-forward work expands beyond personal-scale needs.
**Mitigation:** Apply simplicity guardrails from `docs/architecture.md` before accepting additional hardening tasks.
3. **Risk:** Reliability follow-ups become fragmented across multiple steps.
**Mitigation:** Keep one consolidated carry-forward checklist and update it at milestone check-ins.
---
## Notes
This revision effort is scope-alignment and implementation-follow-through focused.
Historical Step 1/Step 2 documents are intentionally preserved as-is.
@@ -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`
+160
View File
@@ -0,0 +1,160 @@
# Ver1 Step 3 Results: Functional Completion by Requirement Domain
## Summary
Step 3 implementation has been completed for the planned functional-completion scope in a practical personal-scale form.
Implemented in this step:
1. Revision history and acceptance workflows for transcripts.
2. Search over accepted transcript revisions.
3. Export of accepted transcript data.
4. API routes for jobs, revisions, search, and export.
5. UI pathways for revision management, search, and export.
6. Carry-forward integration updates for Step 1/2 follow-ups owned by Step 3.
---
## Implemented Changes
### 1) Data model expansion (functional domain)
Updated `src/transcription/models.py`:
- Added `JobStatus.COMPLETED`.
- Added `TranscriptRevision` table/model:
- `job_id`
- `revision_number`
- `text`
- `source`
- `accepted`
- `created_at`
- Added `Job.revisions` relationship.
This supports immutable revision history and accepted-transcript semantics for search/export.
### 2) Step 3 service layer
Created `src/transcription/services/library.py` with service-backed functional operations:
- `list_jobs(...)`
- `get_job_detail(...)`
- `add_revision(...)`
- `accept_revision(...)`
- `list_revisions(...)`
- `search_accepted_transcripts(...)`
- `export_transcripts(...)`
Key behavior:
- revisions are append-only and incrementing
- accepted revision is unique per job
- accepting a revision syncs canonical transcript and sets job to `completed`
- search scope is accepted revisions only
- export emits deterministic record payloads for archive workflows
### 3) Worker integration for revision provenance
Updated `src/transcription/worker.py`:
- Success path now calls `add_revision(..., source="worker", accepted=False)`.
- Worker still persists canonical transcript and `transcribed` job state.
- Initial machine transcription now appears in revision history.
### 4) API functional completion
Created `src/transcription/api/routes.py` and wired in `src/transcription/app.py`.
New endpoints:
- `GET /api/jobs`
- `GET /api/jobs/{job_id}`
- `GET /api/jobs/{job_id}/revisions`
- `POST /api/jobs/{job_id}/revisions`
- `POST /api/revisions/{revision_id}/accept`
- `GET /api/search?query=...`
- `GET /api/export?accepted_only=true|false`
### 5) UI functional completion
Updated `src/transcription/ui/jobs_page.py`:
- Job detail now includes revision history panel.
- Added user revision submission.
- Added revision accept action.
- Added `/search` page for accepted transcript search.
- Added `/export` page for accepted transcript export preview.
---
## Test Evidence
### Added/Updated Tests
1. `tests/services/test_library.py`
- revision append/accept behavior
- accepted-only search behavior
- export payload behavior
2. `tests/api/test_routes.py`
- jobs/revisions/search/export API serialization and contract behavior
3. `tests/test_models.py`
- `completed` status transition coverage
- `TranscriptRevision` persistence and relationship coverage
4. `tests/services/test_worker.py`
- success-path now verifies initial worker-generated revision persistence
### Full Validation Run
Executed and passing:
- `uv run pytest -q`
---
## Requirement Slice Coverage (Step 3)
| Slice | REQ Coverage | Status | Evidence |
| --- | --- | --- | --- |
| Core lifecycle completion and visibility | REQ-0, REQ-2, REQ-3, REQ-5, REQ-6 | met | worker integration + API/UI jobs routes + tests |
| Revision history and acceptance | REQ-3, REQ-4, REQ-5, REQ-11 | met | `TranscriptRevision`, `services/library.py`, UI revision panel, tests |
| Search over accepted transcripts | REQ-5, REQ-11 | met | `search_accepted_transcripts`, `/api/search`, `/ui/search`, tests |
| Export transcript data | REQ-4, REQ-5, REQ-11 | met | `export_transcripts`, `/api/export`, `/ui/export`, tests |
| Prompt and verbatim flow continuity | REQ-12 | met (continued) | worker transcription flow unchanged in prompt-loading contract |
---
## Carry-Forward Integration Updates
Updated:
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md`
Step 3 updates recorded for:
- CF-A1: in progress with Step 3 inspection evidence
- CF-A3: in progress with boundary-discipline evidence
- CF-C1: done (Step 3 traceability artifacts integrated)
- CF-C2: in progress (routing preserved for later steps)
---
## Residual Follow-ups
1. Step 4: migration rehearsal and rollback runbook execution for schema changes.
2. Step 6/7: broader error-path inventory closure and logging field normalization.
3. Step 9: release readiness reconfirmation for runtime ownership and migration behavior.
---
## Step 3 Exit Assessment
- Requirement-domain functional completion: **met**
- Data integrity and state consistency for new flows: **met**
- API/UI parity for new Step 3 features: **met**
- Test and regression safety: **met**
- Carry-forward integration obligations (Step 3-owned): **met/in progress as routed**
Step 3 is complete and ready to hand off to Step 4.
+433
View File
@@ -0,0 +1,433 @@
# Step 3 Implementation Plan: Functional Completion by Requirement Domain
## Purpose
Implement **Ver1 Step 3** from `docs/ver1/ver1.md` by completing all in-scope V1 functional requirements in a practical, user-first order while preserving:
- personal-scale operation
- single-operator workflow
- private-network deployment assumptions
- low operational overhead
- clean architecture boundaries
Primary governing docs:
- `docs/ver1/ver1.md` (Step 3 objective and sequencing)
- `docs/architecture.md` (module boundaries, workflow, simplicity guardrails)
- `docs/requirements.md` (REQ-0 through REQ-12 traceability)
- `docs/error_handling.md` (error contract across boundaries)
- `docs/intent.md` (verbatim transcription policy and prompt curation)
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md` (Step 1/2 carry-forward integration)
- `docs/ver1/ver1-step2-error-path-inventory.md` (failure-path coverage visibility)
---
## MCP Resources Reviewed and Applied
All resources on `john-stream-mcp` were reviewed. Step 3 applies the following guidance directly:
1. `resource://skills/nicegui/document`
- modular page registration
- one-way dependency flow (`ui/api -> services -> infra`)
- async-first UI responsiveness expectations
2. `resource://skills/nicegui-ui-customization/document`
- reusable UI component extraction for repeated patterns
- in-flight guards and explicit success/failure user feedback
- event-driven updates over ad-hoc polling
3. `resource://skills/fastapi-async-sqlalchemy-modernization/document`
- explicit transaction/session boundaries
- deterministic resource ownership and cleanup continuity from Step 1
- incremental migration strategy with rollback-aware checkpoints
4. `resource://skills/pydantic-settings/document`
- typed configuration as single source of runtime truth
- explicit source precedence and environment-safe defaults
5. `resource://skills/python-logging-dictconfig/document`
- centralized startup-only logging configuration
- named logger discipline and boundary-level structured fields
6. `resource://skills/pytesting/document`
- deterministic test structure and marker discipline
- behavior-first tests with clear fast-path and full-suite validation
7. `resource://skills/fastapi-uv-docker/document`
- health endpoint and runtime startup/shutdown hygiene
- compose/deployment readiness constraints relevant to functional completion
8. `resource://skills/python-typing/document`
- modern typing updates where touched by Step 3 work
9. `resource://skills/ruff-linting-formating/document`
- maintain lint/format consistency in all modified modules
10. `resource://prompts/greenfield-architecture/document`
- explicit staged delivery with tradeoff-aware sequencing and test strategy
11. `resource://prompts/pytest-scaffold/document`
12. `resource://prompts/pytest-fill-scaffold/document`
- structure-first test planning, then deterministic implementation fill-in
Resources reviewed but not directly in Step 3 execution scope (no changes required now):
- `copilot-customization`, `mcp-details`, `vscode-configuration`, `zensical-docs`
- prompts: `authoring`, `mcp-consumer-repo-shim`
---
## Step 3 Success Criteria
Step 3 is complete when:
1. All Step 3-targeted requirement slices are implemented and verified.
2. Functional behavior is available through UI/API where required.
3. Core data integrity and state transitions are deterministic.
4. Error behavior follows `docs/error_handling.md` contracts.
5. Carry-forward Step 1/2 items mapped to Step 3 are updated with evidence.
---
## Requirement-Slice Execution Model (Applied to Every Slice)
For each slice, execute this sequence:
1. Confirm contract/schema and boundary ownership.
2. Implement service/domain logic.
3. Implement persistence/state transitions.
4. Integrate API and/or UI behavior.
5. Add/update unit + integration + targeted end-to-end tests.
6. Update docs and traceability artifacts.
Definition of done per slice:
- behavior is functional
- tests pass in intended marker lanes
- error pathways are classified and surfaced correctly
- requirement traceability is updated with evidence
---
## Detailed Workstreams
## Workstream A — Functional Baseline Audit and Slice Backlog Lock
### Goals
- establish exact Step 3 functional delta from current implementation
- lock a practical slice backlog before coding
### Tasks
1. Build Step 3 requirement matrix (REQ -> current status -> gap -> target slice).
2. Map each gap to one of these domains:
- Upload and lifecycle integrity
- Review and revision history
- Search over accepted transcripts
- Export workflows
- Prompt asset management behavior
- API/UI parity and status visibility
3. Align each slice with architecture boundary ownership and persistence strategy.
4. Link open carry-forward items from checklist:
- CF-A1, CF-A3 (architecture continuity in Step 3)
- CF-C1, CF-C2 (traceability/execution continuity)
### Deliverables
- Step 3 requirement-slice matrix (appendix in this doc or separate artifact)
- prioritized slice backlog with owner and validation method
### Exit Criteria
- every Step 3 slice maps to REQ IDs and a validation method
- no ambiguous ownership remains for in-scope slices
---
## Workstream B — Core End-User Flows (Upload -> Transcribe -> Review)
### Related Requirements
- REQ-0, REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-6, REQ-12
### Goals
- guarantee end-to-end reliability and usability of the primary user flow
- ensure review experience supports transcript acceptance and correction
### Tasks
1. Validate and close any lifecycle-state gaps:
- enforce valid transitions (`queued -> processing -> transcribed/failed/completed`)
- ensure transition visibility in UI/API
2. Review experience completion:
- transcript detail display stability
- failure detail readability and actionability
- acceptance/edit path for human review
3. Ensure prompt-asset integration remains file-based and auditable:
- one prompt per Markdown file
- prompt selection/usage traceability in job outcomes (if available in model)
4. Confirm worker/UI interactions remain responsive under long-running jobs:
- in-flight guards
- clear status refresh behavior
### Deliverables
- complete end-user flow behavior with stable lifecycle visibility
- test coverage for happy path and failure path
### Exit Criteria
- user can run upload -> process -> review reliably
- failed and successful outcomes are both actionable and traceable
---
## Workstream C — Revision History and Provenance Completion
### Related Requirements
- REQ-3, REQ-4, REQ-5, REQ-11
### Goals
- finalize immutable transcript revision behavior and provenance consistency
### Tasks
1. Define/confirm revision invariants:
- append-only revision history
- clear current/accepted revision indicator
2. Persist revision events consistently through service layer boundaries.
3. Ensure UI/API expose revision timeline and selected revision details.
4. Align error handling for revision conflicts and missing resources.
### Deliverables
- revision-history feature completeness
- provenance and history read-path coverage
### Exit Criteria
- transcript edits produce deterministic revision records
- previous revisions remain inspectable
---
## Workstream D — Search Completion (Accepted Transcript Scope)
### Related Requirements
- REQ-0, REQ-5, REQ-11
### Goals
- provide practical search over accepted transcripts for personal corpus usage
### Tasks
1. Finalize searchable scope and indexing rules (accepted/current text only).
2. Implement service-backed search query behavior.
3. Expose search in UI/API with clear result metadata (document/job/revision context).
4. Add guardrails for empty/no-result/error scenarios with actionable messaging.
### Deliverables
- functional search pathway with deterministic results for accepted text
### Exit Criteria
- operator can find transcripts reliably by text queries
- no-result and error states are clear and non-silent
---
## Workstream E — Export Completion
### Related Requirements
- REQ-0, REQ-4, REQ-5, REQ-11
### Goals
- deliver practical export of transcript data for personal archive use
### Tasks
1. Finalize export contract (format, included fields, scope filters).
2. Implement export service with deterministic data mapping.
3. Add UI/API trigger path and user-visible completion/failure feedback.
4. Validate export integrity against persisted source-of-record entities.
### Deliverables
- end-to-end export capability with operator-visible outcomes
### Exit Criteria
- export output is complete, consistent, and usable for downstream personal archive workflows
---
## Workstream F — API/UI Parity and Interaction Hardening
### Related Requirements
- REQ-5 plus cross-cutting REQ-2/3/4
### Goals
- ensure UI and API expose coherent feature behavior and error contracts
### Tasks
1. Verify API/UI parity matrix for each Step 3 slice.
2. Standardize interaction behavior:
- loading and in-flight states
- success/failure notifications
- stable error_id visibility where user-facing
3. Ensure route/page modules remain composition-focused (business logic in services).
### Deliverables
- API/UI parity checklist with resolved gaps
### Exit Criteria
- no major flow exists in one interface with conflicting semantics in the other
---
## Workstream G — Carry-Forward Integration During Step 3
### Goals
- close Step 1/2 follow-ups that are Step 3-owned
### Tasks
1. Update checklist item CF-A1 as Step 3 slices touch runtime resources.
2. Update checklist item CF-A3 with lightweight boundary enforcement evidence.
3. Update CF-C1/CF-C2 traceability mapping with Step 3 outcomes.
### Deliverables
- updated `docs/ver1/ver1-step1-2-carry-forward-checklist.md` evidence entries
### Exit Criteria
- Step 3-owned carry-forward items are either completed or explicitly routed with evidence
---
## Test and Validation Plan
Apply `pytesting` guidance with deterministic, behavior-focused coverage.
### Validation Lanes
1. Structure/collection:
- `uv run pytest --collect-only -q`
2. Fast feedback lane:
- `uv run pytest -m unit -q`
3. Main verification lane:
- `uv run pytest -m "not external" -q`
4. Full suite:
- `uv run pytest -q`
### Required Coverage Areas
- lifecycle transition invariants
- revision history invariants
- search query behavior and result mapping
- export integrity and failure handling
- UI interaction guards and actionable failure feedback
- API envelope and status consistency for new/changed flows
### Test Design Rules
- one behavior target per test
- minimize heavy mocking; prefer real-path behavior checks where practical
- keep markers explicit and strict
---
## Logging, Error, and Config Guardrails for Step 3 Changes
1. Logging
- keep centralized startup logging config (`dictConfig`) as canonical
- include required error fields at boundary failures (`error_id`, `category`, `operation`, identifiers where available)
2. Error handling
- preserve taxonomy stability from `docs/error_handling.md`
- map any new failure pathways into existing categories
- surface actionable suggestions in UI/API
3. Configuration
- use typed settings and avoid ad-hoc env reads in business modules
- keep environment behavior explicit and documented
---
## Implementation Order (Detailed)
1. Workstream A: audit and backlog lock
2. Workstream B: core flow completion
3. Workstream C: revision/provenance completion
4. Workstream D: search completion
5. Workstream E: export completion
6. Workstream F: API/UI parity hardening
7. Workstream G: carry-forward integration updates
8. Full validation pass + docs/traceability updates
---
## Deliverables
1. Step 3 requirement-slice matrix with REQ mapping and evidence links
2. implemented Step 3 functional slices across service/persistence/API/UI
3. updated tests and passing validation lanes
4. updated carry-forward checklist entries (`CF-A1`, `CF-A3`, `CF-C1`, `CF-C2` as applicable)
5. Step 3 results document (`docs/ver1/ver1-step3-results.md`)
---
## Risks and Mitigations
1. **Risk:** Scope creep from optional enhancements during feature completion
- **Mitigation:** enforce REQ-mapped slice backlog and defer non-REQ enhancements
2. **Risk:** Functional parity drift between UI and API
- **Mitigation:** maintain parity matrix and verify both surfaces per slice
3. **Risk:** Data-model changes introduce migration surprises
- **Mitigation:** coordinate with Step 4 runbook expectations early and test on representative data
4. **Risk:** Reliability regressions while adding functionality
- **Mitigation:** run full error-path regression checks and keep Step 2 contracts intact
---
## Step 3 Completion Checklist
- [ ] Step 3 requirement-slice matrix completed and linked to REQ IDs.
- [ ] Core end-user flow is functionally complete and verified.
- [ ] Revision history/provenance behavior is complete and test-covered.
- [ ] Search over accepted transcripts is complete and test-covered.
- [ ] Export flow is complete and test-covered.
- [ ] API/UI parity checklist has no unresolved high-impact gaps.
- [ ] Step 3-owned carry-forward items are updated with evidence.
- [ ] Validation lanes pass (`collect-only`, unit, non-external, full).
- [ ] `docs/ver1/ver1-step3-results.md` is created with evidence and residual follow-ups.
---
## Handoff to Step 4
Step 3 completion enables Step 4 (Data Model and Migration Safety) with:
- finalized functional domain behavior
- stable persistence expectations
- traceable requirement evidence
- clarified migration-impact surface
+113
View File
@@ -0,0 +1,113 @@
# Ver1 Step 4 Migration and Rollback Runbook
## Purpose
Provide a concise, operator-safe procedure for schema migration execution,
compatibility validation, and rollback/mitigation for personal-scale deployments.
This runbook supports `docs/ver1/ver1-step4.md` and REQ-10 by keeping normal
production startup non-mutating unless explicitly configured otherwise.
---
## Preconditions
1. Application version to deploy is known and checked out.
2. `.env` values are configured for target environment.
3. Database backup path is prepared.
4. Application process is stopped before migration on production-like systems.
---
## Commands
Use explicit migration runner operations:
1. List pending migrations:
- `uv run python -m transcription.migration_runner --list`
2. Apply pending migrations:
- `uv run python -m transcription.migration_runner --apply`
3. Validate schema compatibility:
- `uv run python -m transcription.migration_runner --check`
Recommended execution order:
1. `--list`
2. backup database
3. `--apply`
4. `--check`
5. start application
---
## Backup Procedure (SQLite Baseline)
For SQLite deployments, copy the DB file before migration:
- Example DB path default: `./transcription.db`
- Keep timestamped backup copy in a safe location.
If the file is in active use, stop the app first.
---
## Verification Checklist
After migration apply:
1. `--check` exits successfully.
2. `schema_migration_history` includes applied revisions.
3. Application starts successfully.
4. Health endpoint responds: `/healthz`.
5. Critical flows smoke-check:
- upload
- job processing
- revision listing/acceptance
---
## Rollback and Mitigation Decision Tree
1. If migration fails before changes commit:
- fix issue
- re-run apply
2. If migration partially applied or compatibility check fails:
- stop app
- restore from backup
- investigate and produce forward-fix migration if needed
3. If app starts but functional invariants fail:
- stop app
- restore backup
- add corrective migration/backfill and rehearse before retry
For this Step 4 baseline, backup restore is the primary rollback mechanism.
---
## Failure Classification Guidance
Classify migration failures using `docs/error_handling.md` categories:
- transient connection issues -> `infrastructure_transient_error`
- permissions/misconfiguration -> `infrastructure_persistent_error`
- unexpected migration logic defects -> `internal_unexpected_error`
Record failure details with operation context and timestamp.
---
## Operational Notes
- `migration_auto_apply_on_startup` defaults to `False`.
- `validate_schema_on_startup` defaults to `True`.
- Startup schema validation fails fast on incompatibility.
This protects production from accidental schema drift.
---
## Post-Step-4 Follow-Up
If migration complexity grows beyond lightweight revision scripts,
introduce a dedicated migration framework in a future step while preserving
this runbook structure and operator-first workflow.
+153
View File
@@ -0,0 +1,153 @@
# Ver1 Step 4 Results: Data Model and Migration Safety
## Summary
Step 4 implementation status: **complete (baseline scope)**.
This document records completed migration-safety work, validation evidence, and remaining follow-ups for Ver1 Step 4.
Implemented in this step:
1. Added explicit migration framework module with revision history tracking.
2. Added schema compatibility validation and startup guardrails.
3. Added migration runner CLI for list/apply/check operations.
4. Added migration tests and Step 4 validation evidence.
5. Added Step 4 migration/rollback runbook.
---
## Implemented Changes
### 1) Schema audit and invariant lock
Implemented read-only compatibility checks in `src/transcription/db.py`:
- `validate_schema_compatibility(...)` verifies required V1 tables:
- `document`
- `job`
- `transcript`
- `transcriptrevision`
- verifies required `job.retry_count` column
- returns explicit issue identifiers (non-mutating check)
### 2) Migration policy/tooling lock
Added explicit migration revision model in `src/transcription/migrations.py`:
- `MigrationRevision` dataclass
- ordered `MIGRATIONS` registry
- migration history table: `schema_migration_history`
- explicit pending-list and apply operations
### 3) Forward migration implementation
Implemented two baseline forward migrations:
1. `0001_add_retry_count_to_job`
2. `0002_create_transcriptrevision_table`
Each migration is idempotent and recorded in migration history.
### 4) Rollback and mitigation runbook
Created `docs/ver1/ver1-step4-migration-runbook.md` with:
- preconditions
- list/apply/check command sequence
- backup-first procedure
- verification checklist
- rollback/mitigation decision tree
- error classification guidance aligned to `docs/error_handling.md`
### 5) Backfill implementation or explicit no-backfill decision
No backfill required for this baseline Step 4 scope.
Rationale:
- additive migration operations only
- default values and new-table creation do not require historical row rewrites for current V1 invariants
- residual advanced backfill scenarios deferred unless future schema evolution introduces incompatible transforms
---
## Test and Verification Evidence
### Added/Updated Tests
1. `tests/test_migrations.py`
- pending migration discovery
- migration apply + history recording
- idempotent re-apply behavior
2. `tests/test_db.py`
- compatibility-check behavior on fresh schema
- table expectation updates for `transcriptrevision`
3. `tests/test_config.py`
- migration safety setting defaults
4. `tests/test_app.py`
- lifespan test compatibility with migration/validation startup hooks
### Validation Runs
Run and record outcomes:
- `uv run pytest --collect-only -q` -> passed
- `uv run pytest -m unit -q` -> passed
- `uv run pytest -m "not external" -q` -> passed
- `uv run pytest -q` -> passed
### Migration Rehearsal Evidence
Migration rehearsal details (test-based):
- baseline data set used: in-memory SQLite legacy-shaped schema fixture (`job` table missing Step 4 additions)
- forward migration result: pending revisions applied successfully (`0001`, `0002`)
- post-migration verification result: schema checks pass and migration history recorded
- rollback/mitigation rehearsal result: runbook defined backup-restore primary rollback class for personal-scale SQLite deployment
---
## Requirement Traceability (Step 4)
| Step 4 Area | REQ Coverage | Status | Evidence |
| --- | --- | --- | --- |
| Schema lifecycle and state persistence safety | REQ-3, REQ-4, REQ-11 | met | `src/transcription/migrations.py`, `tests/test_migrations.py`, `tests/test_db.py` |
| Lifespan/runtime ownership continuity | REQ-7 | met | `src/transcription/app.py` startup checks + existing lifespan ownership model |
| Explicit non-mutating production startup policy | REQ-10 | met | `migration_auto_apply_on_startup=False` default + explicit runner workflow + startup validation gate |
| Prompt/data continuity constraints | REQ-12 | met (continued) | no prompt-contract mutation in Step 4 changes |
---
## Operational Artifacts Produced
- `docs/ver1/ver1-step4.md`
- `docs/ver1/ver1-step4-migration-runbook.md`
- `src/transcription/migrations.py`
- `src/transcription/migration_runner.py`
- README migration workflow updates
---
## Risks, Exceptions, and Follow-Ups
1. This lightweight migration system is appropriate for current personal-scale scope but may require a dedicated framework as schema complexity grows.
2. Rollback remains backup-restore primary; reversible down-migration coverage is intentionally limited in this baseline.
3. Startup compatibility checks currently fail fast with generic runtime error text and can be further normalized under API/operator error envelopes in later hardening.
Open follow-ups to carry forward:
- Evaluate migration framework escalation criteria in Step 9/10 readiness updates.
- Add optional richer structured migration logging fields if observability scope expands.
---
## Step 4 Exit Assessment
- Schema validation against finalized V1 domain: **met**
- Forward migration path safety and repeatability: **met (baseline scope)**
- Rollback/mitigation readiness: **met (backup-restore primary path)**
- Backfill risk closure: **met (no backfill required for current deltas)**
- Test and regression safety: **met**
Step 4 completion status: **complete (baseline scope)**
---
## Handoff to Step 5
Once Step 4 is marked complete, Step 5 can proceed with:
- verified migration safety baseline
- explicit rollback and recovery procedures
- reduced data-integrity risk entering private-network safety hardening
+378
View File
@@ -0,0 +1,378 @@
# Step 4 Implementation Plan: Data Model and Migration Safety
## Purpose
Implement **Ver1 Step 4** from `docs/ver1/ver1.md` by making data-model evolution safe, explicit, and repeatable for personal-scale deployment.
Step 4 ensures schema changes are handled through deterministic migration workflows rather than implicit startup mutation, while preserving:
- personal-scale operational simplicity
- single-operator deployment model
- lifecycle-owned runtime resource boundaries
- stable requirement traceability and low rollback risk
Primary governing docs:
- `docs/ver1/ver1.md` (Step 4 objective and sequencing)
- `docs/architecture.md` (runtime ownership, persistence boundaries, simplicity guardrails)
- `docs/requirements.md` (REQ-3, REQ-4, REQ-7, REQ-10, REQ-11, REQ-12 emphasis)
- `docs/error_handling.md` (failure classification and safe error surfacing)
- `docs/intent.md` (verbatim/transcription/revision domain behavior)
---
## MCP Resources Reviewed and Applied
All currently available resources on `john-stream-mcp` were reviewed. Step 4 applies the following guidance directly:
1. `resource://skills/fastapi-async-sqlalchemy-modernization/document`
- explicit engine/session lifecycle ownership
- transaction boundary clarity for schema transitions and backfills
- phased rollout with rollback-aware checkpoints
2. `resource://skills/pydantic-settings/document`
- typed migration/runtime safety settings
- explicit source-precedence behavior for operational toggles
- fail-fast config semantics for unsafe startup paths
3. `resource://skills/pytesting/document`
- deterministic migration verification lanes
- strict marker discipline
- behavior-first test coverage for migration outcomes
4. `resource://skills/python-logging-dictconfig/document`
- startup-centralized logging configuration
- structured migration and rollback event traceability
5. `resource://skills/fastapi-uv-docker/document`
- deployment and rehearsal discipline
- startup/health posture validation during migration windows
6. `resource://skills/python-typing/document`
- modern typing hygiene for touched migration/persistence modules
7. `resource://skills/ruff-linting-formating/document`
- lint/format consistency for migration scripts and database modules
Planning methodology inputs also applied:
8. `resource://prompts/greenfield-architecture/document`
- staged execution with explicit risk and extension handling
9. `resource://prompts/pytest-scaffold/document`
10. `resource://prompts/pytest-fill-scaffold/document`
- test-structure-first and deterministic fill-in sequencing
Reviewed but not directly Step 4 execution-critical:
- skills: `copilot-customization`, `mcp-details`, `nicegui`, `nicegui-ui-customization`, `vscode-configuration`, `zensical-docs`
- prompts: `authoring`, `mcp-consumer-repo-shim`
---
## Current-State Gap Summary (Step 4 Scope)
Based on Step 13 outcomes and current docs/tests:
1. **Bootstrap policy baseline is present**
- Environment-aware schema bootstrap policy exists and aligns with REQ-10 intent.
2. **Functional model expanded in Step 3**
- Revision/acceptance features introduce schema evolution requirements that need formal migration safety rehearsal.
3. **Runbook maturity required**
- Step 4 requires explicit migration + rollback procedures and evidence.
4. **Backfill risk must be evaluated**
- New/changed fields and semantics must be checked for historical data reconciliation needs.
5. **Release-path integration needed**
- Step 4 artifacts must feed Step 9 release readiness and Step 10 docs completion.
---
## Scope for Step 4
### In scope
1. Validate final V1 schema against implemented domain behavior (post-Step 3 reality).
2. Define and implement forward-safe migration path for expected upgrades.
3. Define and document rollback/mitigation strategy for migration failures.
4. Implement backfill scripts only if required, with idempotent behavior.
5. Rehearse migration + rollback locally using representative sample data.
6. Add Step 4-specific verification tests and operational checks.
7. Produce operator-facing migration/rollback runbook and Step 4 results evidence.
### Out of scope
- Distributed/externally orchestrated migration systems
- Major persistence-architecture rewrites beyond V1 scope
- Non-V1 enhancement migrations unrelated to implemented requirement slices
---
## Target Decisions for Step 4
1. **Production startup remains non-mutating by default**
- Preserve REQ-10 posture and avoid implicit schema mutation at normal startup.
2. **Schema changes are explicit operator workflows**
- Migrations run as deliberate operational actions, not hidden side effects.
3. **Migration safety beats migration speed**
- Additive and reversible-first patterns are preferred where possible.
4. **Rollback policy is explicit per change**
- Each migration must declare rollback class:
- direct rollback supported
- forward-fix required
- backup restore required
5. **Backfills are optional and minimal**
- Introduce only when required by correctness/invariants, never by convenience.
6. **Migration observability is mandatory**
- Structured logs include operation, migration identifier, status, and failure classification.
---
## Detailed Work Breakdown
## Phase A — Schema and Domain Invariant Audit
- [ ] **A1. Build canonical V1 schema inventory**
- Enumerate all persisted entities and key fields:
- document records
- jobs and statuses
- transcripts
- transcript revisions
- failure/provenance fields
- [ ] **A2. Validate invariants against implemented behavior**
- Cross-check Step 3 functionality and current domain expectations:
- append-only revision history
- accepted revision semantics
- canonical transcript synchronization behavior
- [ ] **A3. Classify required schema deltas**
- Categorize deltas:
- additive and safe
- compatibility-sensitive
- potentially destructive (must be staged or deferred)
### Deliverables
- `docs/ver1/ver1-step4-schema-audit.md` (recommended)
- schema-delta matrix with risk class and owning module
### Exit Criteria
- all required schema changes have explicit rationale and risk classification
- no ambiguous domain invariant remains
---
## Phase B — Migration Policy and Tooling Lock
- [ ] **B1. Lock migration workflow policy**
- Define canonical migration execution path and artifact conventions.
- [ ] **B2. Define migration authoring checklist**
- Include:
- preconditions
- forward steps
- rollback class
- post-verification checks
- [ ] **B3. Align policy with runtime startup safeguards**
- Ensure production startup remains explicit/non-mutating by default.
- [ ] **B4. Define operator invocation standard**
- One documented command path for local and production-like workflows.
### Deliverables
- migration policy section (this doc + runbook)
- migration authoring/review checklist
### Exit Criteria
- one unambiguous migration process exists and is documented
- startup policy and migration policy are consistent and non-conflicting
---
## Phase C — Forward Migration Implementation
- [ ] **C1. Implement required migration set**
- Build migration artifacts for all approved Step 4 deltas.
- [ ] **C2. Preserve compatibility where needed**
- Use staged expand/contract strategy when direct cutover is unsafe.
- [ ] **C3. Add migration logging checkpoints**
- Log start, phase boundaries, completion, and failure details.
- [ ] **C4. Verify post-migration schema state**
- Confirm expected tables/columns/constraints/indexes are present.
### Deliverables
- migration artifacts/scripts for V1 target schema
- schema verification checklist outputs
### Exit Criteria
- baseline-to-target forward migration executes successfully
- post-migration checks pass deterministically
---
## Phase D — Rollback and Mitigation Strategy
- [ ] **D1. Define rollback classes per migration**
- direct downgrade vs forward-fix vs backup-restore.
- [ ] **D2. Create rollback decision tree**
- trigger conditions, safe stop points, and recovery path.
- [ ] **D3. Align failure classification with `error_handling.md`**
- normalize migration failures into canonical categories:
- `infrastructure_transient_error`
- `infrastructure_persistent_error`
- `internal_unexpected_error` (as needed)
- [ ] **D4. Rehearse rollback flow**
- run at least one migration failure simulation and execute chosen recovery path.
### Deliverables
- rollback/mitigation decision tree
- rehearsal evidence notes
### Exit Criteria
- operator can execute rollback/mitigation without undocumented steps
- migration failure paths are diagnosable and classified
---
## Phase E — Backfill Decision and Execution (Conditional)
- [ ] **E1. Determine backfill necessity**
- inspect whether existing records violate new invariants.
- [ ] **E2. If required, implement idempotent backfill**
- resumable, batch-safe, and deterministic update semantics.
- [ ] **E3. Add post-backfill verification**
- validate:
- revision sequencing integrity
- accepted/current transcript consistency
- job lifecycle consistency
- [ ] **E4. If not required, record explicit “no backfill needed” evidence**
### Deliverables
- backfill script(s) and checklist (if applicable)
- no-backfill rationale artifact (if not applicable)
### Exit Criteria
- required backfills completed and verified OR formally ruled out with evidence
---
## Phase F — Verification and Test Expansion
Apply `pytesting` guidance (deterministic, behavior-first, strict markers).
- [ ] **F1. Migration application tests**
- verify forward migration from representative baseline.
- [ ] **F2. Post-migration schema contract tests**
- verify expected schema shape and key constraints.
- [ ] **F3. Rollback/mitigation tests**
- verify chosen rollback class behavior where practical.
- [ ] **F4. Startup policy regression tests**
- confirm production-mode startup does not mutate schema implicitly.
- [ ] **F5. Backfill behavior tests (if applicable)**
- idempotency and invariants after repeated execution.
### Validation Commands
- `uv run pytest --collect-only -q`
- `uv run pytest -m unit -q`
- `uv run pytest -m "not external" -q`
- `uv run pytest -q`
### Exit Criteria
- all Step 4 migration-safety checks pass
- no REQ-10 regression introduced
---
## Phase G — Runbook and Documentation Closure
- [ ] **G1. Create migration and rollback runbook**
- include:
- prerequisites
- backup step
- migration execution
- verification
- rollback/mitigation
- [ ] **G2. Update traceability artifacts**
- map Step 4 outcomes to REQ IDs and evidence.
- [ ] **G3. Prepare Step 4 handoff artifacts**
- ensure outputs feed Step 9 release readiness and Step 10 docs completion.
### Deliverables
- `docs/ver1/ver1-step4-migration-runbook.md` (recommended)
- `docs/ver1/ver1-step4-results.md`
- updated traceability references where needed
### Exit Criteria
- migration operations are executable using docs alone
- Step 4 evidence is complete and auditable
---
## Recommended Implementation Order
1. Phase A — schema/invariant audit
2. Phase B — migration policy and tooling lock
3. Phase C — forward migration implementation
4. Phase D — rollback/mitigation strategy + rehearsal
5. Phase E — backfill decision and execution (conditional)
6. Phase F — test and verification expansion
7. Phase G — runbook + traceability closure
This sequence minimizes risk by locking policy and scope before irreversible data changes.
---
## Risks and Mitigations
1. **Risk:** Data loss from unsafe schema transitions
- **Mitigation:** backup-first gate, staged migration strategies, post-check verification.
2. **Risk:** Startup policy drift reintroduces implicit schema mutation
- **Mitigation:** explicit regression tests for production startup behavior (REQ-10 guard).
3. **Risk:** Rollback path is incomplete or untested
- **Mitigation:** mandatory rollback class declaration + rehearsal evidence.
4. **Risk:** Backfill scripts cause partial/inconsistent state
- **Mitigation:** idempotent design, batching, and invariant-focused verification.
5. **Risk:** Migration failure diagnostics are unclear
- **Mitigation:** structured logging + error category mapping per `error_handling.md`.
---
## Step 4 Completion Checklist
- [ ] V1 schema audit completed and approved.
- [ ] Migration workflow policy is locked and documented.
- [ ] Required forward migrations are implemented and validated.
- [ ] Rollback/mitigation decision tree is documented and rehearsed.
- [ ] Backfill required/not-required decision is evidenced.
- [ ] Migration-safety test coverage is added and passing.
- [ ] Startup non-mutation policy remains verified in production mode.
- [ ] Step 4 runbook and results artifacts are completed.
---
## Handoff to Step 5
Step 4 completion enables Step 5 (Private-Network Safety Baseline) with:
- stable, explicit schema evolution mechanics
- reduced upgrade risk for single-operator deployments
- migration/rollback procedures suitable for personal-scale production
- traceable evidence for release-readiness gates
+178
View File
@@ -0,0 +1,178 @@
# Ver1 Step 5 Results: Private-Network Safety Baseline
## Summary
Step 5 implementation status: **complete**.
This document records completed private-network safety controls, validation evidence, and residual risks for Ver1 Step 5.
Implemented in this step:
1. Added private-network security assumptions and control matrix (`docs/ver1/ver1-step5-security-assumptions.md`).
2. Implemented optional single-operator access control for `/ui*` and `/api*` via HTTP Basic auth.
3. Added upload-size guardrails (`MAX_UPLOAD_BYTES`) and config fail-fast validation for operator credential requirements.
4. Hardened unexpected-error user-facing messaging to reduce sensitive detail leakage.
5. Added Step 5 tests for access control, security settings, and upload size boundaries.
6. Executed dependency/security scans (`pip-audit`, `bandit`) with no critical/high findings.
---
## Implemented Changes
### 1) Security assumptions and threat model
Completed.
- Added `docs/ver1/ver1-step5-security-assumptions.md` defining:
- trusted private-network deployment assumptions
- single-operator usage model
- explicit out-of-scope classes (enterprise IAM, internet-facing zero-trust, multi-tenant controls)
- Added Step 5 control/ownership matrix and residual-risk notes.
### 2) Single-operator access control baseline
Completed.
- New module: `src/transcription/security.py`
- `is_protected_path(...)` protects `/ui*` and `/api*`
- `enforce_request_access(...)` enforces optional operator auth
- robust Basic auth parsing and safe denial responses via `AccessDeniedError`
- App middleware added in `src/transcription/app.py`:
- enforces auth on protected paths
- returns consistent `401` envelope and `WWW-Authenticate: Basic` for denied requests
- Health endpoint `/healthz` remains intentionally unauthenticated.
### 3) Input validation and safe-output hardening
Completed baseline.
- `src/transcription/services/upload.py`
- added size-based validation guard (`max_upload_bytes`)
- emits `user_input_error` with actionable guidance on over-limit uploads
- `src/transcription/errors.py`
- `classify_unexpected_error(...)` now returns operation-only message without embedding raw exception text
- preserves traceability via existing `error_id` and taxonomy while reducing accidental sensitive leak risk
### 4) Secret handling and configuration safety
Completed baseline.
- `src/transcription/config.py` additions:
- `max_upload_bytes` (default `15 * 1024 * 1024`)
- `operator_access_enabled` (default `False`)
- `operator_username` (default `operator`)
- `operator_password` (optional, required when auth enabled)
- Added settings validator enforcing fail-fast config safety:
- raises validation error if `OPERATOR_ACCESS_ENABLED=true` and `OPERATOR_PASSWORD` unset
- `README.md` updated with Step 5 security env settings and explicit secret-handling guidance.
### 5) Dependency/security scanning baseline
Completed.
- Dependency vulnerability scan:
- `uvx pip-audit`
- Result: **No known vulnerabilities found**
- Static security scan:
- `uvx bandit -r src/transcription`
- Result: **No issues identified** (0 low/medium/high)
---
## Test and Verification Evidence
### Added/Updated Tests
1. `tests/api/test_access_control.py`
- unauthorized protected API denied (`401` + challenge)
- invalid credentials denied
- valid credentials accepted
- `/ui` protected when auth enabled
- `/healthz` remains unprotected
2. `tests/services/test_upload.py`
- added rejection test for payloads above `MAX_UPLOAD_BYTES`
3. `tests/test_config.py`
- added security defaults assertions
- added fail-fast assertion for missing `OPERATOR_PASSWORD` when auth enabled
4. `tests/test_errors.py`
- updated expectations for sanitized unexpected-error message behavior
5. Updated integration expectations where failure detail should no longer include raw exception text:
- `tests/services/test_worker.py`
- `tests/integration/test_pipeline_flow.py`
6. `tests/test_app.py` updated for new middleware wiring.
### Validation Runs
Run and record outcomes:
- `uv run pytest --collect-only -q` -> passed
- `uv run pytest -m unit -q` -> passed
- `uv run pytest -m "not external" -q` -> passed
- `uv run pytest -q` -> passed
### Security Scan Evidence
Record scan commands and outcomes:
- dependency scan command(s): `uvx pip-audit`
- static/security lint command(s): `uvx bandit -r src/transcription`
- critical/high findings: none
- remediation/defer decisions: no remediations required for Step 5 baseline
---
## Requirement Traceability (Step 5)
| Step 5 Area | REQ Coverage | Status | Evidence |
| --- | --- | --- | --- |
| Private-network and single-operator safety posture | REQ-9 | met | `docs/ver1/ver1-step5-security-assumptions.md`, README security section |
| Access control behavior at UI/API boundaries | REQ-5, REQ-7 | met | `src/transcription/security.py`, `src/transcription/app.py`, `tests/api/test_access_control.py` |
| Input validation and safe user-facing error behavior | REQ-1, REQ-2, REQ-5 | met | `src/transcription/services/upload.py`, `src/transcription/errors.py`, updated tests |
| Config and startup safety controls | REQ-8, REQ-10 | met | `src/transcription/config.py`, `tests/test_config.py`, `README.md` |
| Persistence and domain integrity continuity | REQ-11, REQ-12 | met (no regressions) | full test lane pass including integration and worker flows |
---
## Operational Artifacts Produced
- `docs/ver1/ver1-step5.md`
- `docs/ver1/ver1-step5-results.md`
- `docs/ver1/ver1-step5-security-assumptions.md`
- `src/transcription/security.py`
- `tests/api/test_access_control.py`
---
## Risks, Exceptions, and Follow-Ups
1. Basic auth is intentionally right-sized for trusted private-network use; if deployment posture changes, stronger identity controls are required.
2. Current model remains single shared operator credential (no per-user audit identity).
3. No built-in brute-force/rate-limit controls in Step 5 scope; evaluate in future hardening if threat model expands.
Open follow-ups to carry forward:
- Consider stronger auth/session model if system becomes multi-user or internet-accessible.
- Consider request throttling/rate limiting if threat model changes.
---
## Step 5 Exit Assessment
- Private-network assumptions and controls: **met**
- Access-control baseline effectiveness: **met**
- Validation and safe-output safety: **met (baseline)**
- Secret handling and config safety: **met**
- Dependency/security risk closure: **met (no critical/high findings)**
- Test and regression safety: **met**
Step 5 completion status: **complete**
---
## Handoff to Step 6
Once Step 5 is marked complete, Step 6 can proceed with:
- clearer operational security assumptions for logs/runbooks
- hardened boundary behavior for diagnosis and support
- reduced risk posture for personal-scale ongoing operations
@@ -0,0 +1,51 @@
# Ver1 Step 5 Security Assumptions (Private-Network Baseline)
## Operating Model
This system is operated as:
1. single operator
2. trusted private network
3. non-public deployment (no direct internet exposure for UI/API)
Out of scope for Step 5:
- enterprise IAM/SSO/RBAC
- internet-facing zero-trust edge controls
- multi-tenant user isolation
## Step 5 Controls and Ownership
| Control | Boundary Owner | Verification |
| --- | --- | --- |
| Optional operator authentication for `/ui*` and `/api*` routes | `src/transcription/security.py`, `src/transcription/app.py` | `tests/api/test_access_control.py` |
| Unauthorized contract (`401` + safe envelope + `WWW-Authenticate`) | `src/transcription/api/errors.py` | `tests/api/test_access_control.py` |
| Upload size guard (`MAX_UPLOAD_BYTES`) | `src/transcription/services/upload.py`, `src/transcription/config.py` | `tests/services/test_upload.py` |
| Fail-fast auth config when enabled | `src/transcription/config.py` | `tests/test_config.py` |
| Safe unexpected error messaging (reduced leak surface) | `src/transcription/errors.py` | `tests/test_errors.py`, worker/integration failure tests |
## Access-Control Policy (Step 5)
- Health endpoint (`/healthz`) remains unauthenticated for operability checks.
- When `OPERATOR_ACCESS_ENABLED=true`, protected paths require HTTP Basic auth:
- `/ui`
- `/ui/...`
- `/api/...`
- Credentials are runtime-configured:
- `OPERATOR_USERNAME` (default `operator`)
- `OPERATOR_PASSWORD` (required when access is enabled)
## Secrets Policy
- Secrets must be provided via runtime environment variables.
- Secrets must not be committed to source control.
- Secrets must not be logged.
- Example secret values in docs must always be placeholders.
## Residual Risks (Accepted for Step 5)
1. HTTP Basic credentials are suitable only for trusted private-network deployment.
2. No per-user identity model (single shared operator credential).
3. No advanced brute-force/rate-limit controls in Step 5 scope.
These are carried forward for future hardening only if deployment posture changes.
+459
View File
@@ -0,0 +1,459 @@
# Step 5 Implementation Plan: Private-Network Safety Baseline
## Purpose
Implement **Ver1 Step 5** from `docs/ver1/ver1.md` by applying right-sized security controls for a single-user system running on a trusted private network.
Step 5 focuses on practical risk reduction without introducing unnecessary complexity, while preserving:
- personal-scale operational simplicity
- single-operator workflow
- explicit boundary ownership from `docs/architecture.md`
- safety and diagnostics behavior defined in `docs/error_handling.md`
Primary governing docs:
- `docs/ver1/ver1.md` (Step 5 objective and sequencing)
- `docs/architecture.md` (deployment model and module boundaries)
- `docs/error_handling.md` (safe user output and diagnostic boundaries)
- `docs/requirements.md` (REQ-1, REQ-2, REQ-5, REQ-7, REQ-8, REQ-9, REQ-10, REQ-11, REQ-12)
- `docs/intent.md` (domain integrity priorities)
---
## MCP Resources Reviewed and Applied
All currently available resources on `john-stream-mcp` were reviewed. Step 5 applies the following guidance directly:
1. `resource://skills/pydantic-settings/document`
- typed security-related runtime settings
- explicit env/source precedence
- fail-fast handling for missing/invalid required values
2. `resource://skills/fastapi-uv-docker/document`
- environment and deployment safety defaults
- startup/health posture and container hygiene assumptions
- local secret handling expectations
3. `resource://skills/pytesting/document`
- deterministic security-behavior test lanes
- marker discipline and behavior-first assertions
4. `resource://skills/python-logging-dictconfig/document`
- centralized logging discipline
- avoid leaking sensitive values in logs
5. `resource://skills/nicegui-ui-customization/document`
- user-safe failure messaging in UI
- resilient interaction behavior and clear error feedback
6. `resource://skills/ruff-linting-formating/document`
- keep lint quality baseline stable during safety changes
Planning methodology input:
7. `resource://prompts/greenfield-architecture/document`
- explicit tradeoff-oriented staging
- scope discipline for minimally sufficient security controls
Reviewed but not directly Step 5 execution-critical:
- skills: `copilot-customization`, `fastapi-async-sqlalchemy-modernization`, `mcp-details`, `nicegui`, `python-typing`, `vscode-configuration`, `zensical-docs`
- prompts: `authoring`, `mcp-consumer-repo-shim`, `pytest-scaffold`, `pytest-fill-scaffold`
---
## Current-State Gap Summary (Step 5 Scope)
Based on current implementation and prior Step outputs:
1. **Private-network assumptions are implicit, not fully codified**
- Need explicit, documented security posture and operator constraints.
2. **Access control for UI/API is minimal or absent**
- Step 5 requires basic single-operator gating appropriate for private-network use.
3. **Input validation baseline exists but needs security-oriented audit closure**
- Upload and API validation should be verified for abuse-resistant boundaries.
4. **Safe error output baseline exists (Step 2), but needs security confirmation pass**
- Must ensure no sensitive internals leak through API/UI error payloads.
5. **Secret handling documentation needs formalization in Step 5 artifacts**
- Local workflow should clearly prohibit secrets in repo-tracked files and logs.
6. **Dependency/security scanning is not yet formalized as a recurring gate**
- Step 5 requires lightweight scanning and triage of high-risk findings.
---
## Scope for Step 5
### In scope
1. Codify private-network and single-operator security assumptions in docs and config.
2. Add basic access control for UI/API actions (right-sized for trusted network model).
3. Audit and harden input-validation boundaries (upload, API params/payloads, operational flags).
4. Verify safe error surface behavior (UI/API) and prevent sensitive leak paths.
5. Formalize local secret handling policy and usage examples.
6. Add lightweight dependency/security scan workflow and triage policy.
7. Add Step 5 verification tests and results artifact.
### Out of scope
- Internet-facing zero-trust security architecture
- Enterprise IAM/SSO/role systems
- Full cryptographic key-management infrastructure
- Major security product integrations beyond lightweight V1 needs
---
## Target Decisions for Step 5
1. **Threat model is explicitly private-network + single operator**
- Security controls are right-sized to this posture and documented as assumptions.
2. **Access control is required, even in private network mode**
- Basic gate (single shared operator credential/token) protects UI/API mutation paths.
3. **Validation and output safety are strict defaults**
- Reject invalid inputs early; never expose sensitive internals in user-facing outputs.
4. **Secrets are runtime-only**
- No secrets committed to source control; no plaintext secret logging.
5. **Security scanning is lightweight but mandatory**
- Add recurring dependency/security checks with high-risk triage and closure workflow.
6. **No security control may violate Step 14 operational simplicity guardrails**
- Preserve deployability and maintainability for personal-scale use.
---
## Detailed Work Breakdown
## Phase A — Security Posture Definition and Gap Lock
- [ ] **A1. Define Step 5 threat model**
- trusted private network
- single operator
- local deployment assumptions
- explicit out-of-scope threat classes
- [ ] **A2. Produce security baseline checklist**
- access control
- validation boundaries
- safe error behavior
- secret handling
- dependency risk checks
- [ ] **A3. Map controls to architecture boundaries**
- UI
- API
- service
- config/runtime
- operator runbooks
### Deliverables
- `docs/ver1/ver1-step5-security-assumptions.md` (recommended)
- Step 5 control matrix (control -> owner -> validation method)
### Exit Criteria
- private-network safety posture is explicit and approved
- each in-scope control has boundary ownership and verification path
---
## Phase B — Basic Single-Operator Access Control
- [ ] **B1. Select access mechanism**
- minimal approach suitable for private-network model
- explicitly document tradeoffs and operator ergonomics
- [ ] **B2. Protect mutating operations first**
- upload/create/accept/export-trigger endpoints
- UI actions that trigger persistence changes
- [ ] **B3. Protect read operations as policy requires**
- determine read-path gating expectations and apply consistently
- [ ] **B4. Add clear unauthorized behavior contract**
- stable API status and safe message
- UI feedback with actionable operator guidance
### Deliverables
- access-control policy and implementation notes
- unauthorized behavior matrix (UI/API)
### Exit Criteria
- unauthorized actions are blocked consistently
- authorized operator flows remain usable and deterministic
---
## Phase C — Input Validation and Safe Output Hardening
- [ ] **C1. Validation audit for all entry points**
- file uploads (type/size/content guards)
- route/query/body constraints
- service-layer invariants
- [ ] **C2. Normalize validation failures to canonical taxonomy**
- `validation_error` vs `user_input_error` consistency
- [ ] **C3. Confirm safe error output policy under security lens**
- no stack traces/secrets/internal paths in UI/API default outputs
- preserve error reference IDs for traceability
- [ ] **C4. Add abuse-resistant guardrails where practical**
- basic request-size and payload-shape constraints
- anti-duplication interaction safeguards (where missing)
### Deliverables
- validation-path inventory and hardening checklist
- safe-output verification notes
### Exit Criteria
- input boundaries are deterministic and tested
- user-facing error outputs remain safe and actionable
---
## Phase D — Secrets Handling and Configuration Safety
- [ ] **D1. Define secret handling policy**
- where secrets are allowed (runtime env only)
- where secrets are prohibited (source files, docs examples beyond placeholders)
- [ ] **D2. Enforce settings expectations**
- required secret fields fail fast
- avoid fallback defaults that silently weaken safety
- [ ] **D3. Add operator documentation for local secret workflow**
- how to set environment values safely
- how to rotate/update credentials locally
- [ ] **D4. Validate logging does not leak secret values**
- startup/config logs
- error logs for provider/config failures
### Deliverables
- secret-handling section in runbook/README/docs
- settings and logging safety verification notes
### Exit Criteria
- no secret leakage paths remain in normal operations
- operator can configure secrets safely using docs only
---
## Phase E — Dependency and Security Scanning Baseline
- [ ] **E1. Select lightweight scanning commands for V1**
- dependency vulnerability scan
- optional static security scan if practical
- [ ] **E2. Define triage policy for findings**
- severity classification
- required closure criteria for Step 5 completion
- [ ] **E3. Run scans and capture evidence**
- record command outputs/summaries
- remediate or formally defer with risk notes
- [ ] **E4. Add recurring execution guidance**
- local pre-release checklist integration
- future CI gate handoff for Step 7/9
### Deliverables
- Step 5 scan report artifact (recommended)
- triage log of resolved/deferred findings
### Exit Criteria
- no unresolved critical vulnerabilities in Step 5 scope
- high-risk findings are resolved or explicitly risk-accepted with rationale
---
## Phase F — Verification and Test Expansion
Apply `pytesting` guidance (deterministic, behavior-first, strict markers).
- [ ] **F1. Access-control tests**
- unauthorized requests are rejected as expected
- authorized operator requests succeed
- [ ] **F2. Validation and abuse-boundary tests**
- invalid payloads rejected with stable category/status
- file-type/size constraints enforced
- [ ] **F3. Safe-output tests**
- API/UI error responses avoid sensitive details
- error IDs and suggestions remain present
- [ ] **F4. Config/secret safety tests**
- required secrets fail fast when missing
- no unsafe fallback behavior introduced
### Validation Commands
- `uv run pytest --collect-only -q`
- `uv run pytest -m unit -q`
- `uv run pytest -m "not external" -q`
- `uv run pytest -q`
### Exit Criteria
- Step 5 safety behavior is test-covered and passing
- no regression in core upload/transcribe/review workflows
---
## Phase G — Documentation and Risk Closure
- [ ] **G1. Create Step 5 results artifact**
- `docs/ver1/ver1-step5-results.md`
- [ ] **G2. Update operator-facing docs**
- security assumptions and local deployment cautions
- credential handling and recovery basics
- [ ] **G3. Update traceability and carry-forward notes**
- map Step 5 controls to REQ and evidence
### Deliverables
- `docs/ver1/ver1-step5-results.md`
- updated security assumptions checklist and risk summary
### Exit Criteria
- Step 5 controls and residual risks are fully documented
- handoff is ready for Step 6 observability and Step 7 quality gates
---
## Recommended Implementation Order
1. Phase A — posture definition and gap lock
2. Phase B — access control baseline
3. Phase C — validation/output hardening
4. Phase D — secrets and config safety
5. Phase E — dependency/security scan baseline
6. Phase F — test expansion and verification
7. Phase G — docs and risk closure
This order reduces risk by locking assumptions first, then applying controls at highest-impact boundaries before final verification and documentation.
---
## Step 5 Execution Checklist (Phase-by-Phase)
Use this checklist to execute Step 5 in implementation order and record progress/evidence.
### Phase A — Security Posture Definition and Gap Lock
- [ ] Publish `docs/ver1/ver1-step5-security-assumptions.md`.
- [ ] Record explicit in-scope and out-of-scope threat classes.
- [ ] Produce Step 5 control matrix (control, owner, validation method).
- [ ] Confirm boundary ownership for each control (UI/API/service/config/docs).
### Phase B — Basic Single-Operator Access Control
- [ ] Choose and document access mechanism (with rationale and tradeoffs).
- [ ] Implement enforcement for mutating API operations.
- [ ] Implement corresponding UI-side access behavior for protected actions.
- [ ] Decide and enforce read-path protection policy.
- [ ] Add unauthorized API/UI contract tests.
### Phase C — Input Validation and Safe Output Hardening
- [ ] Complete input-validation inventory for upload/API/service boundaries.
- [ ] Tighten payload/file constraints where gaps are found.
- [ ] Ensure validation failure categories match `docs/error_handling.md`.
- [ ] Verify user-facing errors remain safe, actionable, and traceable.
- [ ] Add regression tests for invalid/boundary inputs.
### Phase D — Secrets Handling and Configuration Safety
- [ ] Document secrets policy (runtime-only, no repo storage).
- [ ] Verify required secret settings fail fast when missing.
- [ ] Audit logs for accidental secret leakage risk paths.
- [ ] Update operator docs for local secret setup/rotation workflow.
- [ ] Add tests for config safety expectations where practical.
### Phase E — Dependency and Security Scanning Baseline
- [ ] Select scanning commands and record tool versions.
- [ ] Run baseline scans and capture outputs.
- [ ] Triage findings by severity and exploitability in private-network context.
- [ ] Resolve/mitigate critical findings; document accepted residual risk.
- [ ] Add recurring scan guidance for release workflow handoff.
### Phase F — Verification and Test Expansion
- [ ] Run `uv run pytest --collect-only -q`.
- [ ] Run `uv run pytest -m unit -q`.
- [ ] Run `uv run pytest -m "not external" -q`.
- [ ] Run `uv run pytest -q`.
- [ ] Confirm no regressions in upload/transcribe/review core flows.
### Phase G — Documentation and Risk Closure
- [ ] Complete `docs/ver1/ver1-step5-results.md` with evidence.
- [ ] Update docs/README/runbooks with final Step 5 security posture.
- [ ] Record REQ traceability updates and residual risks.
- [ ] Confirm Step 5 completion checklist items are all closed.
---
## Risks and Mitigations
1. **Risk:** Over-engineering beyond private-network needs
- **Mitigation:** enforce Step 5 scope discipline and threat-model constraints.
2. **Risk:** Access controls disrupt operator usability
- **Mitigation:** keep mechanism minimal and test primary workflows thoroughly.
3. **Risk:** Sensitive details leak through errors/logging
- **Mitigation:** apply safe-output and log-sanitization checks with tests.
4. **Risk:** Unpatched dependency vulnerabilities remain invisible
- **Mitigation:** formalize scan + triage + evidence capture workflow.
5. **Risk:** Secret handling remains ad hoc
- **Mitigation:** fail-fast settings + explicit operator documentation + review checks.
---
## Step 5 Completion Checklist
- [ ] Private-network and single-operator security assumptions are documented.
- [ ] Basic single-operator access control is implemented and verified.
- [ ] Input-validation boundaries are audited, hardened, and test-covered.
- [ ] UI/API error output safety is confirmed under security tests.
- [ ] Secret handling policy and local workflow docs are complete.
- [ ] Dependency/security scans are run; critical findings are resolved.
- [ ] Step 5 tests pass across all validation lanes.
- [ ] `docs/ver1/ver1-step5-results.md` is completed with evidence and residual risks.
---
## Handoff to Step 6
Step 5 completion enables Step 6 (Minimal Observability & Operability) with:
- explicit security assumptions for operator context
- access and validation controls suitable for private-network operation
- safer runtime/configuration handling for ongoing operations
- dependency-risk visibility feeding release-readiness gates
+206
View File
@@ -0,0 +1,206 @@
## Step 6 Goal (from `docs/ver1/ver1.md`)
Implement **minimal observability & operability** so a single operator can quickly diagnose and recover from common failures.
---
## 1) Current-State Assessment (what already exists)
### Already in place
- Central startup logging initialization via `setup_logging()` and `dictConfig` (`src/transcription/config.py`, `src/transcription/app.py`).
- Error taxonomy and `error_id` envelope contract (`src/transcription/errors.py`) aligned with `docs/error_handling.md`.
- Error handling for API and worker includes category + error IDs in some paths (`src/transcription/api/errors.py`, `src/transcription/worker.py`).
- Basic health endpoint `/healthz` (`src/transcription/api/health.py`).
- UI error display already shows actionable message + error reference (`src/transcription/ui/error_presenter.py`).
### Gaps to close for Step 6
1. **Structured logging is inconsistent** (many logs are free-form text with embedded key/value; no enforced schema).
2. **Boundary coverage is incomplete** (UI/service/API/worker dont all emit consistent operation logs).
3. `/healthz` is very basic; no lightweight readiness/startup diagnostics endpoint/reporting.
4. No concise **operator runbook** yet (start/stop, log interpretation, recovery playbooks).
5. Minimal counters/timings are not yet standardized.
---
## 2) MCP Guidance Incorporated (relevant items)
From `john-stream-mcp`, these are directly applied:
- **`python-logging-dictconfig`**: keep one centralized `dictConfig`, configure once at startup, named loggers in modules.
- **`fastapi-async-sqlalchemy-modernization`**: include observability + health/readiness checks; explicit lifecycle and deterministic startup/shutdown checks.
- **`fastapi-uv-docker`**: keep `/healthz`; add practical readiness/ops checks for deployment clarity.
- **`pytesting`**: deterministic tests, concise structure, validation lanes (`collect-only`, `unit`, `not external`, full).
- **`pydantic-settings`**: keep typed settings as single source for logging/health behavior flags.
- **`nicegui` + `nicegui-ui-customization`**: preserve clear, actionable user-facing error feedback and non-blocking UI flows.
- **`zensical-docs`**: produce focused, navigable operator docs.
(Other MCP resources were reviewed but are not core to Step 6 implementation scope.)
---
## 3) Detailed Implementation Plan for Step 6
## Workstream A — Structured Logging Contract
### A1. Define a canonical log event schema
Create a project log schema (doc + code-level constants) with required keys:
- `timestamp` (UTC)
- `level`
- `logger`
- `operation`
- `event`
- `error_id` (when error)
- `category` (when error)
- `exception_type` (when error)
- `job_id`, `document_id` (when relevant)
- optional: `duration_ms`, `retry_count`, `status`
### A2. Standardize log emission helpers
Add small logging helpers (or adapter utilities) to reduce drift:
- `log_operation_start(...)`
- `log_operation_success(...)`
- `log_operation_error(...)`
Keep this minimal and avoid heavy observability frameworks.
### A3. Update formatter to structured output
Use `dictConfig` to emit either:
- JSON lines (preferred for structure), or
- strict key-value line format with fixed fields.
**Recommendation:** JSON lines to satisfy “structured logging” unambiguously while still simple.
---
## Workstream B — Boundary-by-Boundary Instrumentation
### B1. API boundary (`src/transcription/api/*`)
- Add request-level operation logs for key routes (`upload.submit`, `jobs.list`, `jobs.get`, etc.).
- Ensure API exception handler logs always include `error_id`, `category`, `operation`, `exception_type`.
### B2. Service boundary (`src/transcription/services/*`)
- Add operation logs around:
- upload validation/persist,
- transcription orchestration,
- revision add/accept,
- search/export.
- Add timing (`duration_ms`) for high-value operations only.
### B3. Worker boundary (`src/transcription/worker.py`)
- Standardize all worker log events to schema.
- Ensure retry logs include: `retriable`, `retry_count`, `max_retries`, `backoff_seconds`.
- Ensure terminal failure logs include error contract fields.
### B4. UI boundary (`src/transcription/ui/*`)
- Keep user-safe UI messages as-is.
- Add backend/UI logger events for user-triggered failures (operation + error_id + category) so UI-visible errors correlate to server logs.
---
## Workstream C — Health, Readiness, Startup Operability
### C1. Keep `/healthz` lightweight
- Return “process is running” status quickly.
### C2. Add lightweight `/readyz`
Include small checks:
- DB connectivity ping.
- Worker thread alive check.
- Optional prompt directory existence check.
Return structured status payload with per-check pass/fail.
### C3. Startup self-check summary log
At startup, emit one concise ops summary event:
- environment
- schema validation result
- worker started
- directories checked
- bootstrap/migration mode flags
---
## Workstream D — Minimal Counters & Timings
Add only high-value diagnostics:
1. `worker_jobs_processed_total`
2. `worker_jobs_failed_total`
3. `worker_retries_total`
4. `transcription_duration_ms` (per job)
5. `upload_persist_duration_ms` (per upload path)
Implementation can be log-derived counters (no external metrics backend required).
---
## Workstream E — Operator Runbook
Create concise runbook doc (recommended: `docs/ver1/ver1-step6-operator-runbook.md`) with:
1. **Start/Stop**
- local `uv` run mode
- docker compose mode (if applicable)
2. **Where logs are**
- stdout, docker logs commands, filtering by `error_id` / `operation`.
3. **Common failure patterns → recovery**
- provider timeout
- auth denied
- missing prompt dir
- DB unavailable
- job stuck/failed with retry exhausted
4. **Recovery procedures**
- restart sequence
- verify health/readiness
- when to requeue/re-upload
5. **Escalation artifacts**
- capture timestamp + error_id + operation + job_id/document_id
Also update `README.md` with short links to the runbook.
---
## Workstream F — Verification & Quality Gates
### Tests to add/update
- `tests/api/test_health.py`
- `/healthz` baseline
- `/readyz` pass/fail behavior
- `tests/api/test_error_responses.py` / `tests/api/test_routes.py`
- logs include `error_id/category/operation` on failures
- `tests/services/test_worker.py`
- retry/failure log fields + timing presence
- `tests/ui/*`
- ensure UI error correlation path includes operation/ref id behavior
### Validation commands (per MCP pytest guidance)
- `uv run pytest --collect-only -q`
- `uv run pytest -m unit -q`
- `uv run pytest -m "not external" -q`
- `uv run pytest -q`
---
## 4) Traceability to Governing Docs
- **`docs/ver1/ver1.md` Step 6:** all 5 implementation bullets covered.
- **`docs/error_handling.md`:** logging contract fields and error taxonomy continuity enforced.
- **`docs/architecture.md`:** respects modular boundaries, in-process worker model, low-complexity ops.
- **`docs/requirements.md`:**
- REQ-8 (startup logging/config centralization) strengthened,
- REQ-5 (status visibility) improved operationally,
- REQ-7 lifecycle ownership observability improved.
- **`docs/intent.md`:** keeps operation simple for personal-scale archival workflow.
---
## 5) Suggested Execution Order (low risk)
1. Logging schema + formatter + helpers
2. Worker/API instrumentation (highest value)
3. Service/UI instrumentation
4. `/readyz` + startup summary check
5. Runbook + README links
6. Tests + Step 6 results artifact (`docs/ver1/ver1-step6-results.md`)
+3 -1
View File
@@ -23,7 +23,9 @@ The objective is to deliver the full scoped product with readiness for reliable
### Deliverables ### Deliverables
- `docs/ver1/ver1.md` (this plan) - `docs/ver1/ver1.md` (this plan)
- V1 traceability artifact (linked from here when created) - V1 traceability artifact:
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md`
- `docs/ver1/ver1-step2-error-path-inventory.md` (supporting artifact)
### Exit Criteria ### Exit Criteria
- Every in-scope requirement has explicit status and validation evidence. - Every in-scope requirement has explicit status and validation evidence.
+11 -2
View File
@@ -4,10 +4,13 @@ from __future__ import annotations
import logging import logging
from fastapi import FastAPI, Request from fastapi import FastAPI
from fastapi import Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from transcription.errors import AppError, ErrorCategory, build_error_envelope from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.errors import build_error_envelope
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -31,6 +34,12 @@ def _status_for(error: AppError) -> int:
def register_error_handlers(app: FastAPI) -> None: def register_error_handlers(app: FastAPI) -> None:
"""Register API exception handlers on the app.""" """Register API exception handlers on the app."""
@app.exception_handler(AccessDeniedError)
async def access_denied_handler(_request: Request, exc: AccessDeniedError) -> JSONResponse:
envelope = build_error_envelope(exc)
headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None
return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers)
@app.exception_handler(AppError) @app.exception_handler(AppError)
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse: async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
envelope = build_error_envelope(exc) envelope = build_error_envelope(exc)
+161
View File
@@ -0,0 +1,161 @@
"""Functional API routes for jobs, revisions, search, and export."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter
from pydantic import BaseModel
from pydantic import Field
from transcription.services.library import accept_revision
from transcription.services.library import add_revision
from transcription.services.library import export_transcripts
from transcription.services.library import get_job_detail
from transcription.services.library import list_jobs
from transcription.services.library import list_revisions
from transcription.services.library import search_accepted_transcripts
router = APIRouter(prefix="/api", tags=["transcription"])
class CreateRevisionRequest(BaseModel):
text: str = Field(min_length=1)
source: str = "user"
accepted: bool = False
@router.get("/jobs")
def get_jobs() -> list[dict[str, str]]:
jobs = list_jobs()
return [
{
"id": str(job.id),
"document_id": str(job.document_id),
"status": job.status.value,
"created_at": job.created_at.isoformat(),
"updated_at": job.updated_at.isoformat(),
}
for job in jobs
]
@router.get("/jobs/{job_id}")
def get_job(job_id: UUID) -> dict[str, object | None]:
detail = get_job_detail(job_id=job_id)
return {
"job": {
"id": str(detail.job.id),
"document_id": str(detail.job.document_id),
"status": detail.job.status.value,
"created_at": detail.job.created_at.isoformat(),
"updated_at": detail.job.updated_at.isoformat(),
},
"document": (
{
"id": str(detail.document.id),
"filename": detail.document.filename,
"file_path": detail.document.file_path,
}
if detail.document is not None
else None
),
"transcript": (
{
"id": str(detail.transcript.id),
"text": detail.transcript.text,
"error_detail": detail.transcript.error_detail,
"created_at": detail.transcript.created_at.isoformat(),
}
if detail.transcript is not None
else None
),
"accepted_revision": (
{
"id": str(detail.accepted_revision.id),
"revision_number": detail.accepted_revision.revision_number,
"text": detail.accepted_revision.text,
"source": detail.accepted_revision.source,
"created_at": detail.accepted_revision.created_at.isoformat(),
}
if detail.accepted_revision is not None
else None
),
}
@router.get("/jobs/{job_id}/revisions")
def get_job_revisions(job_id: UUID) -> list[dict[str, object]]:
revisions = list_revisions(job_id=job_id)
return [
{
"id": str(revision.id),
"job_id": str(revision.job_id),
"revision_number": revision.revision_number,
"text": revision.text,
"source": revision.source,
"accepted": revision.accepted,
"created_at": revision.created_at.isoformat(),
}
for revision in revisions
]
@router.post("/jobs/{job_id}/revisions")
def create_job_revision(job_id: UUID, payload: CreateRevisionRequest) -> dict[str, object]:
revision = add_revision(
job_id=job_id,
text=payload.text,
source=payload.source,
accepted=payload.accepted,
)
return {
"id": str(revision.id),
"job_id": str(revision.job_id),
"revision_number": revision.revision_number,
"text": revision.text,
"source": revision.source,
"accepted": revision.accepted,
"created_at": revision.created_at.isoformat(),
}
@router.post("/revisions/{revision_id}/accept")
def accept_job_revision(revision_id: UUID) -> dict[str, object]:
revision = accept_revision(revision_id=revision_id)
return {
"id": str(revision.id),
"job_id": str(revision.job_id),
"revision_number": revision.revision_number,
"text": revision.text,
"source": revision.source,
"accepted": revision.accepted,
"created_at": revision.created_at.isoformat(),
}
@router.get("/search")
def search(query: str) -> list[dict[str, object]]:
results = search_accepted_transcripts(query=query)
return [
{
"revision_id": str(revision.id),
"job_id": str(revision.job_id),
"revision_number": revision.revision_number,
"text": revision.text,
"source": revision.source,
"accepted": revision.accepted,
"created_at": revision.created_at.isoformat(),
}
for revision in results
]
@router.get("/export")
def export(accepted_only: bool = True) -> dict[str, object]:
records = export_transcripts(accepted_only=accepted_only)
return {
"count": len(records),
"accepted_only": accepted_only,
"records": records,
}
+36 -19
View File
@@ -3,29 +3,31 @@
from __future__ import annotations from __future__ import annotations
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from threading import Event, Thread from threading import Event
from threading import Thread
from fastapi import FastAPI from fastapi import FastAPI
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.api.errors import register_error_handlers from .api.errors import register_error_handlers
from transcription.api.health import router as health_router from .api.health import router as health_router
from transcription.config import get_settings, setup_logging from .config import configure_logging
from transcription.db import ( from .config import get_settings
create_all, from .db import cleanup_database
dispose_database_runtime, from .db import create_all
initialize_database_runtime, from .db import initialize_database_runtime
should_bootstrap_schema, from .ui import register_pages
) from .worker import run_worker_loop
from transcription.ui import register_pages
from transcription.worker import run_worker_loop
def _start_worker(app: FastAPI) -> None: def _start_worker(app: FastAPI) -> None:
session_factory: async_sessionmaker[AsyncSession] = app.state.db_session_factory
stop_event = Event() stop_event = Event()
worker_thread = Thread( worker_thread = Thread(
target=run_worker_loop, target=run_worker_loop,
kwargs={ kwargs={
"engine": app.state.db_runtime.engine, "session_factory": session_factory,
"stop_event": stop_event, "stop_event": stop_event,
"poll_interval_seconds": 1.0, "poll_interval_seconds": 1.0,
}, },
@@ -48,14 +50,16 @@ def _stop_worker(app: FastAPI) -> None:
@asynccontextmanager @asynccontextmanager
async def _lifespan(app: FastAPI): async def _lifespan(app: FastAPI):
setup_logging() configure_logging()
settings = get_settings() settings = get_settings()
app.state.settings = settings app.state.settings = settings
app.state.db_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 should_bootstrap_schema(settings): if settings.should_bootstrap_schema:
create_all(engine=app.state.db_runtime.engine) await create_all(engine=runtime.engine)
settings.upload_dir.mkdir(parents=True, exist_ok=True) settings.upload_dir.mkdir(parents=True, exist_ok=True)
settings.prompt_dir.mkdir(parents=True, exist_ok=True) settings.prompt_dir.mkdir(parents=True, exist_ok=True)
@@ -65,14 +69,27 @@ async def _lifespan(app: FastAPI):
yield yield
finally: finally:
_stop_worker(app) _stop_worker(app)
dispose_database_runtime() await cleanup_database()
def create_app() -> FastAPI: def create_app() -> FastAPI:
"""Create and configure the FastAPI application.""" """Create and configure the FastAPI application."""
app = FastAPI(title="Transcription", lifespan=_lifespan) app = FastAPI(title="Transcription", lifespan=_lifespan)
@app.middleware("http")
async def operator_access_middleware(request: Request, call_next):
settings = get_settings()
try:
enforce_request_access(request=request, settings=settings)
except AccessDeniedError as exc:
envelope = build_error_envelope(exc)
headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None
return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers)
return await call_next(request)
register_error_handlers(app) register_error_handlers(app)
register_pages(app) register_pages(app)
app.include_router(health_router) app.include_router(health_router)
app.include_router(transcription_router)
return app return app
+42 -13
View File
@@ -5,14 +5,16 @@ once at startup. Provider-specific defaults (model names, base URLs)
are resolved by the provider adapters, not here. are resolved by the provider adapters, not here.
""" """
import logging
import logging.config import logging.config
from contextvars import ContextVar
from enum import StrEnum from enum import StrEnum
from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict
logger = logging.getLogger(__name__)
class Provider(StrEnum): class Provider(StrEnum):
@@ -39,15 +41,43 @@ class Settings(BaseSettings):
# --- persistence --- # --- persistence ---
database_url: str = "sqlite:///./transcription.db" database_url: str = "sqlite:///./transcription.db"
bootstrap_schema_on_startup: bool | None = None bootstrap_schema_on_startup: bool | None = None
migration_auto_apply_on_startup: bool = False
validate_schema_on_startup: bool = True
# --- filesystem paths --- # --- filesystem paths ---
upload_dir: Path = Path("./uploads") upload_dir: Path = Path("./uploads")
prompt_dir: Path = Path("./prompts") prompt_dir: Path = Path("./prompts")
# --- upload safety ---
max_upload_bytes: int = 15 * 1024 * 1024
# --- single-operator access control ---
operator_access_enabled: bool = False
operator_username: str = "operator"
operator_password: str | None = None
# --- worker reliability --- # --- worker reliability ---
worker_max_retries: int = 0 worker_max_retries: int = 0
worker_retry_backoff_seconds: float = 0.0 worker_retry_backoff_seconds: float = 0.0
@property
def should_bootstrap_schema(self) -> bool:
"""Return whether startup should auto-create schema for this environment."""
if self.bootstrap_schema_on_startup is not None:
return self.bootstrap_schema_on_startup
return self.environment in {"development", "test"}
_settings: ContextVar[Settings | None] = ContextVar("settings", default=None)
def get_settings() -> Settings:
settings = _settings.get()
if settings is None:
settings = Settings() # pyright: ignore[reportCallIssue]
_settings.set(settings)
return settings
LOGGING_CONFIG: dict[str, object] = { LOGGING_CONFIG: dict[str, object] = {
"version": 1, "version": 1,
@@ -69,18 +99,17 @@ LOGGING_CONFIG: dict[str, object] = {
"level": "INFO", "level": "INFO",
"handlers": ["console"], "handlers": ["console"],
}, },
"loggers": {
"transcription": {
"level": "DEBUG",
"handlers": ["console"],
"propagate": False,
}
},
} }
@lru_cache(maxsize=1) def configure_logging() -> None:
def get_settings() -> Settings:
"""Return the singleton Settings instance.
Cached so the entire application shares one validated config.
"""
return Settings()
def setup_logging() -> None:
"""Configure root logging once at startup.""" """Configure root logging once at startup."""
logging.config.dictConfig(LOGGING_CONFIG) logging.config.dictConfig(LOGGING_CONFIG)
logger.debug("Logging configured")
+86 -53
View File
@@ -4,113 +4,146 @@ V1 moves database resource ownership to explicit runtime initialization so
startup/shutdown behavior is predictable and lifespan-managed. startup/shutdown behavior is predictable and lifespan-managed.
""" """
from __future__ import annotations
import contextlib import contextlib
import logging import logging
from collections.abc import Generator from collections.abc import AsyncGenerator
from dataclasses import dataclass from dataclasses import dataclass
from sqlalchemy import inspect, text from sqlalchemy import inspect
from sqlalchemy.engine import Engine from sqlalchemy import text
from sqlmodel import Session, SQLModel, create_engine 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 transcription.config import Settings, get_settings from .config import Settings
from .config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass(frozen=True) @dataclass(frozen=True)
class DatabaseRuntime: class DatabaseRuntime:
"""Process-level database runtime resources.""" """Database runtime resources owned by app lifespan."""
engine: Engine engine: AsyncEngine
session_factory: async_sessionmaker[AsyncSession]
_runtime: DatabaseRuntime | None = None _runtime: DatabaseRuntime | None = None
def _build_engine(settings: Settings) -> Engine: 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] = {} connect_args: dict[str, object] = {}
if settings.database_url.startswith("sqlite"): if database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False connect_args["check_same_thread"] = False
return create_engine( return create_async_engine(
settings.database_url, url=database_url,
echo=False, echo=False,
pool_pre_ping=True,
connect_args=connect_args, connect_args=connect_args,
) )
def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime: def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime:
"""Initialize and cache the process database runtime once.""" """Initialize lifespan-owned async DB resources once per process."""
global _runtime global _runtime
if _runtime is not None: if _runtime is not None:
return _runtime return _runtime
runtime_settings = settings or get_settings() active_settings = settings or get_settings()
_runtime = DatabaseRuntime(engine=_build_engine(runtime_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 return _runtime
def get_database_runtime() -> DatabaseRuntime: def get_engine() -> AsyncEngine:
"""Return initialized database runtime, creating it if needed.""" """Return the current async SQLAlchemy engine."""
if _runtime is None: runtime = _runtime or initialize_database_runtime()
return initialize_database_runtime() return runtime.engine
return _runtime
def dispose_database_runtime() -> None: def get_session_factory() -> async_sessionmaker[AsyncSession]:
"""Dispose process database runtime resources.""" """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 global _runtime
if _runtime is not None: if _runtime is None:
_runtime.engine.dispose() return
await _runtime.engine.dispose()
_runtime = None _runtime = None
def should_bootstrap_schema(settings: Settings) -> bool: async def create_all(*, engine: AsyncEngine | None = None) -> None:
"""Return whether startup should auto-create schema for this environment."""
if settings.bootstrap_schema_on_startup is not None:
return settings.bootstrap_schema_on_startup
return settings.environment in {"development", "test"}
def create_all(*, engine: Engine | None = None) -> None:
"""Create all tables on the selected engine.""" """Create all tables on the selected engine."""
# Import models so SQLModel metadata is fully registered before bootstrap. # Import models so SQLModel metadata is fully registered before bootstrap.
from transcription import models as _models # noqa: F401 from transcription import models as _models # noqa: F401
active_engine = engine or get_database_runtime().engine active_engine = engine or get_engine()
SQLModel.metadata.create_all(active_engine) async with active_engine.begin() as connection:
_ensure_sqlite_compat_columns(active_engine) 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(engine: Engine) -> None: def _ensure_sqlite_compat_columns(connection: Connection) -> None:
"""Apply lightweight dev/test SQLite compatibility column patches. """Apply lightweight dev/test SQLite compatibility column patches.
This keeps local bootstrap resilient when models evolve but no full This performs read-only validation and never mutates schema.
migration tooling is in place yet.
""" """
if engine.url.get_backend_name() != "sqlite": if connection.engine.url.get_backend_name() != "sqlite":
return return
inspector = inspect(engine) inspector = inspect(connection)
table_names = set(inspector.get_table_names()) table_names = set(inspector.get_table_names())
if "job" not in table_names:
return 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")} columns = {column["name"] for column in inspector.get_columns("job")}
if "retry_count" not in columns: if "retry_count" not in columns:
with engine.begin() as connection: connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
connection.execute( logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0")
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.contextmanager @contextlib.asynccontextmanager
def get_session(*, engine: Engine | None = None) -> Generator[Session]: async def get_session(
*,
session_factory: async_sessionmaker[AsyncSession] | None = None,
) -> AsyncGenerator[AsyncSession]:
"""Yield a database session and ensure cleanup.""" """Yield a database session and ensure cleanup."""
active_engine = engine or get_database_runtime().engine active_session_factory = session_factory or get_session_factory()
with Session(active_engine) as session: async with active_session_factory() as session:
yield session yield session
def should_bootstrap_schema(settings: Settings) -> bool:
"""Compatibility helper for explicit bootstrap checks."""
return settings.should_bootstrap_schema
+6 -4
View File
@@ -3,7 +3,8 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import UTC
from datetime import datetime
from enum import StrEnum from enum import StrEnum
from uuid import uuid4 from uuid import uuid4
@@ -64,14 +65,15 @@ def build_error_envelope(error: AppError) -> ErrorEnvelope:
category=error.category.value, category=error.category.value,
message=error.message, message=error.message,
suggestion=error.suggestion, suggestion=error.suggestion,
timestamp=datetime.now(timezone.utc).isoformat(), timestamp=datetime.now(UTC).isoformat(),
) )
def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError: def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
"""Normalize unknown exceptions into internal_unexpected_error.""" """Normalize unknown exceptions into internal_unexpected_error."""
_ = exc
return AppError( return AppError(
f"Unexpected error during {operation}: {exc}", f"Unexpected error during {operation}",
category=ErrorCategory.INTERNAL_UNEXPECTED, category=ErrorCategory.INTERNAL_UNEXPECTED,
suggestion="Retry once. If it persists, review logs and report the error reference id.", suggestion="Retry once. If it persists, review logs and report the error reference id.",
retriable=False, retriable=False,
@@ -83,4 +85,4 @@ def format_error_detail(error: AppError) -> str:
return ( return (
f"[{error.category.value}] {error.message} | " f"[{error.category.value}] {error.message} | "
f"suggestion={error.suggestion} | error_id={error.error_id}" f"suggestion={error.suggestion} | error_id={error.error_id}"
) )
+75
View File
@@ -0,0 +1,75 @@
"""CLI entrypoint for explicit schema migration and compatibility checks."""
from __future__ import annotations
import argparse
from transcription.config import get_settings
from transcription.db import initialize_database_runtime
from transcription.db import validate_schema_compatibility
from transcription.migrations import apply_pending_migrations
from transcription.migrations import list_pending_migrations
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Transcription schema migration runner")
parser.add_argument(
"--apply",
action="store_true",
help="Apply all pending migrations.",
)
parser.add_argument(
"--list",
action="store_true",
help="List pending migrations.",
)
parser.add_argument(
"--check",
action="store_true",
help="Run schema compatibility check.",
)
return parser
def main() -> int:
parser = _build_parser()
args = parser.parse_args()
if not (args.apply or args.list or args.check):
parser.error("Specify at least one action: --list, --apply, or --check")
runtime = initialize_database_runtime(settings=get_settings())
engine = runtime.engine
if args.list:
pending = list_pending_migrations(engine=engine)
if not pending:
print("No pending migrations.")
else:
print("Pending migrations:")
for migration in pending:
print(f"- {migration.revision_id}: {migration.description}")
if args.apply:
applied = apply_pending_migrations(engine=engine)
if not applied:
print("No migrations applied.")
else:
print("Applied migrations:")
for revision_id in applied:
print(f"- {revision_id}")
if args.check:
issues = validate_schema_compatibility(engine=engine)
if issues:
print("Schema compatibility check failed:")
for issue in issues:
print(f"- {issue}")
return 1
print("Schema compatibility check passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+132
View File
@@ -0,0 +1,132 @@
"""Lightweight schema migration helpers for V1 Step 4.
This module provides explicit, operator-invoked migration execution for
personal-scale deployments without introducing heavyweight migration tooling.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from sqlalchemy import inspect
from sqlalchemy import text
from sqlalchemy.engine import Connection
from sqlalchemy.engine import Engine
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class MigrationRevision:
"""Represents one ordered schema migration revision."""
revision_id: str
description: str
apply: Callable[[Connection], None]
def _ensure_history_table(connection: Connection) -> None:
"""Create migration history table when missing."""
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS schema_migration_history (
revision_id VARCHAR(64) PRIMARY KEY,
description VARCHAR(255) NOT NULL,
applied_at VARCHAR(64) NOT NULL
)
"""
)
)
def _get_applied_revisions(connection: Connection) -> set[str]:
"""Return applied migration revision IDs."""
_ensure_history_table(connection)
rows = connection.execute(text("SELECT revision_id FROM schema_migration_history")).fetchall()
return {row[0] for row in rows}
def _record_revision(connection: Connection, revision: MigrationRevision) -> None:
"""Persist one applied migration revision record."""
connection.execute(
text(
"""
INSERT INTO schema_migration_history (revision_id, description, applied_at)
VALUES (:revision_id, :description, :applied_at)
"""
),
{
"revision_id": revision.revision_id,
"description": revision.description,
"applied_at": datetime.now(UTC).isoformat(),
},
)
def _apply_0001_add_retry_count(connection: Connection) -> None:
"""Ensure job.retry_count exists for legacy databases."""
inspector = inspect(connection)
table_names = set(inspector.get_table_names())
if "job" not in table_names:
return
columns = {column["name"] for column in inspector.get_columns("job")}
if "retry_count" in columns:
return
# Compatible with SQLite and PostgreSQL for this additive integer column.
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
def _apply_0002_create_transcriptrevision(connection: Connection) -> None:
"""Ensure transcriptrevision table exists."""
# Import models lazily so metadata is fully populated.
from sqlmodel import SQLModel
from transcription.models import TranscriptRevision # noqa: F401
table = SQLModel.metadata.tables["transcriptrevision"]
table.create(bind=connection, checkfirst=True)
MIGRATIONS: tuple[MigrationRevision, ...] = (
MigrationRevision(
revision_id="0001_add_retry_count_to_job",
description="Add retry_count column to job table with default 0",
apply=_apply_0001_add_retry_count,
),
MigrationRevision(
revision_id="0002_create_transcriptrevision_table",
description="Create transcriptrevision table for immutable transcript history",
apply=_apply_0002_create_transcriptrevision,
),
)
def list_pending_migrations(*, engine: Engine) -> list[MigrationRevision]:
"""Return pending migrations ordered by revision."""
with engine.begin() as connection:
applied = _get_applied_revisions(connection)
return [revision for revision in MIGRATIONS if revision.revision_id not in applied]
def apply_pending_migrations(*, engine: Engine) -> list[str]:
"""Apply all pending migrations and return applied revision IDs."""
pending = list_pending_migrations(engine=engine)
applied_ids: list[str] = []
for revision in pending:
logger.info("Applying migration revision=%s", revision.revision_id)
with engine.begin() as connection:
_ensure_history_table(connection)
revision.apply(connection)
_record_revision(connection, revision)
applied_ids.append(revision.revision_id)
logger.info("Applied migration revision=%s", revision.revision_id)
return applied_ids
+30 -21
View File
@@ -1,21 +1,22 @@
"""SQLModel domain models for the transcription system. """SQLModel domain models for the transcription system."""
Three models capture the MVP lifecycle: from datetime import UTC
Document -> one-to-many -> Job -> one-to-one -> Transcript from datetime import datetime
"""
from datetime import datetime, timezone
from enum import StrEnum from enum import StrEnum
from typing import Optional from typing import Optional
from uuid import UUID, uuid4 from uuid import UUID
from uuid import uuid4
from sqlmodel import Field, Relationship, SQLModel from sqlmodel import Field
from sqlmodel import Relationship
from sqlmodel import SQLModel
class JobStatus(StrEnum): class JobStatus(StrEnum):
QUEUED = "queued" QUEUED = "queued"
PROCESSING = "processing" PROCESSING = "processing"
TRANSCRIBED = "transcribed" TRANSCRIBED = "transcribed"
COMPLETED = "completed"
FAILED = "failed" FAILED = "failed"
@@ -25,9 +26,7 @@ class Document(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
filename: str filename: str
file_path: str file_path: str
uploaded_at: datetime = Field( uploaded_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
default_factory=lambda: datetime.now(timezone.utc),
)
# --- relationships --- # --- relationships ---
jobs: list["Job"] = Relationship(back_populates="document") jobs: list["Job"] = Relationship(back_populates="document")
@@ -40,28 +39,38 @@ class Job(SQLModel, table=True):
document_id: UUID = Field(foreign_key="document.id") document_id: UUID = Field(foreign_key="document.id")
status: JobStatus = Field(default=JobStatus.QUEUED) status: JobStatus = Field(default=JobStatus.QUEUED)
retry_count: int = Field(default=0, ge=0) retry_count: int = Field(default=0, ge=0)
created_at: datetime = Field( created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
default_factory=lambda: datetime.now(timezone.utc), updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
)
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
)
# --- relationships --- # --- relationships ---
document: Document = Relationship(back_populates="jobs") document: Document = Relationship(back_populates="jobs")
transcript: Optional["Transcript"] = Relationship(back_populates="job") transcript: Optional["Transcript"] = Relationship(back_populates="job")
revisions: list["TranscriptRevision"] = Relationship(back_populates="job")
class Transcript(SQLModel, table=True): class Transcript(SQLModel, table=True):
"""The output of a transcription job.""" """Canonical transcript state for a job (latest text or failure detail)."""
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
job_id: UUID = Field(foreign_key="job.id", unique=True) job_id: UUID = Field(foreign_key="job.id", unique=True)
text: str | None = None text: str | None = None
error_detail: str | None = None error_detail: str | None = None
created_at: datetime = Field( created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
default_factory=lambda: datetime.now(timezone.utc),
)
# --- relationships --- # --- relationships ---
job: Job = Relationship(back_populates="transcript") job: Job = Relationship(back_populates="transcript")
class TranscriptRevision(SQLModel, table=True):
"""Immutable transcript revision history for review/acceptance workflows."""
id: UUID = Field(default_factory=uuid4, primary_key=True)
job_id: UUID = Field(foreign_key="job.id", index=True)
revision_number: int = Field(ge=1)
text: str
source: str = Field(default="worker")
accepted: bool = Field(default=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# --- relationships ---
job: Job = Relationship(back_populates="revisions")
+9 -9
View File
@@ -1,13 +1,13 @@
"""Provider exports and factory for transcription adapters.""" """Provider exports and factory for transcription adapters."""
from transcription.config import Provider, Settings, get_settings from transcription.config import Provider
from transcription.providers.base import ( from transcription.config import Settings
ProviderAuthError, from transcription.config import get_settings
ProviderError, from transcription.providers.base import ProviderAuthError
ProviderResponseError, from transcription.providers.base import ProviderError
TranscriptionProvider, from transcription.providers.base import ProviderResponseError
TranscriptionResult, from transcription.providers.base import TranscriptionProvider
) from transcription.providers.base import TranscriptionResult
from transcription.providers.openrouter import OpenRouterTranscriptionProvider from transcription.providers.openrouter import OpenRouterTranscriptionProvider
@@ -21,11 +21,11 @@ def get_transcription_provider(*, settings: Settings | None = None) -> Transcrip
__all__ = [ __all__ = [
"OpenRouterTranscriptionProvider",
"ProviderAuthError", "ProviderAuthError",
"ProviderError", "ProviderError",
"ProviderResponseError", "ProviderResponseError",
"TranscriptionProvider", "TranscriptionProvider",
"TranscriptionResult", "TranscriptionResult",
"OpenRouterTranscriptionProvider",
"get_transcription_provider", "get_transcription_provider",
] ]
+8 -12
View File
@@ -9,13 +9,12 @@ from typing import Any
from openrouter import OpenRouter from openrouter import OpenRouter
from transcription.config import Settings, get_settings from transcription.config import Settings
from transcription.providers.base import ( from transcription.config import get_settings
ProviderAuthError, from transcription.providers.base import ProviderAuthError
ProviderError, from transcription.providers.base import ProviderError
ProviderResponseError, from transcription.providers.base import ProviderResponseError
TranscriptionResult, from transcription.providers.base import TranscriptionResult
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -55,7 +54,7 @@ class OpenRouterTranscriptionProvider:
http_referer=request.http_referer, http_referer=request.http_referer,
x_open_router_title=request.x_open_router_title, x_open_router_title=request.x_open_router_title,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc:
message = str(exc).lower() message = str(exc).lower()
if "401" in message or "auth" in message or "api key" in message: if "401" in message or "auth" in message or "api key" in message:
raise ProviderAuthError("OpenRouter authentication failed") from exc raise ProviderAuthError("OpenRouter authentication failed") from exc
@@ -111,10 +110,7 @@ class OpenRouterTranscriptionProvider:
parts: list[str] = [] parts: list[str] = []
for item in content: for item in content:
text_part = None text_part = None
if isinstance(item, dict): text_part = item.get("text") if isinstance(item, dict) else self._get_optional_attr(item, "text")
text_part = item.get("text")
else:
text_part = self._get_optional_attr(item, "text")
if isinstance(text_part, str) and text_part.strip(): if isinstance(text_part, str) and text_part.strip():
parts.append(text_part.strip()) parts.append(text_part.strip())
+82
View File
@@ -0,0 +1,82 @@
"""Step 5 single-operator access control helpers."""
from __future__ import annotations
import base64
import binascii
import secrets
from fastapi import Request
from transcription.config import Settings
from transcription.errors import AppError
from transcription.errors import ErrorCategory
class AccessDeniedError(AppError):
"""Raised when a request is not authorized for operator actions."""
def __init__(self, message: str, *, suggestion: str, should_challenge: bool = True) -> None:
super().__init__(message, category=ErrorCategory.USER_INPUT, suggestion=suggestion)
self.should_challenge = should_challenge
def is_protected_path(path: str) -> bool:
"""Return True when a request path requires operator authentication."""
return path == "/ui" or path.startswith(("/ui/", "/api"))
def enforce_request_access(*, request: Request, settings: Settings) -> None:
"""Enforce basic operator access control for protected paths."""
if not settings.operator_access_enabled or not is_protected_path(request.url.path):
return
if not settings.operator_password:
raise AppError(
"Operator authentication is enabled but credentials are not configured",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Set OPERATOR_PASSWORD in the runtime environment and restart the app.",
)
authorization = request.headers.get("Authorization")
username, password = _parse_basic_authorization_header(authorization)
valid_username = secrets.compare_digest(username, settings.operator_username)
valid_password = secrets.compare_digest(password, settings.operator_password)
if not (valid_username and valid_password):
raise AccessDeniedError(
"Invalid operator credentials",
suggestion="Provide valid operator credentials and retry.",
)
def _parse_basic_authorization_header(value: str | None) -> tuple[str, str]:
if not value:
raise AccessDeniedError(
"Operator authentication required",
suggestion="Provide HTTP Basic operator credentials and retry.",
)
scheme, _, token = value.partition(" ")
if scheme.lower() != "basic" or not token:
raise AccessDeniedError(
"Operator authentication required",
suggestion="Provide HTTP Basic operator credentials and retry.",
)
try:
decoded = base64.b64decode(token, validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError) as exc:
raise AccessDeniedError(
"Invalid authentication header",
suggestion="Provide HTTP Basic operator credentials and retry.",
) from exc
username, sep, password = decoded.partition(":")
if not sep or not username:
raise AccessDeniedError(
"Invalid authentication header",
suggestion="Provide HTTP Basic operator credentials and retry.",
)
return username, password
+14 -19
View File
@@ -1,30 +1,25 @@
"""Service layer exports.""" """Service layer exports."""
from transcription.services.transcription import ( from transcription.services.transcription import DEFAULT_PROMPT_FILE
DEFAULT_PROMPT_FILE, from transcription.services.transcription import PromptLoadError
PromptLoadError, from transcription.services.transcription import TranscriptionError
TranscriptionError, from transcription.services.transcription import load_image_payload
load_image_payload, from transcription.services.transcription import load_prompt_text
load_prompt_text, from transcription.services.transcription import transcribe_document_image
transcribe_document_image, from transcription.services.upload import SUPPORTED_UPLOAD_EXTENSIONS
) from transcription.services.upload import UploadError
from transcription.services.upload import ( from transcription.services.upload import UploadJobResult
SUPPORTED_UPLOAD_EXTENSIONS, from transcription.services.upload import create_upload_job
UploadError,
UploadJobResult,
create_upload_job,
)
__all__ = [ __all__ = [
"DEFAULT_PROMPT_FILE", "DEFAULT_PROMPT_FILE",
"SUPPORTED_UPLOAD_EXTENSIONS",
"PromptLoadError", "PromptLoadError",
"TranscriptionError", "TranscriptionError",
"load_image_payload",
"load_prompt_text",
"transcribe_document_image",
"SUPPORTED_UPLOAD_EXTENSIONS",
"UploadError", "UploadError",
"UploadJobResult", "UploadJobResult",
"create_upload_job", "create_upload_job",
"load_image_payload",
"load_prompt_text",
"transcribe_document_image",
] ]
+264
View File
@@ -0,0 +1,264 @@
"""Step 3 functional services: job detail, revisions, search, and export."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from uuid import UUID
from sqlmodel import Session
from sqlmodel import select
from transcription.db import get_session
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.models import TranscriptRevision
class LibraryError(AppError):
"""Base error for review/search/export service pathways."""
@dataclass(frozen=True)
class JobDetail:
"""Job detail read model including latest transcript and accepted revision."""
job: Job
document: Document | None
transcript: Transcript | None
accepted_revision: TranscriptRevision | None
def list_jobs(*, session: Session | None = None) -> list[Job]:
"""Return jobs in most-recent-first order."""
if session is None:
with get_session() as local_session:
return list_jobs(session=local_session)
return list(session.exec(select(Job).order_by(Job.created_at.desc())).all())
def get_job_detail(*, job_id: UUID, session: Session | None = None) -> JobDetail:
"""Fetch job detail with related document/transcript and accepted revision."""
if session is None:
with get_session() as local_session:
return get_job_detail(job_id=job_id, session=local_session)
job = session.get(Job, job_id)
if job is None:
raise LibraryError(
f"Job not found: {job_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh jobs list and open a valid job id.",
)
document = session.get(Document, job.document_id)
transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
accepted_revision = session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.job_id == job.id, TranscriptRevision.accepted.is_(True))
.order_by(TranscriptRevision.revision_number.desc())
).first()
return JobDetail(
job=job,
document=document,
transcript=transcript,
accepted_revision=accepted_revision,
)
def add_revision(
*,
job_id: UUID,
text: str,
source: str = "user",
accepted: bool = False,
session: Session | None = None,
) -> TranscriptRevision:
"""Append a transcript revision and optionally mark it as accepted."""
if not text.strip():
raise LibraryError(
"Revision text cannot be empty",
category=ErrorCategory.VALIDATION,
suggestion="Provide non-empty transcript text and retry.",
)
if session is None:
with get_session() as local_session:
return add_revision(
job_id=job_id,
text=text,
source=source,
accepted=accepted,
session=local_session,
)
job = session.get(Job, job_id)
if job is None:
raise LibraryError(
f"Job not found: {job_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh jobs list and retry with a valid job id.",
)
revisions = list(
session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.job_id == job_id)
.order_by(TranscriptRevision.revision_number)
).all()
)
next_revision_number = (revisions[-1].revision_number + 1) if revisions else 1
if accepted:
for existing in revisions:
if existing.accepted:
existing.accepted = False
session.add(existing)
revision = TranscriptRevision(
job_id=job_id,
revision_number=next_revision_number,
text=text,
source=source,
accepted=accepted,
)
session.add(revision)
transcript = session.exec(select(Transcript).where(Transcript.job_id == job_id)).first()
if transcript is None:
transcript = Transcript(job_id=job_id)
transcript.text = text
transcript.error_detail = None
session.add(transcript)
job.updated_at = datetime.now(UTC)
if accepted:
job.status = JobStatus.COMPLETED
elif job.status == JobStatus.QUEUED:
job.status = JobStatus.TRANSCRIBED
session.add(job)
session.commit()
session.refresh(revision)
return revision
def accept_revision(*, revision_id: UUID, session: Session | None = None) -> TranscriptRevision:
"""Mark one revision as accepted and synchronize canonical transcript/job state."""
if session is None:
with get_session() as local_session:
return accept_revision(revision_id=revision_id, session=local_session)
revision = session.get(TranscriptRevision, revision_id)
if revision is None:
raise LibraryError(
f"Revision not found: {revision_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh job detail and select a valid revision.",
)
all_revisions = list(session.exec(select(TranscriptRevision).where(TranscriptRevision.job_id == revision.job_id)).all())
for item in all_revisions:
item.accepted = item.id == revision.id
session.add(item)
transcript = session.exec(select(Transcript).where(Transcript.job_id == revision.job_id)).first()
if transcript is None:
transcript = Transcript(job_id=revision.job_id)
transcript.text = revision.text
transcript.error_detail = None
session.add(transcript)
job = session.get(Job, revision.job_id)
if job is not None:
job.status = JobStatus.COMPLETED
job.updated_at = datetime.now(UTC)
session.add(job)
session.commit()
session.refresh(revision)
return revision
def list_revisions(*, job_id: UUID, session: Session | None = None) -> list[TranscriptRevision]:
"""Return revision history for a job in ascending revision order."""
if session is None:
with get_session() as local_session:
return list_revisions(job_id=job_id, session=local_session)
if session.get(Job, job_id) is None:
raise LibraryError(
f"Job not found: {job_id}",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh jobs list and open a valid job id.",
)
return list(
session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.job_id == job_id)
.order_by(TranscriptRevision.revision_number)
).all()
)
def search_accepted_transcripts(*, query: str, session: Session | None = None) -> list[TranscriptRevision]:
"""Search accepted transcript revisions using case-insensitive text containment."""
if not query.strip():
raise LibraryError(
"Search query cannot be empty",
category=ErrorCategory.VALIDATION,
suggestion="Enter a non-empty search query and retry.",
)
if session is None:
with get_session() as local_session:
return search_accepted_transcripts(query=query, session=local_session)
pattern = f"%{query.strip()}%"
return list(
session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.accepted.is_(True), TranscriptRevision.text.ilike(pattern))
.order_by(TranscriptRevision.created_at.desc())
).all()
)
def export_transcripts(*, accepted_only: bool = True, session: Session | None = None) -> list[dict[str, str | int | None]]:
"""Export transcript data as serializable records for archive workflows."""
if session is None:
with get_session() as local_session:
return export_transcripts(accepted_only=accepted_only, session=local_session)
statement = select(TranscriptRevision).order_by(TranscriptRevision.created_at)
if accepted_only:
statement = statement.where(TranscriptRevision.accepted.is_(True))
revisions = list(session.exec(statement).all())
payload: list[dict[str, str | int | None]] = []
for revision in revisions:
detail = get_job_detail(job_id=revision.job_id, session=session)
payload.append(
{
"job_id": str(revision.job_id),
"document_id": str(detail.job.document_id),
"filename": detail.document.filename if detail.document else None,
"revision_id": str(revision.id),
"revision_number": revision.revision_number,
"accepted": revision.accepted,
"source": revision.source,
"text": revision.text,
"created_at": revision.created_at.isoformat(),
}
)
return payload
+10 -10
View File
@@ -6,16 +6,16 @@ import logging
import mimetypes import mimetypes
from pathlib import Path from pathlib import Path
from transcription.config import Settings, get_settings from transcription.config import Settings
from transcription.errors import AppError, ErrorCategory from transcription.config import get_settings
from transcription.providers import ( from transcription.errors import AppError
ProviderAuthError, from transcription.errors import ErrorCategory
ProviderError, from transcription.providers import ProviderAuthError
ProviderResponseError, from transcription.providers import ProviderError
TranscriptionProvider, from transcription.providers import ProviderResponseError
TranscriptionResult, from transcription.providers import TranscriptionProvider
get_transcription_provider, from transcription.providers import TranscriptionResult
) from transcription.providers import get_transcription_provider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+44 -19
View File
@@ -5,14 +5,19 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from uuid import UUID, uuid4 from uuid import UUID
from uuid import uuid4
from sqlmodel import Session from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings, get_settings from transcription.config import Settings
from transcription.config import get_settings
from transcription.db import get_session from transcription.db import get_session
from transcription.errors import AppError, ErrorCategory from transcription.errors import AppError
from transcription.models import Document, Job, JobStatus from transcription.errors import ErrorCategory
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -33,16 +38,20 @@ class UploadJobResult:
original_filename: str original_filename: str
def create_upload_job( async def create_upload_job(
*, *,
filename: str, filename: str,
file_bytes: bytes, file_bytes: bytes,
session: Session | None = None, session: AsyncSession | None = None,
settings: Settings | None = None, settings: Settings | None = None,
) -> UploadJobResult: ) -> UploadJobResult:
"""Persist an uploaded file and create document/job records.""" """Persist an uploaded file and create document/job records."""
runtime_settings = settings or get_settings() runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes) _validate_upload(
filename=filename,
file_bytes=file_bytes,
max_upload_bytes=runtime_settings.max_upload_bytes,
)
upload_dir = runtime_settings.upload_dir upload_dir = runtime_settings.upload_dir
upload_dir.mkdir(parents=True, exist_ok=True) upload_dir.mkdir(parents=True, exist_ok=True)
@@ -61,15 +70,19 @@ def create_upload_job(
try: try:
if session is not None: if session is not None:
document, job = _create_upload_records(session=session, original_filename=filename, stored_path=stored_path) document, job = await _create_upload_records(
session=session,
original_filename=filename,
stored_path=stored_path,
)
else: else:
with get_session() as local_session: async with get_session() as local_session:
document, job = _create_upload_records( document, job = await _create_upload_records(
session=local_session, session=local_session,
original_filename=filename, original_filename=filename,
stored_path=stored_path, stored_path=stored_path,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc:
_best_effort_delete(stored_path) _best_effort_delete(stored_path)
raise UploadError( raise UploadError(
"Failed to create upload database records", "Failed to create upload database records",
@@ -87,7 +100,7 @@ def create_upload_job(
) )
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: if not file_bytes:
raise UploadError( raise UploadError(
"Upload payload is empty", "Upload payload is empty",
@@ -95,6 +108,13 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
suggestion="Select a non-empty file and try again.", suggestion="Select a non-empty file and try again.",
) )
if len(file_bytes) > max_upload_bytes:
raise UploadError(
f"Upload exceeds maximum allowed size ({max_upload_bytes} bytes)",
category=ErrorCategory.USER_INPUT,
suggestion="Upload a smaller file or increase MAX_UPLOAD_BYTES for this deployment.",
)
safe_name = Path(filename).name safe_name = Path(filename).name
if not safe_name: if not safe_name:
raise UploadError( raise UploadError(
@@ -117,22 +137,27 @@ def _build_stored_filename(filename: str) -> str:
return f"{uuid4()}_{safe_name}" return f"{uuid4()}_{safe_name}"
def _create_upload_records(*, session: Session, original_filename: str, stored_path: Path) -> tuple[Document, Job]: async def _create_upload_records(
*,
session: AsyncSession,
original_filename: str,
stored_path: Path,
) -> tuple[Document, Job]:
document = Document( document = Document(
filename=Path(original_filename).name, filename=Path(original_filename).name,
file_path=str(stored_path), file_path=str(stored_path),
) )
session.add(document) session.add(document)
session.flush() await session.flush()
job = Job( job = Job(
document_id=document.id, document_id=document.id,
status=JobStatus.QUEUED, status=JobStatus.QUEUED,
) )
session.add(job) session.add(job)
session.commit() await session.commit()
session.refresh(document) await session.refresh(document)
session.refresh(job) await session.refresh(job)
return document, job return document, job
@@ -141,4 +166,4 @@ def _best_effort_delete(path: Path) -> None:
if path.exists(): if path.exists():
path.unlink() path.unlink()
except OSError: except OSError:
logger.warning("Failed to clean up upload file after DB error: %s", path) logger.warning("Failed to clean up upload file after DB error: %s", path)
+2 -4
View File
@@ -3,8 +3,8 @@
from fastapi import FastAPI from fastapi import FastAPI
from nicegui import ui from nicegui import ui
from transcription.ui.jobs_page import register_page as register_jobs_page from transcription.ui.pages.jobs_page import register_page as register_jobs_page
from transcription.ui.upload_page import register_page as register_upload_page from transcription.ui.pages.upload_page import register_page as register_upload_page
def register_pages(app: FastAPI) -> None: def register_pages(app: FastAPI) -> None:
@@ -12,5 +12,3 @@ def register_pages(app: FastAPI) -> None:
register_upload_page() register_upload_page()
register_jobs_page() register_jobs_page()
ui.run_with(app, mount_path="/ui", show_welcome_message=False) ui.run_with(app, mount_path="/ui", show_welcome_message=False)
@@ -4,7 +4,9 @@ from __future__ import annotations
from nicegui import ui from nicegui import ui
from transcription.errors import AppError, ErrorCategory, classify_unexpected_error from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.errors import classify_unexpected_error
def to_app_error(exc: Exception, *, operation: str) -> AppError: def to_app_error(exc: Exception, *, operation: str) -> AppError:
@@ -37,4 +39,4 @@ def summarize_error(exc: Exception, *, operation: str) -> str:
error = to_app_error(exc, operation=operation) error = to_app_error(exc, operation=operation)
if error.category == ErrorCategory.INTERNAL_UNEXPECTED: if error.category == ErrorCategory.INTERNAL_UNEXPECTED:
return f"Unexpected error (ref: {error.error_id})" return f"Unexpected error (ref: {error.error_id})"
return f"{error.message} (ref: {error.error_id})" return f"{error.message} (ref: {error.error_id})"
@@ -0,0 +1,30 @@
"""Reusable job detail rendering helpers."""
from __future__ import annotations
from nicegui import ui
from transcription.models import Document
from transcription.models import Job
from transcription.models import Transcript
def render_job_detail(*, job: Job, document: Document | None, transcript: Transcript | None) -> None:
"""Render all sections for the job detail page."""
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)
@@ -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']}")
+110 -2
View File
@@ -9,8 +9,16 @@ from nicegui import ui
from sqlmodel import select from sqlmodel import select
from transcription.db import get_session from transcription.db import get_session
from transcription.models import Document, Job, Transcript from transcription.models import Document
from transcription.ui.error_presenter import show_error, summarize_error 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) @dataclass(frozen=True)
@@ -134,4 +142,104 @@ def register_page() -> None:
ui.label("Failure detail:") ui.label("Failure detail:")
ui.label(transcript.error_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") ui.link("Back to jobs", "/jobs")
+92
View File
@@ -0,0 +1,92 @@
"""Jobs list and detail page registration."""
from __future__ import annotations
from uuid import UUID
from nicegui import ui
from sqlmodel import desc
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.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.job_table import JobTableRow
from transcription.ui.components.job_table import render_jobs_table
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() -> None:
ui.label("Transcription Jobs")
status = ui.label("Ready")
@ui.refreshable
async def render_table() -> None:
jobs = await fetch_jobs()
render_jobs_table(jobs)
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) -> 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 = await fetch_job_detail(parsed_id)
if job is None:
ui.label("Job not found")
ui.link("Back to jobs", "/jobs")
return
render_job_detail(job=job, document=document, transcript=transcript)
ui.link("Back to jobs", "/jobs")
@@ -7,8 +7,11 @@ from dataclasses import dataclass
from nicegui import ui from nicegui import ui
from nicegui.events import UploadEventArguments from nicegui.events import UploadEventArguments
from transcription.services.upload import UploadError, UploadJobResult, create_upload_job from transcription.services.upload import UploadError
from transcription.ui.error_presenter import show_error, summarize_error 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 @dataclass
@@ -24,9 +27,9 @@ def accepted_upload_types() -> str:
return ".jpg,.jpeg,.png,.tif,.tiff,.pdf" return ".jpg,.jpeg,.png,.tif,.tiff,.pdf"
def submit_upload(*, filename: str, file_bytes: bytes) -> UploadJobResult: async def submit_upload(*, filename: str, file_bytes: bytes) -> UploadJobResult:
"""Create an upload job from incoming file data.""" """Create an upload job from incoming file data."""
return create_upload_job(filename=filename, file_bytes=file_bytes) return await create_upload_job(filename=filename, file_bytes=file_bytes)
def register_page() -> None: def register_page() -> None:
@@ -46,7 +49,7 @@ def register_page() -> None:
status_label.text = "Uploading..." status_label.text = "Uploading..."
try: try:
payload = await event.file.read() payload = await event.file.read()
result = submit_upload(filename=event.file.name, file_bytes=payload) result = await submit_upload(filename=event.file.name, file_bytes=payload)
state.message = f"Created job {result.job_id}" state.message = f"Created job {result.job_id}"
status_label.text = state.message status_label.text = state.message
ui.notify(state.message, type="positive") ui.notify(state.message, type="positive")
+77 -44
View File
@@ -2,53 +2,62 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import time from datetime import UTC
from datetime import datetime, timezone from datetime import datetime
from threading import Event from threading import Event
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy.engine import Engine from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel import Session, select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings, get_settings from transcription.config import Settings
from transcription.config import get_settings
from transcription.db import get_session from transcription.db import get_session
from transcription.errors import AppError, ErrorCategory, classify_unexpected_error, format_error_detail from transcription.errors import AppError
from transcription.models import Document, Job, JobStatus, Transcript from transcription.errors import ErrorCategory
from transcription.errors import classify_unexpected_error
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 from transcription.services.transcription import transcribe_document_image
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def process_next_queued_job(*, session: Session | None = None, engine: Engine | None = None) -> bool: async def process_next_queued_job(
*,
session: AsyncSession | None = None,
session_factory: async_sessionmaker[AsyncSession] | None = None,
) -> bool:
"""Process the next queued job and persist terminal outcome. """Process the next queued job and persist terminal outcome.
Returns True when a job was processed, False when no queued job exists. Returns True when a job was processed, False when no queued job exists.
""" """
if session is None: if session is None:
with get_session(engine=engine) as local_session: async with get_session(session_factory=session_factory) as local_session:
return _process_next_queued_job(session=local_session) return await _process_next_queued_job(session=local_session)
return _process_next_queued_job(session=session) return await _process_next_queued_job(session=session)
def _process_next_queued_job(*, session: Session) -> bool: async def _process_next_queued_job(*, session: AsyncSession) -> bool:
job = session.exec( job = (await session.exec(select(Job).where(Job.status == JobStatus.QUEUED).order_by(Job.created_at))).first()
select(Job)
.where(Job.status == JobStatus.QUEUED)
.order_by(Job.created_at)
).first()
if job is None: if job is None:
return False return False
logger.info("Picked queued job operation=worker.pick job_id=%s", job.id) logger.info("Picked queued job operation=worker.pick job_id=%s", job.id)
job.status = JobStatus.PROCESSING job.status = JobStatus.PROCESSING
job.updated_at = datetime.now(timezone.utc) job.updated_at = datetime.now(UTC)
session.add(job) session.add(job)
session.commit() await session.commit()
session.refresh(job) await session.refresh(job)
document = session.get(Document, job.document_id) document = await session.get(Document, job.document_id)
if document is None: if document is None:
error = AppError( error = AppError(
"Document not found", "Document not found",
@@ -66,22 +75,23 @@ def _process_next_queued_job(*, session: Session) -> bool:
try: try:
result = transcribe_document_image(document.file_path) result = transcribe_document_image(document.file_path)
_upsert_transcript(session=session, job_id=job.id, text=result.text, error_detail=None) await _upsert_transcript(session=session, job_id=job.id, text=result.text, error_detail=None)
job.status = JobStatus.TRANSCRIBED job.status = JobStatus.TRANSCRIBED
job.updated_at = datetime.now(timezone.utc) job.updated_at = datetime.now(UTC)
session.add(job) session.add(job)
session.commit() await session.commit()
logger.info( logger.info(
"Job transcribed operation=worker.process_job job_id=%s document_id=%s provider=%s", "Job transcribed operation=worker.process_job job_id=%s document_id=%s provider=%s revision_number=%s",
job.id, job.id,
document.id, document.id,
result.provider, result.provider,
revision.revision_number,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc:
error = exc if isinstance(exc, AppError) else classify_unexpected_error(exc, operation="worker.process_job") error = exc if isinstance(exc, AppError) else classify_unexpected_error(exc, operation="worker.process_job")
settings = _get_worker_settings() settings = _get_worker_settings()
if _should_retry(job=job, error=error, settings=settings): if _should_retry(job=job, error=error, settings=settings):
_requeue_for_retry(session=session, job=job, error=error, settings=settings) await _requeue_for_retry(session=session, job=job, error=error, settings=settings)
logger.warning( logger.warning(
"Job retried operation=worker.process_job job_id=%s document_id=%s retry_count=%s error_id=%s category=%s", "Job retried operation=worker.process_job job_id=%s document_id=%s retry_count=%s error_id=%s category=%s",
job.id, job.id,
@@ -91,7 +101,7 @@ def _process_next_queued_job(*, session: Session) -> bool:
error.category.value, error.category.value,
) )
else: else:
_finalize_failed_job(session=session, job=job, error=error) await _finalize_failed_job(session=session, job=job, error=error)
logger.exception( logger.exception(
"Job failed operation=worker.process_job job_id=%s document_id=%s error_id=%s category=%s", "Job failed operation=worker.process_job job_id=%s document_id=%s error_id=%s category=%s",
job.id, job.id,
@@ -103,16 +113,18 @@ def _process_next_queued_job(*, session: Session) -> bool:
return True return True
def _upsert_transcript(*, session: Session, job_id, text: str | None, error_detail: str | None) -> Transcript: async def _upsert_transcript(
transcript = session.exec(select(Transcript).where(Transcript.job_id == job_id)).first() *, 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: if transcript is None:
transcript = Transcript(job_id=job_id) transcript = Transcript(job_id=job_id)
transcript.text = text transcript.text = text
transcript.error_detail = error_detail transcript.error_detail = error_detail
session.add(transcript) session.add(transcript)
session.commit() await session.commit()
session.refresh(transcript) await session.refresh(transcript)
return transcript return transcript
@@ -127,32 +139,53 @@ def _should_retry(*, job: Job, error: AppError, settings: Settings) -> bool:
return error.retriable and job.retry_count < settings.worker_max_retries return error.retriable and job.retry_count < settings.worker_max_retries
def _requeue_for_retry(*, session: Session, job: Job, error: AppError, settings: Settings) -> None: async def _requeue_for_retry(*, session: AsyncSession, job: Job, error: AppError, settings: Settings) -> None:
_upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error)) await _upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error))
job.retry_count += 1 job.retry_count += 1
job.status = JobStatus.QUEUED job.status = JobStatus.QUEUED
job.updated_at = datetime.now(timezone.utc) job.updated_at = datetime.now(UTC)
session.add(job) session.add(job)
session.commit() await session.commit()
if settings.worker_retry_backoff_seconds > 0: if settings.worker_retry_backoff_seconds > 0:
time.sleep(settings.worker_retry_backoff_seconds) await asyncio.sleep(settings.worker_retry_backoff_seconds)
def _finalize_failed_job(*, session: Session, job: Job, error: AppError) -> None: async def _finalize_failed_job(*, session: AsyncSession, job: Job, error: AppError) -> None:
_upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error)) await _upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error))
job.status = JobStatus.FAILED job.status = JobStatus.FAILED
job.updated_at = datetime.now(timezone.utc) job.updated_at = datetime.now(UTC)
session.add(job) session.add(job)
session.commit() await session.commit()
def run_worker_loop(*, engine: Engine | None = None, stop_event: Event | None = None, poll_interval_seconds: float = 1.0) -> None: 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.""" """Run worker polling loop until stop_event is set."""
while True: while True:
if stop_event is not None and stop_event.is_set(): if stop_event is not None and stop_event.is_set():
logger.info("Worker stop event received") logger.info("Worker stop event received")
return return
processed = process_next_queued_job(engine=engine) processed = await process_next_queued_job(session_factory=session_factory)
if not processed: if not processed:
time.sleep(poll_interval_seconds) await asyncio.sleep(poll_interval_seconds)
def run_worker_loop(
*,
session_factory: async_sessionmaker[AsyncSession] | None = None,
stop_event: Event | None = None,
poll_interval_seconds: float = 1.0,
) -> None:
"""Synchronous thread entrypoint that runs the async worker loop."""
asyncio.run(
_run_worker_loop_async(
session_factory=session_factory,
stop_event=stop_event,
poll_interval_seconds=poll_interval_seconds,
)
)
+129
View File
@@ -0,0 +1,129 @@
"""Tests for Step 5 operator access control behavior."""
from __future__ import annotations
import base64
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from transcription.api.errors import register_error_handlers
from transcription.errors import build_error_envelope
from transcription.security import AccessDeniedError
from transcription.security import enforce_request_access
def _basic_header(username: str, password: str) -> str:
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
return f"Basic {token}"
def _build_app(*, settings) -> FastAPI:
app = FastAPI()
register_error_handlers(app)
@app.middleware("http")
async def operator_access_middleware(request, call_next):
try:
enforce_request_access(request=request, settings=settings)
except AccessDeniedError as exc:
envelope = build_error_envelope(exc)
headers = {"WWW-Authenticate": "Basic"} if exc.should_challenge else None
return JSONResponse(status_code=401, content=envelope.__dict__, headers=headers)
return await call_next(request)
@app.get("/healthz")
def healthz():
return {"status": "ok"}
@app.get("/api/jobs")
def get_jobs():
return [{"id": "demo"}]
@app.get("/ui")
def ui_root():
return {"ok": True}
return app
@pytest.mark.integration
class TestAccessControl:
"""Verify protected routes enforce operator auth when enabled."""
def test_protected_api_requires_credentials(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get("/api/jobs")
assert response.status_code == 401
assert response.headers.get("WWW-Authenticate") == "Basic"
payload = response.json()
assert payload["category"] == "user_input_error"
assert payload["suggestion"]
def test_protected_api_rejects_invalid_credentials(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get(
"/api/jobs",
headers={"Authorization": _basic_header("operator", "wrong")},
)
assert response.status_code == 401
payload = response.json()
assert payload["message"] == "Invalid operator credentials"
def test_protected_api_allows_valid_credentials(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get(
"/api/jobs",
headers={"Authorization": _basic_header("operator", "secret")},
)
assert response.status_code == 200
assert response.json() == [{"id": "demo"}]
def test_protected_ui_path_requires_credentials(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get("/ui")
assert response.status_code == 401
def test_healthz_is_not_protected(self):
settings = SimpleNamespace(
operator_access_enabled=True,
operator_username="operator",
operator_password="secret",
)
client = TestClient(_build_app(settings=settings), raise_server_exceptions=False)
response = client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
+3 -2
View File
@@ -1,11 +1,12 @@
"""Tests for API error response envelope handlers.""" """Tests for API error response envelope handlers."""
import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import pytest
from transcription.api.errors import register_error_handlers from transcription.api.errors import register_error_handlers
from transcription.errors import AppError, ErrorCategory from transcription.errors import AppError
from transcription.errors import ErrorCategory
@pytest.mark.integration @pytest.mark.integration
+119
View File
@@ -0,0 +1,119 @@
"""Tests for Step 3 functional API routes."""
from datetime import UTC
from datetime import datetime
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from transcription.api.errors import register_error_handlers
from transcription.api.routes import router
def _build_app() -> FastAPI:
app = FastAPI()
register_error_handlers(app)
app.include_router(router)
return app
@pytest.mark.integration
class TestFunctionalRoutes:
"""Verify jobs/revisions/search/export route behavior."""
def test_get_jobs_returns_serialized_rows(self, monkeypatch):
"""GET /api/jobs returns normalized job rows."""
now = datetime.now(UTC)
job = SimpleNamespace(
id=uuid4(),
document_id=uuid4(),
status=SimpleNamespace(value="queued"),
created_at=now,
updated_at=now,
)
monkeypatch.setattr("transcription.api.routes.list_jobs", lambda: [job])
client = TestClient(_build_app())
response = client.get("/api/jobs")
assert response.status_code == 200
payload = response.json()
assert len(payload) == 1
assert payload[0]["id"] == str(job.id)
assert payload[0]["status"] == "queued"
def test_create_revision_returns_revision_payload(self, monkeypatch):
"""POST /api/jobs/{job_id}/revisions returns created revision fields."""
revision = SimpleNamespace(
id=uuid4(),
job_id=uuid4(),
revision_number=2,
text="edited text",
source="user",
accepted=False,
created_at=datetime.now(UTC),
)
monkeypatch.setattr("transcription.api.routes.add_revision", lambda **_kwargs: revision)
client = TestClient(_build_app())
response = client.post(
f"/api/jobs/{revision.job_id}/revisions",
json={"text": "edited text", "source": "user", "accepted": False},
)
assert response.status_code == 200
payload = response.json()
assert payload["id"] == str(revision.id)
assert payload["revision_number"] == 2
assert payload["text"] == "edited text"
def test_search_returns_results(self, monkeypatch):
"""GET /api/search returns accepted transcript matches."""
result = SimpleNamespace(
id=uuid4(),
job_id=uuid4(),
revision_number=1,
text="family archive",
source="user",
accepted=True,
created_at=datetime.now(UTC),
)
monkeypatch.setattr("transcription.api.routes.search_accepted_transcripts", lambda query: [result])
client = TestClient(_build_app())
response = client.get("/api/search", params={"query": "archive"})
assert response.status_code == 200
payload = response.json()
assert len(payload) == 1
assert payload[0]["revision_id"] == str(result.id)
assert payload[0]["accepted"] is True
def test_export_returns_count_and_records(self, monkeypatch):
"""GET /api/export returns record count and payload list."""
records = [
{
"job_id": str(uuid4()),
"document_id": str(uuid4()),
"filename": "letter.jpg",
"revision_id": str(uuid4()),
"revision_number": 1,
"accepted": True,
"source": "user",
"text": "exported",
"created_at": datetime.now(UTC).isoformat(),
}
]
monkeypatch.setattr("transcription.api.routes.export_transcripts", lambda accepted_only=True: records)
client = TestClient(_build_app())
response = client.get("/api/export")
assert response.status_code == 200
payload = response.json()
assert payload["count"] == 1
assert payload["accepted_only"] is True
assert payload["records"] == records
@@ -11,11 +11,11 @@ BOOK 1 had 54 pages; 14 chapters. BOOK 2 has 70 pages; 18 chapters. BOOK 1 con-
sisted largely of first generation family history. BOOK 2 throws more light on sisted largely of first generation family history. BOOK 2 throws more light on
the second generation. Sidney promises a BOOK 3 and that may begin to do justice the second generation. Sidney promises a BOOK 3 and that may begin to do justice
to the third generation. We suggest that Sidney get the help of Louis Shinn to the third generation. We suggest that Sidney get the help of Louis Shinn
who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumeeq who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumecq
Plains. Louis has the gift of seeing, recalling and telling. One sentence in Plains. Louis has the gift of seeing, recalling and telling. One sentence in
his chapter gives a great tribute to the Doumeeqers - so far as he knows no one his chapter gives a great tribute to the Doumecqers--so far as he knows no one
on the Doumeeq Plains went on relief during the depression. That in a nutshell on the Doumecq Plains went on relief during the depression. That in a nutshell
shows the sturdy character of the residents of the Doumeeq Plains. shows the sturdy character of the residents of the Doumecq Plains.
We promised in BOOK 1 that in BOOK 2 we would give the story of the trip of We promised in BOOK 1 that in BOOK 2 we would give the story of the trip of
John E. Cochran and wife to Tennessee, Cuba and the Panama Canal. You will see John E. Cochran and wife to Tennessee, Cuba and the Panama Canal. You will see
@@ -32,7 +32,7 @@ enough pictures but we had to take only part of them. We think there are great
possibilities in reproducing old pictures. We wish we had a Pickard group. Some possibilities in reproducing old pictures. We wish we had a Pickard group. Some
Pickard descendant may wish to make a collection. Pickard descendant may wish to make a collection.
We are much impressed with the future possibilities of getting a complete geneol- We are much impressed with the future possibilities of getting a complete geneol-[sic]
ogy of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but ogy of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but
to date we have not had the pleasure of finding all of the family dates. We had to date we have not had the pleasure of finding all of the family dates. We had
intended to give more family data in this book but it takes time to get the intended to give more family data in this book but it takes time to get the
@@ -6,9 +6,7 @@ JOHN E. COCHRAN
FAMILY ASSOCIATION FAMILY ASSOCIATION
Family Only Family Only
Home | Sibling's Stories | 1st Cousins | Ancestor's Stories | Reunion History | Next Reunion | JECMEF Home | Sibling's Stories | 1st Cousins | Ancestor's Stories | Reunion History | Next Reunion | JECMEF
OMIE WRITES HOME OMIE WRITES HOME
Ed. Note: The following letter was written by Omie Cochran in Nome, Alaska and sent to her Ed. Note: The following letter was written by Omie Cochran in Nome, Alaska and sent to her
sister, Ethel Shinn, in Canfield, Idaho in 1923. It has been stored away these 63 years in the sister, Ethel Shinn, in Canfield, Idaho in 1923. It has been stored away these 63 years in the
original envelope with its 2 cent stamp. The letter has a number of references to the Shinn original envelope with its 2 cent stamp. The letter has a number of references to the Shinn
@@ -17,6 +15,7 @@ Miss Saville was the nurse at the Nome Hospital that was mentioned in the articl
the family newsletter two years ago. the family newsletter two years ago.
Nome Alaska August 26, 1923 Nome Alaska August 26, 1923
My Dear Ethel et al. My Dear Ethel et al.
I don't know when I did write or when you did I don't know when I did write or when you did
@@ -27,7 +26,7 @@ and Polly sit up and listen and that little black
rascal of yours would fairly sparkle with rascal of yours would fairly sparkle with
listening. Can't I see him listening now to all the listening. Can't I see him listening now to all the
yarns we told last summer? yarns we told last summer?
[photo of people on ice with kayak and dog sled]
You see, we-Miss Saville and I, took a trip north You see, we-Miss Saville and I, took a trip north
on the Buford and it was very interesting. We on the Buford and it was very interesting. We
went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain
@@ -38,19 +37,18 @@ along the icebergs and the walrus were 'heisted' on board by the use of the cran
tons of freight and the beasts were so huge that they made the pulleys just creak. They were tons of freight and the beasts were so huge that they made the pulleys just creak. They were
over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or
flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles
all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are
the most repulsive looking animals imaginable and tho I have always read about them I never the most repulsive looking animals imaginable and tho I have always read about them I never
expect such disagreeable looking creatures. They had a rough brown hairy skin and some of expect such disagreeable looking creatures. They had a rough brown hairy skin and some of
them looked warty. They must have weighed two ton at least. Ere we got them back to Nome them looked warty. They must have weighed two ton at least. Ere we got them back to Nome
to the natives they were getting extremely odiferousin fact, you could scarcely stay on the to the natives they were getting extremely odiferousin fact, you could scarcely stay on the
ship with any degree of comfort unless you had per chance lost your sense of smell. ship with any degree of comfort unless you had per chance lost your sense of smell.
Then we went north to a few minutes beyond the 70th degree of latitude and thot [sic] for awhile Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile
we would go to Wrangell Island where some men from Stefflonsons [sic] ship were supposed to be we would go to Wrangell Island where some men from Steffonsons ship were supposed to be
stranded but we didn't get there and instead stopped at a small native village at Cape Serdz [sic] in stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in
Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23 Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23
years. He was a Swedewho else could. Their houses were circular and built up with dirt 2 or years. He was a Swede--who else could. Their houses were circular and built up with dirt 2 or
3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room 3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room
was partitioned off at the sides with skins for sleeping quarters. In the main part they had the was partitioned off at the sides with skins for sleeping quarters. In the main part they had the
fire on the ground and the fish drying on lines and the skins hanging around and the dogs and fire on the ground and the fish drying on lines and the skins hanging around and the dogs and
@@ -65,23 +63,22 @@ The other place we stopped was at Whalen, a trading post in Siberia. There these
went wild. They rushed helter-skelter, hither and thither, here and there, trying to find went wild. They rushed helter-skelter, hither and thither, here and there, trying to find
something to buy. Prices raised right before your eyes. One would but something for $1.00 something to buy. Prices raised right before your eyes. One would but something for $1.00
and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to
have it. One man I was sort of taking care of, tho [sic] he had his son along for the purpose, have it. One man I was sort of taking care of, tho he had his son along for the purpose,
bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billi[illegible]s, 6 or 8 ivory and silver rings, one bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billikens, 6 or 8 ivory and silver rings, one
fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the
little natives throw them at the flocks of geese and ducks which fly close over the village and little natives throw them at the flocks of geese and ducks which fly close over the village and
the slings entangle their wings and legs, sometimes more than one, and they can't fly. They the slings entangle their wings and legs, sometimes more than one, and they can't fly. They
come down and the natives capture them. There was more junk brot [sic] aboard than baggage, I come down and the natives capture them. There was more junk brot aboard than baggage, I
do believe. And they say that at the first stop it was worse than here. The red flag was flying do believe. And they say that at the first stop it was worse than here. The red flag was flying
over Whalen and the Russian soldiers were therea few, one or two or three, I forget the over Whalen and the Russian soldiers were therea few, one or two or three, I forget the
number. number.
We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out
until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let
us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is
all I had to write about and I know Buster'd [sic] listen anyway and I'd soak ole Peter's head if he all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he
didn't and Polly would in my lap and I don't know much about the youngest one of yours so didn't and Polly would in my lap and I don't know much about the youngest one of yours so
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf [sic]. likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf.
I expect there were 150 passengers on board and almost or more of the crew and helpers. We I expect there were 150 passengers on board and almost or more of the crew and helpers. We
had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times. had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times.
@@ -109,7 +106,7 @@ Ome
Reprinted from Cochran Chronicles, Volume 9, Number 1, November 1986 Reprinted from Cochran Chronicles, Volume 9, Number 1, November 1986
© [inserted: JECFA] 1986 © JECFA 1986
Up Up
@@ -2,30 +2,28 @@ source: Rod Moser Letter - p1.jpg
provider: openrouter provider: openrouter
model: google/gemini-2.5-flash model: google/gemini-2.5-flash
--- ---
JOHN ISBILL JOHN ISBILL R. T. MOSER
R. T. MOSER
ISBILL & MOSER ISBILL & MOSER
DEALERS IN DEALERS IN
GENERAL MERCHANDISE GENERAL MERCHANDISE
Vonore, Tenn., Jany 27- 1913 Vonore, Tenn. January 27 - 1913
Dear Much Aunt Louie Dear Uncle [sic] Aun[t Adeline?]
How are you a Was at home a
few nights ago I sewed a few nights ago & saw a
letter from your folks, so letter from you folks, so
I decided to write you I decided to write you
a few lines myself ok a few lines myself &
I am contemplateing a I am contemplating a
trip out west next summer trip out west next summ[er]
& I want Some Olders to go & I want both of fillers [sic] to go
where I and them. when I am [to] them.
Am getting
I am getting up in years & unmarried,
up in years & unmarried
so you see the object of so you see the object of
my trip, is to get a bunch my trip, is to get a wife
of Young & old maids & I hear is a lot old maids
& widows out there. I & widows out there. I
want you to kiss them want you to see them
at my fans [sic] mug as they at my land my [sic] at there [sic]
as soon as I get there as soon as I get there
+3 -1
View File
@@ -5,7 +5,9 @@ isolated, fast, and leave no artifacts on disk.
""" """
import pytest import pytest
from sqlmodel import Session, SQLModel, create_engine from sqlmodel import Session
from sqlmodel import SQLModel
from sqlmodel import create_engine
from sqlmodel.pool import StaticPool from sqlmodel.pool import StaticPool
+4 -2
View File
@@ -6,7 +6,9 @@ import pytest
from sqlmodel import select from sqlmodel import select
from transcription.config import Settings from transcription.config import Settings
from transcription.models import Job, JobStatus, Transcript from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.providers.base import TranscriptionResult from transcription.providers.base import TranscriptionResult
from transcription.services.upload import create_upload_job from transcription.services.upload import create_upload_job
from transcription.worker import process_next_queued_job from transcription.worker import process_next_queued_job
@@ -71,6 +73,6 @@ class TestPipelineFailureFlow:
assert job.status == JobStatus.FAILED assert job.status == JobStatus.FAILED
assert transcript is not None assert transcript is not None
assert transcript.text is None assert transcript.text is None
assert "pipeline provider failure" in transcript.error_detail assert "pipeline provider failure" not in transcript.error_detail
assert "[internal_unexpected_error]" in transcript.error_detail assert "[internal_unexpected_error]" in transcript.error_detail
assert "error_id=" in transcript.error_detail assert "error_id=" in transcript.error_detail
+4 -2
View File
@@ -5,8 +5,10 @@ from types import SimpleNamespace
import pytest import pytest
from transcription.config import Settings from transcription.config import Settings
from transcription.providers.base import ProviderError, ProviderResponseError from transcription.providers.base import ProviderError
from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL, OpenRouterTranscriptionProvider from transcription.providers.base import ProviderResponseError
from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
class _FakeChat: class _FakeChat:
+98
View File
@@ -0,0 +1,98 @@
"""Tests for Step 3 library services (revisions, search, export)."""
import pytest
from sqlmodel import select
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.models import TranscriptRevision
from transcription.services.library import accept_revision
from transcription.services.library import add_revision
from transcription.services.library import export_transcripts
from transcription.services.library import list_revisions
from transcription.services.library import search_accepted_transcripts
def _create_job(session) -> Job:
document = Document(filename="letter.jpg", file_path="uploads/letter.jpg")
session.add(document)
session.commit()
session.refresh(document)
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED)
session.add(job)
session.commit()
session.refresh(job)
return job
@pytest.mark.integration
class TestRevisionHistoryBehavior:
"""Verify revision append/accept behavior."""
def test_add_revision_appends_incrementing_revision_numbers(self, session):
"""add_revision creates immutable incrementing revisions per job."""
job = _create_job(session)
r1 = add_revision(job_id=job.id, text="first", session=session)
r2 = add_revision(job_id=job.id, text="second", session=session)
revisions = list_revisions(job_id=job.id, session=session)
assert r1.revision_number == 1
assert r2.revision_number == 2
assert [revision.revision_number for revision in revisions] == [1, 2]
def test_accept_revision_marks_selected_revision_and_sets_job_completed(self, session):
"""accept_revision marks one revision accepted and transitions job to completed."""
job = _create_job(session)
r1 = add_revision(job_id=job.id, text="v1", session=session)
r2 = add_revision(job_id=job.id, text="v2", session=session)
accepted = accept_revision(revision_id=r2.id, session=session)
session.refresh(job)
all_revisions = list(session.exec(select(TranscriptRevision).where(TranscriptRevision.job_id == job.id)).all())
accepted_flags = {revision.id: revision.accepted for revision in all_revisions}
transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
assert accepted.id == r2.id
assert accepted_flags[r1.id] is False
assert accepted_flags[r2.id] is True
assert job.status == JobStatus.COMPLETED
assert transcript is not None
assert transcript.text == "v2"
@pytest.mark.integration
class TestSearchAndExportBehavior:
"""Verify accepted-only search and export semantics."""
def test_search_returns_only_accepted_revisions(self, session):
"""search_accepted_transcripts filters out non-accepted revisions."""
job = _create_job(session)
draft = add_revision(job_id=job.id, text="family archive draft", session=session)
accepted = add_revision(job_id=job.id, text="family archive final", accepted=True, session=session)
results = search_accepted_transcripts(query="archive", session=session)
assert results
result_ids = {result.id for result in results}
assert accepted.id in result_ids
assert draft.id not in result_ids
def test_export_returns_serializable_records_for_accepted_revisions(self, session):
"""export_transcripts returns expected fields for accepted-only export."""
job = _create_job(session)
accepted = add_revision(job_id=job.id, text="export me", accepted=True, session=session)
records = export_transcripts(accepted_only=True, session=session)
assert len(records) == 1
record = records[0]
assert record["job_id"] == str(job.id)
assert record["revision_id"] == str(accepted.id)
assert record["accepted"] is True
assert record["text"] == "export me"
+7 -8
View File
@@ -5,14 +5,13 @@ from pathlib import Path
import pytest import pytest
from transcription.config import Settings from transcription.config import Settings
from transcription.providers.base import ProviderError, TranscriptionResult from transcription.providers.base import ProviderError
from transcription.services.transcription import ( from transcription.providers.base import TranscriptionResult
PromptLoadError, from transcription.services.transcription import PromptLoadError
TranscriptionError, from transcription.services.transcription import TranscriptionError
load_image_payload, from transcription.services.transcription import load_image_payload
load_prompt_text, from transcription.services.transcription import load_prompt_text
transcribe_document_image, from transcription.services.transcription import transcribe_document_image
)
class _FakeProvider: class _FakeProvider:
@@ -7,7 +7,6 @@ import pytest
from transcription.services.transcription import transcribe_document_image from transcription.services.transcription import transcribe_document_image
HAS_OPENROUTER_KEY = bool(os.getenv("OPENROUTER_API_KEY")) HAS_OPENROUTER_KEY = bool(os.getenv("OPENROUTER_API_KEY"))
REAL_IMAGES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "real" REAL_IMAGES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "real"
@@ -67,4 +66,4 @@ class TestRealImageExternalTranscription:
f"{result.text}\n" f"{result.text}\n"
) )
artifact_path.write_text(artifact_text, encoding="utf-8") artifact_path.write_text(artifact_text, encoding="utf-8")
assert artifact_path.exists() assert artifact_path.exists()
+23 -2
View File
@@ -5,8 +5,11 @@ from pathlib import Path
import pytest import pytest
from transcription.config import Settings from transcription.config import Settings
from transcription.models import Document, Job, JobStatus from transcription.models import Document
from transcription.services.upload import UploadError, create_upload_job 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 @pytest.mark.unit
@@ -41,6 +44,24 @@ class TestUploadValidation:
assert exc_info.value.category.value == "user_input_error" assert exc_info.value.category.value == "user_input_error"
assert "jpg" in exc_info.value.suggestion.lower() 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 @pytest.mark.integration
class TestUploadPersistence: class TestUploadPersistence:
+22 -7
View File
@@ -7,10 +7,16 @@ import pytest
from sqlmodel import select from sqlmodel import select
from transcription.config import Settings from transcription.config import Settings
from transcription.errors import AppError, ErrorCategory from transcription.errors import AppError
from transcription.models import Document, Job, JobStatus, Transcript 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.providers.base import TranscriptionResult
from transcription.worker import process_next_queued_job, run_worker_loop 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: def _create_queued_job(session, *, filename: str = "doc.jpg", file_path: str = "uploads/doc.jpg") -> Job:
@@ -74,12 +80,21 @@ class TestWorkerSuccessPath:
process_next_queued_job(session=session) process_next_queued_job(session=session)
transcript = session.exec( transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
select(Transcript).where(Transcript.job_id == job.id) revision = session.exec(
select(TranscriptRevision)
.where(TranscriptRevision.job_id == job.id)
.order_by(TranscriptRevision.revision_number)
).first() ).first()
assert transcript is not None assert transcript is not None
assert transcript.text == "Transcript body" assert transcript.text == "Transcript body"
assert transcript.error_detail is None 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 @pytest.mark.integration
@@ -109,7 +124,7 @@ class TestWorkerFailurePath:
assert job.status == JobStatus.FAILED assert job.status == JobStatus.FAILED
assert transcript is not None assert transcript is not None
assert transcript.text is None assert transcript.text is None
assert "provider failure" in transcript.error_detail assert "provider failure" not in transcript.error_detail
assert "[internal_unexpected_error]" in transcript.error_detail assert "[internal_unexpected_error]" in transcript.error_detail
assert "error_id=" in transcript.error_detail assert "error_id=" in transcript.error_detail
assert "suggestion=" in transcript.error_detail assert "suggestion=" in transcript.error_detail
@@ -139,7 +154,7 @@ class TestWorkerFailurePath:
assert len(transcripts) == 1 assert len(transcripts) == 1
assert transcripts[0].id == existing.id assert transcripts[0].id == existing.id
assert transcripts[0].text is None assert transcripts[0].text is None
assert "provider failure" in transcripts[0].error_detail assert "provider failure" not in transcripts[0].error_detail
assert "[internal_unexpected_error]" in transcripts[0].error_detail assert "[internal_unexpected_error]" in transcripts[0].error_detail
assert "error_id=" in transcripts[0].error_detail assert "error_id=" in transcripts[0].error_detail
+12
View File
@@ -26,6 +26,8 @@ class TestAppLifespan:
calls = [] calls = []
monkeypatch.setattr("transcription.app.setup_logging", lambda: calls.append("logging")) monkeypatch.setattr("transcription.app.setup_logging", lambda: calls.append("logging"))
monkeypatch.setattr("transcription.app.get_settings", lambda: object())
monkeypatch.setattr("transcription.app.enforce_request_access", lambda **_kwargs: None)
monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: calls.append("schema")) monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: calls.append("schema"))
monkeypatch.setattr( monkeypatch.setattr(
"transcription.app.initialize_database_runtime", "transcription.app.initialize_database_runtime",
@@ -35,6 +37,8 @@ class TestAppLifespan:
monkeypatch.setattr("transcription.app.should_bootstrap_schema", lambda _settings: True) monkeypatch.setattr("transcription.app.should_bootstrap_schema", lambda _settings: True)
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker")) monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker")) monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
monkeypatch.setattr("transcription.app.apply_pending_migrations", lambda **_kwargs: calls.append("migrate"))
monkeypatch.setattr("transcription.app.validate_schema_compatibility", lambda **_kwargs: [])
class _Dir: class _Dir:
def mkdir(self, parents: bool, exist_ok: bool): def mkdir(self, parents: bool, exist_ok: bool):
@@ -43,6 +47,8 @@ class TestAppLifespan:
class _Settings: class _Settings:
upload_dir = _Dir() upload_dir = _Dir()
prompt_dir = _Dir() prompt_dir = _Dir()
migration_auto_apply_on_startup = False
validate_schema_on_startup = True
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings()) monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
@@ -61,6 +67,8 @@ class TestAppLifespan:
calls = [] calls = []
monkeypatch.setattr("transcription.app.setup_logging", lambda: None) monkeypatch.setattr("transcription.app.setup_logging", lambda: None)
monkeypatch.setattr("transcription.app.get_settings", lambda: object())
monkeypatch.setattr("transcription.app.enforce_request_access", lambda **_kwargs: None)
monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: None) monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: None)
monkeypatch.setattr( monkeypatch.setattr(
"transcription.app.initialize_database_runtime", "transcription.app.initialize_database_runtime",
@@ -70,6 +78,8 @@ class TestAppLifespan:
monkeypatch.setattr("transcription.app.should_bootstrap_schema", lambda _settings: True) monkeypatch.setattr("transcription.app.should_bootstrap_schema", lambda _settings: True)
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker")) monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker")) monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
monkeypatch.setattr("transcription.app.apply_pending_migrations", lambda **_kwargs: calls.append("migrate"))
monkeypatch.setattr("transcription.app.validate_schema_compatibility", lambda **_kwargs: [])
class _Dir: class _Dir:
def mkdir(self, parents: bool, exist_ok: bool): def mkdir(self, parents: bool, exist_ok: bool):
@@ -78,6 +88,8 @@ class TestAppLifespan:
class _Settings: class _Settings:
upload_dir = _Dir() upload_dir = _Dir()
prompt_dir = _Dir() prompt_dir = _Dir()
migration_auto_apply_on_startup = False
validate_schema_on_startup = True
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings()) monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
+29 -1
View File
@@ -5,7 +5,8 @@ from pathlib import Path
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from transcription.config import Provider, Settings from transcription.config import Provider
from transcription.config import Settings
def _make_settings(**overrides) -> Settings: def _make_settings(**overrides) -> Settings:
@@ -63,6 +64,33 @@ class TestPathSettings:
assert isinstance(settings.prompt_dir, Path) assert isinstance(settings.prompt_dir, Path)
class TestMigrationSafetySettings:
"""Verify migration safety settings defaults."""
def test_migration_safety_defaults(self):
"""Migration auto-apply is off and startup schema validation is on by default."""
settings = _make_settings()
assert settings.migration_auto_apply_on_startup is False
assert settings.validate_schema_on_startup is True
class TestSecuritySettings:
"""Verify Step 5 security-related settings behavior."""
def test_security_defaults(self):
"""Security controls default to disabled auth and bounded upload size."""
settings = _make_settings()
assert settings.max_upload_bytes == 15 * 1024 * 1024
assert settings.operator_access_enabled is False
assert settings.operator_username == "operator"
assert settings.operator_password is None
def test_operator_password_required_when_access_enabled(self):
"""Enabling operator access requires OPERATOR_PASSWORD."""
with pytest.raises(ValidationError):
_make_settings(operator_access_enabled=True, operator_password=None)
class TestWorkerReliabilitySettings: class TestWorkerReliabilitySettings:
"""Verify worker retry settings defaults.""" """Verify worker retry settings defaults."""
+22 -5
View File
@@ -1,7 +1,10 @@
"""Tests for transcription.db — schema bootstrap and session factory.""" """Tests for transcription.db — schema bootstrap and session factory."""
from sqlalchemy import inspect, text from sqlalchemy import inspect
from sqlmodel import Session, SQLModel, create_engine from sqlalchemy import text
from sqlmodel import Session
from sqlmodel import SQLModel
from sqlmodel import create_engine
from sqlmodel.pool import StaticPool from sqlmodel.pool import StaticPool
@@ -18,12 +21,14 @@ class TestSchemaBootstrap:
"""Verify create_all produces the expected table set.""" """Verify create_all produces the expected table set."""
def test_create_all_creates_expected_tables(self): def test_create_all_creates_expected_tables(self):
"""After create_all(), document, job, and transcript tables exist.""" """After create_all(), core V1 tables exist."""
engine = _in_memory_engine() engine = _in_memory_engine()
# Ensure models are imported so metadata is populated # Ensure models are imported so metadata is populated
from transcription.models import Document, Job, Transcript # noqa: F401
import transcription.db as db_module import transcription.db as db_module
from transcription.models import Document # noqa: F401
from transcription.models import Job # noqa: F401
from transcription.models import Transcript # noqa: F401
from transcription.models import TranscriptRevision # noqa: F401
db_module.create_all(engine=engine) db_module.create_all(engine=engine)
@@ -32,6 +37,18 @@ class TestSchemaBootstrap:
assert "document" in table_names assert "document" in table_names
assert "job" in table_names assert "job" in table_names
assert "transcript" in table_names assert "transcript" in table_names
assert "transcriptrevision" in table_names
def test_validate_schema_compatibility_returns_no_issues_for_fresh_schema(self):
"""validate_schema_compatibility reports no issues on fresh schema."""
engine = _in_memory_engine()
import transcription.db as db_module
db_module.create_all(engine=engine)
issues = db_module.validate_schema_compatibility(engine=engine)
assert issues == []
class TestSessionFactory: class TestSessionFactory:
+5 -2
View File
@@ -2,7 +2,10 @@
import pytest import pytest
from transcription.errors import AppError, ErrorCategory, classify_unexpected_error, new_error_id from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.errors import classify_unexpected_error
from transcription.errors import new_error_id
@pytest.mark.unit @pytest.mark.unit
@@ -38,6 +41,6 @@ class TestAppErrorHelpers:
assert isinstance(err, AppError) assert isinstance(err, AppError)
assert err.category == ErrorCategory.INTERNAL_UNEXPECTED assert err.category == ErrorCategory.INTERNAL_UNEXPECTED
assert "unit.test" in err.message assert "unit.test" in err.message
assert "boom" in err.message assert "boom" not in err.message
assert err.suggestion assert err.suggestion
assert err.error_id assert err.error_id
+108
View File
@@ -0,0 +1,108 @@
"""Tests for transcription.migrations — explicit Step 4 migration safety behavior."""
from sqlalchemy import inspect
from sqlalchemy import text
from sqlmodel import create_engine
from sqlmodel.pool import StaticPool
from transcription.migrations import apply_pending_migrations
from transcription.migrations import list_pending_migrations
def _in_memory_engine():
"""Create isolated in-memory SQLite engine."""
return create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
class TestMigrations:
"""Verify migration listing and application behavior."""
def test_list_pending_returns_all_before_apply(self):
"""All known migrations are pending on a fresh legacy-shaped database."""
engine = _in_memory_engine()
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE job (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
)
pending = list_pending_migrations(engine=engine)
assert [migration.revision_id for migration in pending] == [
"0001_add_retry_count_to_job",
"0002_create_transcriptrevision_table",
]
def test_apply_pending_migrations_records_history_and_schema(self):
"""Applying pending migrations mutates schema and records revision history."""
engine = _in_memory_engine()
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE job (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
)
applied = apply_pending_migrations(engine=engine)
assert applied == [
"0001_add_retry_count_to_job",
"0002_create_transcriptrevision_table",
]
inspector = inspect(engine)
job_columns = {column["name"] for column in inspector.get_columns("job")}
assert "retry_count" in job_columns
assert "transcriptrevision" in set(inspector.get_table_names())
with engine.begin() as connection:
rows = connection.execute(
text("SELECT revision_id FROM schema_migration_history ORDER BY revision_id")
).fetchall()
assert [row[0] for row in rows] == applied
def test_apply_pending_migrations_is_idempotent(self):
"""Re-running apply_pending_migrations with no pending revisions is a no-op."""
engine = _in_memory_engine()
with engine.begin() as connection:
connection.execute(
text(
"""
CREATE TABLE job (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
)
first_apply = apply_pending_migrations(engine=engine)
second_apply = apply_pending_migrations(engine=engine)
assert len(first_apply) == 2
assert second_apply == []
+44 -1
View File
@@ -5,7 +5,11 @@ from uuid import UUID
import pytest import pytest
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from transcription.models import Document, Job, JobStatus, Transcript from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.models import TranscriptRevision
def _make_document(**overrides) -> Document: def _make_document(**overrides) -> Document:
@@ -89,6 +93,12 @@ class TestJobModel:
session.refresh(job) session.refresh(job)
assert job.status == JobStatus.TRANSCRIBED assert job.status == JobStatus.TRANSCRIBED
job.status = JobStatus.COMPLETED
session.add(job)
session.commit()
session.refresh(job)
assert job.status == JobStatus.COMPLETED
def test_transitions_to_failed(self, session): def test_transitions_to_failed(self, session):
"""Status updates from processing to failed.""" """Status updates from processing to failed."""
doc = _persist_document(session) doc = _persist_document(session)
@@ -152,6 +162,27 @@ class TestTranscriptModel:
session.commit() session.commit()
class TestTranscriptRevisionModel:
"""Verify transcript revision persistence and defaults."""
def test_revision_defaults_and_persistence(self, session):
"""Revision records persist with revision metadata and defaults."""
doc = _persist_document(session)
job = _persist_job(session, doc)
revision = TranscriptRevision(job_id=job.id, revision_number=1, text="Rev text")
session.add(revision)
session.commit()
session.refresh(revision)
fetched = session.get(TranscriptRevision, revision.id)
assert fetched is not None
assert fetched.revision_number == 1
assert fetched.text == "Rev text"
assert fetched.source == "worker"
assert fetched.accepted is False
class TestRelationships: class TestRelationships:
"""Verify SQLModel relationship navigation between models.""" """Verify SQLModel relationship navigation between models."""
@@ -177,3 +208,15 @@ class TestRelationships:
assert job.transcript is not None assert job.transcript is not None
assert isinstance(job.transcript, Transcript) assert isinstance(job.transcript, Transcript)
assert job.transcript.text == "Transcribed text" assert job.transcript.text == "Transcribed text"
def test_job_exposes_revisions(self, session):
"""job.revisions returns revision history linked to the Job."""
doc = _persist_document(session)
job = _persist_job(session, doc)
session.add(TranscriptRevision(job_id=job.id, revision_number=1, text="v1"))
session.add(TranscriptRevision(job_id=job.id, revision_number=2, text="v2", accepted=True))
session.commit()
session.refresh(job)
assert len(job.revisions) == 2
assert all(isinstance(revision, TranscriptRevision) for revision in job.revisions)
-1
View File
@@ -2,7 +2,6 @@
from pathlib import Path from pathlib import Path
PROMPT_PATH = Path("prompts/transcribe_document.md") PROMPT_PATH = Path("prompts/transcribe_document.md")
+6
View File
@@ -19,14 +19,17 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
"REQ-3": [ "REQ-3": [
"tests/services/test_worker.py", "tests/services/test_worker.py",
"tests/ui/test_jobs_page.py", "tests/ui/test_jobs_page.py",
"tests/services/test_library.py",
], ],
"REQ-4": [ "REQ-4": [
"tests/services/test_worker.py", "tests/services/test_worker.py",
"tests/integration/test_pipeline_flow.py", "tests/integration/test_pipeline_flow.py",
"tests/services/test_library.py",
], ],
"REQ-5": [ "REQ-5": [
"tests/ui/test_jobs_page.py", "tests/ui/test_jobs_page.py",
"tests/ui/test_pages_registration.py", "tests/ui/test_pages_registration.py",
"tests/api/test_routes.py",
], ],
"REQ-6": [ "REQ-6": [
"tests/test_app.py", "tests/test_app.py",
@@ -36,6 +39,9 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
"tests/test_app.py", "tests/test_app.py",
"tests/test_config.py", "tests/test_config.py",
], ],
"REQ-11": [
"tests/services/test_library.py",
],
"REQ-12": [ "REQ-12": [
"tests/test_prompts.py", "tests/test_prompts.py",
"tests/services/test_transcription.py", "tests/services/test_transcription.py",
+5 -2
View File
@@ -4,8 +4,11 @@ from uuid import uuid4
import pytest import pytest
from transcription.models import Document, Job, Transcript from transcription.models import Document
from transcription.ui.jobs_page import fetch_job_detail, fetch_jobs from transcription.models import Job
from transcription.models import Transcript
from transcription.ui.jobs_page import fetch_job_detail
from transcription.ui.jobs_page import fetch_jobs
@pytest.mark.integration @pytest.mark.integration
+1 -1
View File
@@ -1,8 +1,8 @@
"""Tests for UI page registration wiring.""" """Tests for UI page registration wiring."""
import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import pytest
from transcription.ui import register_pages from transcription.ui import register_pages
+2 -1
View File
@@ -5,7 +5,8 @@ from uuid import uuid4
import pytest import pytest
from transcription.services.upload import UploadError, UploadJobResult from transcription.services.upload import UploadError
from transcription.services.upload import UploadJobResult
from transcription.ui import upload_page from transcription.ui import upload_page
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB