generated from john/python-template
Implement Ver1 Stage3
This commit is contained in:
@@ -28,14 +28,14 @@ Historical records remain unchanged:
|
||||
|
||||
| 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 | not started | |
|
||||
| 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 | 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 | in progress | This checklist is the initial integration artifact |
|
||||
| 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 | Routing established in this matrix |
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# Ver1 Step 3 Results: Functional Completion by Requirement Domain
|
||||
|
||||
## Summary
|
||||
|
||||
Step 3 implementation has been completed for the planned functional-completion scope in a practical personal-scale form.
|
||||
|
||||
Implemented in this step:
|
||||
|
||||
1. Revision history and acceptance workflows for transcripts.
|
||||
2. Search over accepted transcript revisions.
|
||||
3. Export of accepted transcript data.
|
||||
4. API routes for jobs, revisions, search, and export.
|
||||
5. UI pathways for revision management, search, and export.
|
||||
6. Carry-forward integration updates for Step 1/2 follow-ups owned by Step 3.
|
||||
|
||||
---
|
||||
|
||||
## Implemented Changes
|
||||
|
||||
### 1) Data model expansion (functional domain)
|
||||
|
||||
Updated `src/transcription/models.py`:
|
||||
|
||||
- Added `JobStatus.COMPLETED`.
|
||||
- Added `TranscriptRevision` table/model:
|
||||
- `job_id`
|
||||
- `revision_number`
|
||||
- `text`
|
||||
- `source`
|
||||
- `accepted`
|
||||
- `created_at`
|
||||
- Added `Job.revisions` relationship.
|
||||
|
||||
This supports immutable revision history and accepted-transcript semantics for search/export.
|
||||
|
||||
### 2) Step 3 service layer
|
||||
|
||||
Created `src/transcription/services/library.py` with service-backed functional operations:
|
||||
|
||||
- `list_jobs(...)`
|
||||
- `get_job_detail(...)`
|
||||
- `add_revision(...)`
|
||||
- `accept_revision(...)`
|
||||
- `list_revisions(...)`
|
||||
- `search_accepted_transcripts(...)`
|
||||
- `export_transcripts(...)`
|
||||
|
||||
Key behavior:
|
||||
|
||||
- revisions are append-only and incrementing
|
||||
- accepted revision is unique per job
|
||||
- accepting a revision syncs canonical transcript and sets job to `completed`
|
||||
- search scope is accepted revisions only
|
||||
- export emits deterministic record payloads for archive workflows
|
||||
|
||||
### 3) Worker integration for revision provenance
|
||||
|
||||
Updated `src/transcription/worker.py`:
|
||||
|
||||
- Success path now calls `add_revision(..., source="worker", accepted=False)`.
|
||||
- Worker still persists canonical transcript and `transcribed` job state.
|
||||
- Initial machine transcription now appears in revision history.
|
||||
|
||||
### 4) API functional completion
|
||||
|
||||
Created `src/transcription/api/routes.py` and wired in `src/transcription/app.py`.
|
||||
|
||||
New endpoints:
|
||||
|
||||
- `GET /api/jobs`
|
||||
- `GET /api/jobs/{job_id}`
|
||||
- `GET /api/jobs/{job_id}/revisions`
|
||||
- `POST /api/jobs/{job_id}/revisions`
|
||||
- `POST /api/revisions/{revision_id}/accept`
|
||||
- `GET /api/search?query=...`
|
||||
- `GET /api/export?accepted_only=true|false`
|
||||
|
||||
### 5) UI functional completion
|
||||
|
||||
Updated `src/transcription/ui/jobs_page.py`:
|
||||
|
||||
- Job detail now includes revision history panel.
|
||||
- Added user revision submission.
|
||||
- Added revision accept action.
|
||||
- Added `/search` page for accepted transcript search.
|
||||
- Added `/export` page for accepted transcript export preview.
|
||||
|
||||
---
|
||||
|
||||
## Test Evidence
|
||||
|
||||
### Added/Updated Tests
|
||||
|
||||
1. `tests/services/test_library.py`
|
||||
- revision append/accept behavior
|
||||
- accepted-only search behavior
|
||||
- export payload behavior
|
||||
|
||||
2. `tests/api/test_routes.py`
|
||||
- jobs/revisions/search/export API serialization and contract behavior
|
||||
|
||||
3. `tests/test_models.py`
|
||||
- `completed` status transition coverage
|
||||
- `TranscriptRevision` persistence and relationship coverage
|
||||
|
||||
4. `tests/services/test_worker.py`
|
||||
- success-path now verifies initial worker-generated revision persistence
|
||||
|
||||
### Full Validation Run
|
||||
|
||||
Executed and passing:
|
||||
|
||||
- `uv run pytest -q`
|
||||
|
||||
---
|
||||
|
||||
## Requirement Slice Coverage (Step 3)
|
||||
|
||||
| Slice | REQ Coverage | Status | Evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| Core lifecycle completion and visibility | REQ-0, REQ-2, REQ-3, REQ-5, REQ-6 | met | worker integration + API/UI jobs routes + tests |
|
||||
| Revision history and acceptance | REQ-3, REQ-4, REQ-5, REQ-11 | met | `TranscriptRevision`, `services/library.py`, UI revision panel, tests |
|
||||
| Search over accepted transcripts | REQ-5, REQ-11 | met | `search_accepted_transcripts`, `/api/search`, `/ui/search`, tests |
|
||||
| Export transcript data | REQ-4, REQ-5, REQ-11 | met | `export_transcripts`, `/api/export`, `/ui/export`, tests |
|
||||
| Prompt and verbatim flow continuity | REQ-12 | met (continued) | worker transcription flow unchanged in prompt-loading contract |
|
||||
|
||||
---
|
||||
|
||||
## Carry-Forward Integration Updates
|
||||
|
||||
Updated:
|
||||
|
||||
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md`
|
||||
|
||||
Step 3 updates recorded for:
|
||||
|
||||
- CF-A1: in progress with Step 3 inspection evidence
|
||||
- CF-A3: in progress with boundary-discipline evidence
|
||||
- CF-C1: done (Step 3 traceability artifacts integrated)
|
||||
- CF-C2: in progress (routing preserved for later steps)
|
||||
|
||||
---
|
||||
|
||||
## Residual Follow-ups
|
||||
|
||||
1. Step 4: migration rehearsal and rollback runbook execution for schema changes.
|
||||
2. Step 6/7: broader error-path inventory closure and logging field normalization.
|
||||
3. Step 9: release readiness reconfirmation for runtime ownership and migration behavior.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 Exit Assessment
|
||||
|
||||
- Requirement-domain functional completion: **met**
|
||||
- Data integrity and state consistency for new flows: **met**
|
||||
- API/UI parity for new Step 3 features: **met**
|
||||
- Test and regression safety: **met**
|
||||
- Carry-forward integration obligations (Step 3-owned): **met/in progress as routed**
|
||||
|
||||
Step 3 is complete and ready to hand off to Step 4.
|
||||
@@ -0,0 +1,433 @@
|
||||
# Step 3 Implementation Plan: Functional Completion by Requirement Domain
|
||||
|
||||
## Purpose
|
||||
|
||||
Implement **Ver1 Step 3** from `docs/ver1/ver1.md` by completing all in-scope V1 functional requirements in a practical, user-first order while preserving:
|
||||
|
||||
- personal-scale operation
|
||||
- single-operator workflow
|
||||
- private-network deployment assumptions
|
||||
- low operational overhead
|
||||
- clean architecture boundaries
|
||||
|
||||
Primary governing docs:
|
||||
|
||||
- `docs/ver1/ver1.md` (Step 3 objective and sequencing)
|
||||
- `docs/architecture.md` (module boundaries, workflow, simplicity guardrails)
|
||||
- `docs/requirements.md` (REQ-0 through REQ-12 traceability)
|
||||
- `docs/error_handling.md` (error contract across boundaries)
|
||||
- `docs/intent.md` (verbatim transcription policy and prompt curation)
|
||||
- `docs/ver1/ver1-step1-2-carry-forward-checklist.md` (Step 1/2 carry-forward integration)
|
||||
- `docs/ver1/ver1-step2-error-path-inventory.md` (failure-path coverage visibility)
|
||||
|
||||
---
|
||||
|
||||
## MCP Resources Reviewed and Applied
|
||||
|
||||
All resources on `john-stream-mcp` were reviewed. Step 3 applies the following guidance directly:
|
||||
|
||||
1. `resource://skills/nicegui/document`
|
||||
- modular page registration
|
||||
- one-way dependency flow (`ui/api -> services -> infra`)
|
||||
- async-first UI responsiveness expectations
|
||||
|
||||
2. `resource://skills/nicegui-ui-customization/document`
|
||||
- reusable UI component extraction for repeated patterns
|
||||
- in-flight guards and explicit success/failure user feedback
|
||||
- event-driven updates over ad-hoc polling
|
||||
|
||||
3. `resource://skills/fastapi-async-sqlalchemy-modernization/document`
|
||||
- explicit transaction/session boundaries
|
||||
- deterministic resource ownership and cleanup continuity from Step 1
|
||||
- incremental migration strategy with rollback-aware checkpoints
|
||||
|
||||
4. `resource://skills/pydantic-settings/document`
|
||||
- typed configuration as single source of runtime truth
|
||||
- explicit source precedence and environment-safe defaults
|
||||
|
||||
5. `resource://skills/python-logging-dictconfig/document`
|
||||
- centralized startup-only logging configuration
|
||||
- named logger discipline and boundary-level structured fields
|
||||
|
||||
6. `resource://skills/pytesting/document`
|
||||
- deterministic test structure and marker discipline
|
||||
- behavior-first tests with clear fast-path and full-suite validation
|
||||
|
||||
7. `resource://skills/fastapi-uv-docker/document`
|
||||
- health endpoint and runtime startup/shutdown hygiene
|
||||
- compose/deployment readiness constraints relevant to functional completion
|
||||
|
||||
8. `resource://skills/python-typing/document`
|
||||
- modern typing updates where touched by Step 3 work
|
||||
|
||||
9. `resource://skills/ruff-linting-formating/document`
|
||||
- maintain lint/format consistency in all modified modules
|
||||
|
||||
10. `resource://prompts/greenfield-architecture/document`
|
||||
- explicit staged delivery with tradeoff-aware sequencing and test strategy
|
||||
|
||||
11. `resource://prompts/pytest-scaffold/document`
|
||||
12. `resource://prompts/pytest-fill-scaffold/document`
|
||||
- structure-first test planning, then deterministic implementation fill-in
|
||||
|
||||
Resources reviewed but not directly in Step 3 execution scope (no changes required now):
|
||||
|
||||
- `copilot-customization`, `mcp-details`, `vscode-configuration`, `zensical-docs`
|
||||
- prompts: `authoring`, `mcp-consumer-repo-shim`
|
||||
|
||||
---
|
||||
|
||||
## Step 3 Success Criteria
|
||||
|
||||
Step 3 is complete when:
|
||||
|
||||
1. All Step 3-targeted requirement slices are implemented and verified.
|
||||
2. Functional behavior is available through UI/API where required.
|
||||
3. Core data integrity and state transitions are deterministic.
|
||||
4. Error behavior follows `docs/error_handling.md` contracts.
|
||||
5. Carry-forward Step 1/2 items mapped to Step 3 are updated with evidence.
|
||||
|
||||
---
|
||||
|
||||
## Requirement-Slice Execution Model (Applied to Every Slice)
|
||||
|
||||
For each slice, execute this sequence:
|
||||
|
||||
1. Confirm contract/schema and boundary ownership.
|
||||
2. Implement service/domain logic.
|
||||
3. Implement persistence/state transitions.
|
||||
4. Integrate API and/or UI behavior.
|
||||
5. Add/update unit + integration + targeted end-to-end tests.
|
||||
6. Update docs and traceability artifacts.
|
||||
|
||||
Definition of done per slice:
|
||||
|
||||
- behavior is functional
|
||||
- tests pass in intended marker lanes
|
||||
- error pathways are classified and surfaced correctly
|
||||
- requirement traceability is updated with evidence
|
||||
|
||||
---
|
||||
|
||||
## Detailed Workstreams
|
||||
|
||||
## Workstream A — Functional Baseline Audit and Slice Backlog Lock
|
||||
|
||||
### Goals
|
||||
|
||||
- establish exact Step 3 functional delta from current implementation
|
||||
- lock a practical slice backlog before coding
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Build Step 3 requirement matrix (REQ -> current status -> gap -> target slice).
|
||||
2. Map each gap to one of these domains:
|
||||
- Upload and lifecycle integrity
|
||||
- Review and revision history
|
||||
- Search over accepted transcripts
|
||||
- Export workflows
|
||||
- Prompt asset management behavior
|
||||
- API/UI parity and status visibility
|
||||
3. Align each slice with architecture boundary ownership and persistence strategy.
|
||||
4. Link open carry-forward items from checklist:
|
||||
- CF-A1, CF-A3 (architecture continuity in Step 3)
|
||||
- CF-C1, CF-C2 (traceability/execution continuity)
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Step 3 requirement-slice matrix (appendix in this doc or separate artifact)
|
||||
- prioritized slice backlog with owner and validation method
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- every Step 3 slice maps to REQ IDs and a validation method
|
||||
- no ambiguous ownership remains for in-scope slices
|
||||
|
||||
---
|
||||
|
||||
## Workstream B — Core End-User Flows (Upload -> Transcribe -> Review)
|
||||
|
||||
### Related Requirements
|
||||
|
||||
- REQ-0, REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-6, REQ-12
|
||||
|
||||
### Goals
|
||||
|
||||
- guarantee end-to-end reliability and usability of the primary user flow
|
||||
- ensure review experience supports transcript acceptance and correction
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Validate and close any lifecycle-state gaps:
|
||||
- enforce valid transitions (`queued -> processing -> transcribed/failed/completed`)
|
||||
- ensure transition visibility in UI/API
|
||||
2. Review experience completion:
|
||||
- transcript detail display stability
|
||||
- failure detail readability and actionability
|
||||
- acceptance/edit path for human review
|
||||
3. Ensure prompt-asset integration remains file-based and auditable:
|
||||
- one prompt per Markdown file
|
||||
- prompt selection/usage traceability in job outcomes (if available in model)
|
||||
4. Confirm worker/UI interactions remain responsive under long-running jobs:
|
||||
- in-flight guards
|
||||
- clear status refresh behavior
|
||||
|
||||
### Deliverables
|
||||
|
||||
- complete end-user flow behavior with stable lifecycle visibility
|
||||
- test coverage for happy path and failure path
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- user can run upload -> process -> review reliably
|
||||
- failed and successful outcomes are both actionable and traceable
|
||||
|
||||
---
|
||||
|
||||
## Workstream C — Revision History and Provenance Completion
|
||||
|
||||
### Related Requirements
|
||||
|
||||
- REQ-3, REQ-4, REQ-5, REQ-11
|
||||
|
||||
### Goals
|
||||
|
||||
- finalize immutable transcript revision behavior and provenance consistency
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Define/confirm revision invariants:
|
||||
- append-only revision history
|
||||
- clear current/accepted revision indicator
|
||||
2. Persist revision events consistently through service layer boundaries.
|
||||
3. Ensure UI/API expose revision timeline and selected revision details.
|
||||
4. Align error handling for revision conflicts and missing resources.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- revision-history feature completeness
|
||||
- provenance and history read-path coverage
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- transcript edits produce deterministic revision records
|
||||
- previous revisions remain inspectable
|
||||
|
||||
---
|
||||
|
||||
## Workstream D — Search Completion (Accepted Transcript Scope)
|
||||
|
||||
### Related Requirements
|
||||
|
||||
- REQ-0, REQ-5, REQ-11
|
||||
|
||||
### Goals
|
||||
|
||||
- provide practical search over accepted transcripts for personal corpus usage
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Finalize searchable scope and indexing rules (accepted/current text only).
|
||||
2. Implement service-backed search query behavior.
|
||||
3. Expose search in UI/API with clear result metadata (document/job/revision context).
|
||||
4. Add guardrails for empty/no-result/error scenarios with actionable messaging.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- functional search pathway with deterministic results for accepted text
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- operator can find transcripts reliably by text queries
|
||||
- no-result and error states are clear and non-silent
|
||||
|
||||
---
|
||||
|
||||
## Workstream E — Export Completion
|
||||
|
||||
### Related Requirements
|
||||
|
||||
- REQ-0, REQ-4, REQ-5, REQ-11
|
||||
|
||||
### Goals
|
||||
|
||||
- deliver practical export of transcript data for personal archive use
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Finalize export contract (format, included fields, scope filters).
|
||||
2. Implement export service with deterministic data mapping.
|
||||
3. Add UI/API trigger path and user-visible completion/failure feedback.
|
||||
4. Validate export integrity against persisted source-of-record entities.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- end-to-end export capability with operator-visible outcomes
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- export output is complete, consistent, and usable for downstream personal archive workflows
|
||||
|
||||
---
|
||||
|
||||
## Workstream F — API/UI Parity and Interaction Hardening
|
||||
|
||||
### Related Requirements
|
||||
|
||||
- REQ-5 plus cross-cutting REQ-2/3/4
|
||||
|
||||
### Goals
|
||||
|
||||
- ensure UI and API expose coherent feature behavior and error contracts
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Verify API/UI parity matrix for each Step 3 slice.
|
||||
2. Standardize interaction behavior:
|
||||
- loading and in-flight states
|
||||
- success/failure notifications
|
||||
- stable error_id visibility where user-facing
|
||||
3. Ensure route/page modules remain composition-focused (business logic in services).
|
||||
|
||||
### Deliverables
|
||||
|
||||
- API/UI parity checklist with resolved gaps
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- no major flow exists in one interface with conflicting semantics in the other
|
||||
|
||||
---
|
||||
|
||||
## Workstream G — Carry-Forward Integration During Step 3
|
||||
|
||||
### Goals
|
||||
|
||||
- close Step 1/2 follow-ups that are Step 3-owned
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Update checklist item CF-A1 as Step 3 slices touch runtime resources.
|
||||
2. Update checklist item CF-A3 with lightweight boundary enforcement evidence.
|
||||
3. Update CF-C1/CF-C2 traceability mapping with Step 3 outcomes.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- updated `docs/ver1/ver1-step1-2-carry-forward-checklist.md` evidence entries
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- Step 3-owned carry-forward items are either completed or explicitly routed with evidence
|
||||
|
||||
---
|
||||
|
||||
## Test and Validation Plan
|
||||
|
||||
Apply `pytesting` guidance with deterministic, behavior-focused coverage.
|
||||
|
||||
### Validation Lanes
|
||||
|
||||
1. Structure/collection:
|
||||
- `uv run pytest --collect-only -q`
|
||||
2. Fast feedback lane:
|
||||
- `uv run pytest -m unit -q`
|
||||
3. Main verification lane:
|
||||
- `uv run pytest -m "not external" -q`
|
||||
4. Full suite:
|
||||
- `uv run pytest -q`
|
||||
|
||||
### Required Coverage Areas
|
||||
|
||||
- lifecycle transition invariants
|
||||
- revision history invariants
|
||||
- search query behavior and result mapping
|
||||
- export integrity and failure handling
|
||||
- UI interaction guards and actionable failure feedback
|
||||
- API envelope and status consistency for new/changed flows
|
||||
|
||||
### Test Design Rules
|
||||
|
||||
- one behavior target per test
|
||||
- minimize heavy mocking; prefer real-path behavior checks where practical
|
||||
- keep markers explicit and strict
|
||||
|
||||
---
|
||||
|
||||
## Logging, Error, and Config Guardrails for Step 3 Changes
|
||||
|
||||
1. Logging
|
||||
- keep centralized startup logging config (`dictConfig`) as canonical
|
||||
- include required error fields at boundary failures (`error_id`, `category`, `operation`, identifiers where available)
|
||||
|
||||
2. Error handling
|
||||
- preserve taxonomy stability from `docs/error_handling.md`
|
||||
- map any new failure pathways into existing categories
|
||||
- surface actionable suggestions in UI/API
|
||||
|
||||
3. Configuration
|
||||
- use typed settings and avoid ad-hoc env reads in business modules
|
||||
- keep environment behavior explicit and documented
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order (Detailed)
|
||||
|
||||
1. Workstream A: audit and backlog lock
|
||||
2. Workstream B: core flow completion
|
||||
3. Workstream C: revision/provenance completion
|
||||
4. Workstream D: search completion
|
||||
5. Workstream E: export completion
|
||||
6. Workstream F: API/UI parity hardening
|
||||
7. Workstream G: carry-forward integration updates
|
||||
8. Full validation pass + docs/traceability updates
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. Step 3 requirement-slice matrix with REQ mapping and evidence links
|
||||
2. implemented Step 3 functional slices across service/persistence/API/UI
|
||||
3. updated tests and passing validation lanes
|
||||
4. updated carry-forward checklist entries (`CF-A1`, `CF-A3`, `CF-C1`, `CF-C2` as applicable)
|
||||
5. Step 3 results document (`docs/ver1/ver1-step3-results.md`)
|
||||
|
||||
---
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
1. **Risk:** Scope creep from optional enhancements during feature completion
|
||||
- **Mitigation:** enforce REQ-mapped slice backlog and defer non-REQ enhancements
|
||||
|
||||
2. **Risk:** Functional parity drift between UI and API
|
||||
- **Mitigation:** maintain parity matrix and verify both surfaces per slice
|
||||
|
||||
3. **Risk:** Data-model changes introduce migration surprises
|
||||
- **Mitigation:** coordinate with Step 4 runbook expectations early and test on representative data
|
||||
|
||||
4. **Risk:** Reliability regressions while adding functionality
|
||||
- **Mitigation:** run full error-path regression checks and keep Step 2 contracts intact
|
||||
|
||||
---
|
||||
|
||||
## Step 3 Completion Checklist
|
||||
|
||||
- [ ] Step 3 requirement-slice matrix completed and linked to REQ IDs.
|
||||
- [ ] Core end-user flow is functionally complete and verified.
|
||||
- [ ] Revision history/provenance behavior is complete and test-covered.
|
||||
- [ ] Search over accepted transcripts is complete and test-covered.
|
||||
- [ ] Export flow is complete and test-covered.
|
||||
- [ ] API/UI parity checklist has no unresolved high-impact gaps.
|
||||
- [ ] Step 3-owned carry-forward items are updated with evidence.
|
||||
- [ ] Validation lanes pass (`collect-only`, unit, non-external, full).
|
||||
- [ ] `docs/ver1/ver1-step3-results.md` is created with evidence and residual follow-ups.
|
||||
|
||||
---
|
||||
|
||||
## Handoff to Step 4
|
||||
|
||||
Step 3 completion enables Step 4 (Data Model and Migration Safety) with:
|
||||
|
||||
- finalized functional domain behavior
|
||||
- stable persistence expectations
|
||||
- traceable requirement evidence
|
||||
- clarified migration-impact surface
|
||||
@@ -0,0 +1,162 @@
|
||||
"""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, Field
|
||||
|
||||
from transcription.services.library import (
|
||||
accept_revision,
|
||||
add_revision,
|
||||
export_transcripts,
|
||||
get_job_detail,
|
||||
list_jobs,
|
||||
list_revisions,
|
||||
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,
|
||||
}
|
||||
@@ -9,6 +9,7 @@ from fastapi import FastAPI
|
||||
|
||||
from transcription.api.errors import register_error_handlers
|
||||
from transcription.api.health import router as health_router
|
||||
from transcription.api.routes import router as transcription_router
|
||||
from transcription.config import get_settings, setup_logging
|
||||
from transcription.db import (
|
||||
create_all,
|
||||
@@ -74,5 +75,6 @@ def create_app() -> FastAPI:
|
||||
register_error_handlers(app)
|
||||
register_pages(app)
|
||||
app.include_router(health_router)
|
||||
app.include_router(transcription_router)
|
||||
return app
|
||||
|
||||
|
||||
+23
-18
@@ -1,8 +1,4 @@
|
||||
"""SQLModel domain models for the transcription system.
|
||||
|
||||
Three models capture the MVP lifecycle:
|
||||
Document -> one-to-many -> Job -> one-to-one -> Transcript
|
||||
"""
|
||||
"""SQLModel domain models for the transcription system."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import StrEnum
|
||||
@@ -16,6 +12,7 @@ class JobStatus(StrEnum):
|
||||
QUEUED = "queued"
|
||||
PROCESSING = "processing"
|
||||
TRANSCRIBED = "transcribed"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@@ -25,9 +22,7 @@ class Document(SQLModel, table=True):
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
filename: str
|
||||
file_path: str
|
||||
uploaded_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# --- relationships ---
|
||||
jobs: list["Job"] = Relationship(back_populates="document")
|
||||
@@ -40,28 +35,38 @@ class Job(SQLModel, table=True):
|
||||
document_id: UUID = Field(foreign_key="document.id")
|
||||
status: JobStatus = Field(default=JobStatus.QUEUED)
|
||||
retry_count: int = Field(default=0, ge=0)
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# --- relationships ---
|
||||
document: Document = Relationship(back_populates="jobs")
|
||||
transcript: Optional["Transcript"] = Relationship(back_populates="job")
|
||||
revisions: list["TranscriptRevision"] = Relationship(back_populates="job")
|
||||
|
||||
|
||||
class Transcript(SQLModel, table=True):
|
||||
"""The output of a transcription job."""
|
||||
"""Canonical transcript state for a job (latest text or failure detail)."""
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
job_id: UUID = Field(foreign_key="job.id", unique=True)
|
||||
text: str | None = None
|
||||
error_detail: str | None = None
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# --- relationships ---
|
||||
job: Job = Relationship(back_populates="transcript")
|
||||
|
||||
|
||||
class TranscriptRevision(SQLModel, table=True):
|
||||
"""Immutable transcript revision history for review/acceptance workflows."""
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
job_id: UUID = Field(foreign_key="job.id", index=True)
|
||||
revision_number: int = Field(ge=1)
|
||||
text: str
|
||||
source: str = Field(default="worker")
|
||||
accepted: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# --- relationships ---
|
||||
job: Job = Relationship(back_populates="revisions")
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Step 3 functional services: job detail, revisions, search, and export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from transcription.db import get_session
|
||||
from transcription.errors import AppError, ErrorCategory
|
||||
from transcription.models import Document, Job, JobStatus, Transcript, 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(timezone.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(timezone.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,6 +10,13 @@ from sqlmodel import select
|
||||
|
||||
from transcription.db import get_session
|
||||
from transcription.models import Document, Job, Transcript
|
||||
from transcription.services.library import (
|
||||
accept_revision,
|
||||
add_revision,
|
||||
export_transcripts,
|
||||
list_revisions,
|
||||
search_accepted_transcripts,
|
||||
)
|
||||
from transcription.ui.error_presenter import show_error, summarize_error
|
||||
|
||||
|
||||
@@ -134,4 +141,104 @@ def register_page() -> None:
|
||||
ui.label("Failure detail:")
|
||||
ui.label(transcript.error_detail)
|
||||
|
||||
ui.separator()
|
||||
ui.label("Revision History")
|
||||
revisions_container = ui.column()
|
||||
|
||||
def render_revisions() -> None:
|
||||
revisions_container.clear()
|
||||
with revisions_container:
|
||||
revisions = list_revisions(job_id=parsed_id)
|
||||
if not revisions:
|
||||
ui.label("No revisions yet.")
|
||||
return
|
||||
|
||||
for revision in revisions:
|
||||
with ui.card().classes("w-full"):
|
||||
ui.label(
|
||||
f"Revision {revision.revision_number} | source={revision.source} | accepted={revision.accepted}"
|
||||
)
|
||||
ui.markdown(revision.text)
|
||||
|
||||
if not revision.accepted:
|
||||
ui.button(
|
||||
"Accept revision",
|
||||
on_click=lambda rev_id=revision.id: _accept_revision(rev_id),
|
||||
)
|
||||
|
||||
def _accept_revision(revision_id):
|
||||
try:
|
||||
accept_revision(revision_id=revision_id)
|
||||
ui.notify("Revision accepted", type="positive")
|
||||
render_revisions()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Accept revision failed", operation="revisions.accept")
|
||||
|
||||
new_revision_text = ui.textarea("Add revision text").props("rows=6")
|
||||
|
||||
def _submit_revision() -> None:
|
||||
try:
|
||||
add_revision(job_id=parsed_id, text=new_revision_text.value or "", source="user", accepted=False)
|
||||
new_revision_text.value = ""
|
||||
ui.notify("Revision added", type="positive")
|
||||
render_revisions()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Add revision failed", operation="revisions.create")
|
||||
|
||||
ui.button("Add revision", on_click=_submit_revision)
|
||||
|
||||
render_revisions()
|
||||
|
||||
ui.link("Search transcripts", "/search")
|
||||
ui.link("Export transcripts", "/export")
|
||||
ui.link("Back to jobs", "/jobs")
|
||||
|
||||
@ui.page("/search")
|
||||
def search_page() -> None:
|
||||
ui.label("Search Accepted Transcripts")
|
||||
query_input = ui.input("Search query")
|
||||
results_container = ui.column()
|
||||
|
||||
def run_search() -> None:
|
||||
results_container.clear()
|
||||
try:
|
||||
results = search_accepted_transcripts(query=query_input.value or "")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Search failed", operation="search.run")
|
||||
return
|
||||
|
||||
with results_container:
|
||||
if not results:
|
||||
ui.label("No results.")
|
||||
return
|
||||
|
||||
for result in results:
|
||||
with ui.card().classes("w-full"):
|
||||
ui.label(f"Job {result.job_id} | Revision {result.revision_number}")
|
||||
ui.markdown(result.text)
|
||||
|
||||
ui.button("Search", on_click=run_search)
|
||||
ui.link("Back to jobs", "/jobs")
|
||||
|
||||
@ui.page("/export")
|
||||
def export_page() -> None:
|
||||
ui.label("Export Accepted Transcripts")
|
||||
results_container = ui.column()
|
||||
|
||||
def run_export() -> None:
|
||||
results_container.clear()
|
||||
try:
|
||||
records = export_transcripts(accepted_only=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Export failed", operation="export.run")
|
||||
return
|
||||
|
||||
with results_container:
|
||||
ui.label(f"Exported records: {len(records)}")
|
||||
for record in records:
|
||||
with ui.card().classes("w-full"):
|
||||
ui.label(f"{record['filename']} | Revision {record['revision_number']}")
|
||||
ui.markdown(str(record["text"]))
|
||||
|
||||
ui.button("Run export", on_click=run_export)
|
||||
ui.link("Back to jobs", "/jobs")
|
||||
|
||||
@@ -15,6 +15,7 @@ from transcription.config import Settings, get_settings
|
||||
from transcription.db import get_session
|
||||
from transcription.errors import AppError, ErrorCategory, classify_unexpected_error, format_error_detail
|
||||
from transcription.models import Document, Job, JobStatus, Transcript
|
||||
from transcription.services.library import add_revision
|
||||
from transcription.services.transcription import transcribe_document_image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -66,16 +67,23 @@ def _process_next_queued_job(*, session: Session) -> bool:
|
||||
|
||||
try:
|
||||
result = transcribe_document_image(document.file_path)
|
||||
_upsert_transcript(session=session, job_id=job.id, text=result.text, error_detail=None)
|
||||
revision = add_revision(
|
||||
job_id=job.id,
|
||||
text=result.text,
|
||||
source="worker",
|
||||
accepted=False,
|
||||
session=session,
|
||||
)
|
||||
job.status = JobStatus.TRANSCRIBED
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
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,
|
||||
document.id,
|
||||
result.provider,
|
||||
revision.revision_number,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
error = exc if isinstance(exc, AppError) else classify_unexpected_error(exc, operation="worker.process_job")
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for Step 3 functional API routes."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
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(timezone.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(timezone.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(timezone.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(timezone.utc).isoformat(),
|
||||
}
|
||||
]
|
||||
monkeypatch.setattr("transcription.api.routes.export_transcripts", lambda accepted_only=True: records)
|
||||
|
||||
client = TestClient(_build_app())
|
||||
response = client.get("/api/export")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["count"] == 1
|
||||
assert payload["accepted_only"] is True
|
||||
assert payload["records"] == records
|
||||
@@ -11,11 +11,11 @@ BOOK 1 had 54 pages; 14 chapters. BOOK 2 has 70 pages; 18 chapters. BOOK 1 con-
|
||||
sisted largely of first generation family history. BOOK 2 throws more light on
|
||||
the second generation. Sidney promises a BOOK 3 and that may begin to do justice
|
||||
to the third generation. We suggest that Sidney get the help of Louis Shinn
|
||||
who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumeeq
|
||||
who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumecq
|
||||
Plains. Louis has the gift of seeing, recalling and telling. One sentence in
|
||||
his chapter gives a great tribute to the Doumeeqers - so far as he knows no one
|
||||
on the Doumeeq Plains went on relief during the depression. That in a nutshell
|
||||
shows the sturdy character of the residents of the Doumeeq Plains.
|
||||
his chapter gives a great tribute to the Doumecqers--so far as he knows no one
|
||||
on the Doumecq Plains went on relief during the depression. That in a nutshell
|
||||
shows the sturdy character of the residents of the Doumecq Plains.
|
||||
|
||||
We promised in BOOK 1 that in BOOK 2 we would give the story of the trip of
|
||||
John E. Cochran and wife to Tennessee, Cuba and the Panama Canal. You will see
|
||||
@@ -32,7 +32,7 @@ enough pictures but we had to take only part of them. We think there are great
|
||||
possibilities in reproducing old pictures. We wish we had a Pickard group. Some
|
||||
Pickard descendant may wish to make a collection.
|
||||
|
||||
We are much impressed with the future possibilities of getting a complete geneol-
|
||||
We are much impressed with the future possibilities of getting a complete geneal-
|
||||
ogy of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but
|
||||
to date we have not had the pleasure of finding all of the family dates. We had
|
||||
intended to give more family data in this book but it takes time to get the
|
||||
|
||||
@@ -18,7 +18,6 @@ the family newsletter two years ago.
|
||||
|
||||
Nome Alaska August 26, 1923
|
||||
My Dear Ethel et al.
|
||||
|
||||
I don't know when I did write or when you did
|
||||
but I am going to write now however and never
|
||||
the less. But I wish I could talk (I can yet but I
|
||||
@@ -27,7 +26,7 @@ and Polly sit up and listen and that little black
|
||||
rascal of yours would fairly sparkle with
|
||||
listening. Can't I see him listening now to all the
|
||||
yarns we told last summer?
|
||||
|
||||
[photo of people on ice with ship in background]
|
||||
You see, we-Miss Saville and I, took a trip north
|
||||
on the Buford and it was very interesting. We
|
||||
went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain
|
||||
@@ -43,14 +42,14 @@ all around it similar to a currycomb in coarseness; no ears but huge tusks of iv
|
||||
the most repulsive looking animals imaginable and tho I have always read about them I never
|
||||
expect such disagreeable looking creatures. They had a rough brown hairy skin and some of
|
||||
them looked warty. They must have weighed two ton at least. Ere we got them back to Nome
|
||||
to the natives they were getting extremely odiferous–in fact, you could scarcely stay on the
|
||||
to the natives they were getting extremely odiferous—in fact, you could scarcely stay on the
|
||||
ship with any degree of comfort unless you had per chance lost your sense of smell.
|
||||
|
||||
Then we went north to a few minutes beyond the 70th degree of latitude and thot [sic] for awhile
|
||||
we would go to Wrangell Island where some men from Stefflonsons [sic] ship were supposed to be
|
||||
stranded but we didn't get there and instead stopped at a small native village at Cape Serdz [sic] in
|
||||
Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile
|
||||
we would go to Wrangell Island where some men from Steffonsons ship were supposed to be
|
||||
stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in
|
||||
Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23
|
||||
years. He was a Swede–who else could. Their houses were circular and built up with dirt 2 or
|
||||
years. He was a Swede--who else could. Their houses were circular and built up with dirt 2 or
|
||||
3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room
|
||||
was partitioned off at the sides with skins for sleeping quarters. In the main part they had the
|
||||
fire on the ground and the fish drying on lines and the skins hanging around and the dogs and
|
||||
@@ -65,22 +64,22 @@ The other place we stopped was at Whalen, a trading post in Siberia. There these
|
||||
went wild. They rushed helter-skelter, hither and thither, here and there, trying to find
|
||||
something to buy. Prices raised right before your eyes. One would but something for $1.00
|
||||
and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to
|
||||
have it. One man I was sort of taking care of, tho [sic] he had his son along for the purpose,
|
||||
bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billi[illegible]s, 6 or 8 ivory and silver rings, one
|
||||
have it. One man I was sort of taking care of, tho he had his son along for the purpose,
|
||||
bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billikens, 6 or 8 ivory and silver rings, one
|
||||
fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the
|
||||
little natives throw them at the flocks of geese and ducks which fly close over the village and
|
||||
the slings entangle their wings and legs, sometimes more than one, and they can't fly. They
|
||||
come down and the natives capture them. There was more junk brot [sic] aboard than baggage, I
|
||||
come down and the natives capture them. There was more junk brot aboard than baggage, I
|
||||
do believe. And they say that at the first stop it was worse than here. The red flag was flying
|
||||
over Whalen and the Russian soldiers were there–a few, one or two or three, I forget the
|
||||
over Whalen and the Russian soldiers were there—a few, one or two or three, I forget the
|
||||
number.
|
||||
|
||||
We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out
|
||||
until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let
|
||||
us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is
|
||||
all I had to write about and I know Buster'd [sic] listen anyway and I'd soak ole Peter's head if he
|
||||
all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he
|
||||
didn't and Polly would in my lap and I don't know much about the youngest one of yours so
|
||||
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf [sic].
|
||||
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf.
|
||||
|
||||
I expect there were 150 passengers on board and almost or more of the crew and helpers. We
|
||||
had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times.
|
||||
@@ -109,7 +108,7 @@ Ome
|
||||
|
||||
Reprinted from Cochran Chronicles, Volume 9, Number 1, November 1986
|
||||
|
||||
© [inserted: JECFA] 1986
|
||||
© JECFA 1986
|
||||
|
||||
Up
|
||||
|
||||
|
||||
@@ -4,28 +4,28 @@ model: google/gemini-2.5-flash
|
||||
---
|
||||
JOHN ISBILL
|
||||
R. T. MOSER
|
||||
|
||||
ISBILL & MOSER
|
||||
DEALERS IN
|
||||
GENERAL MERCHANDISE
|
||||
|
||||
Vonore, Tenn., Jany 27- 1913
|
||||
Dear Much Aunt Louie
|
||||
How are you a
|
||||
few nights ago I sewed a
|
||||
letter from your folks, so
|
||||
Vonore, Tenn. January 27 – 1913
|
||||
Dear Uncle Aunt Adeline
|
||||
Was at home a
|
||||
few nights ago & saw a
|
||||
letter from your folks, So
|
||||
I decided to write you
|
||||
a few lines myself ok
|
||||
I am contemplateing a
|
||||
I am contemplate a
|
||||
trip out west next summer
|
||||
& I want Some Olders to go
|
||||
where I and them.
|
||||
|
||||
I am getting
|
||||
& [inserted: I] would like of adders [sic] to go
|
||||
where I [inserted: am] them.
|
||||
Am getting
|
||||
up in years & unmarried
|
||||
so you see the object of
|
||||
my trip, is to get a bunch
|
||||
of Young & old maids
|
||||
& widows out there. I
|
||||
my trip is to get a wife
|
||||
& if there is any old maid
|
||||
or widows out there, I
|
||||
want you to kiss them
|
||||
at my fans [sic] mug as they
|
||||
as soon as I get there
|
||||
at my [inserted: mind] for me at there [sic]
|
||||
as soon as I get there.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests for Step 3 library services (revisions, search, export)."""
|
||||
|
||||
from sqlmodel import select
|
||||
import pytest
|
||||
|
||||
from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
|
||||
from transcription.services.library import accept_revision, add_revision, export_transcripts, list_revisions, 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"
|
||||
@@ -8,7 +8,7 @@ from sqlmodel import select
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.errors import AppError, ErrorCategory
|
||||
from transcription.models import Document, Job, JobStatus, Transcript
|
||||
from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
|
||||
from transcription.providers.base import TranscriptionResult
|
||||
from transcription.worker import process_next_queued_job, run_worker_loop
|
||||
|
||||
@@ -74,12 +74,21 @@ class TestWorkerSuccessPath:
|
||||
|
||||
process_next_queued_job(session=session)
|
||||
|
||||
transcript = session.exec(
|
||||
select(Transcript).where(Transcript.job_id == job.id)
|
||||
transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
|
||||
revision = session.exec(
|
||||
select(TranscriptRevision)
|
||||
.where(TranscriptRevision.job_id == job.id)
|
||||
.order_by(TranscriptRevision.revision_number)
|
||||
).first()
|
||||
|
||||
assert transcript is not None
|
||||
assert transcript.text == "Transcript body"
|
||||
assert transcript.error_detail is None
|
||||
assert revision is not None
|
||||
assert revision.revision_number == 1
|
||||
assert revision.text == "Transcript body"
|
||||
assert revision.source == "worker"
|
||||
assert revision.accepted is False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
+40
-1
@@ -5,7 +5,7 @@ from uuid import UUID
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from transcription.models import Document, Job, JobStatus, Transcript
|
||||
from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
|
||||
|
||||
|
||||
def _make_document(**overrides) -> Document:
|
||||
@@ -89,6 +89,12 @@ class TestJobModel:
|
||||
session.refresh(job)
|
||||
assert job.status == JobStatus.TRANSCRIBED
|
||||
|
||||
job.status = JobStatus.COMPLETED
|
||||
session.add(job)
|
||||
session.commit()
|
||||
session.refresh(job)
|
||||
assert job.status == JobStatus.COMPLETED
|
||||
|
||||
def test_transitions_to_failed(self, session):
|
||||
"""Status updates from processing to failed."""
|
||||
doc = _persist_document(session)
|
||||
@@ -152,6 +158,27 @@ class TestTranscriptModel:
|
||||
session.commit()
|
||||
|
||||
|
||||
class TestTranscriptRevisionModel:
|
||||
"""Verify transcript revision persistence and defaults."""
|
||||
|
||||
def test_revision_defaults_and_persistence(self, session):
|
||||
"""Revision records persist with revision metadata and defaults."""
|
||||
doc = _persist_document(session)
|
||||
job = _persist_job(session, doc)
|
||||
|
||||
revision = TranscriptRevision(job_id=job.id, revision_number=1, text="Rev text")
|
||||
session.add(revision)
|
||||
session.commit()
|
||||
session.refresh(revision)
|
||||
|
||||
fetched = session.get(TranscriptRevision, revision.id)
|
||||
assert fetched is not None
|
||||
assert fetched.revision_number == 1
|
||||
assert fetched.text == "Rev text"
|
||||
assert fetched.source == "worker"
|
||||
assert fetched.accepted is False
|
||||
|
||||
|
||||
class TestRelationships:
|
||||
"""Verify SQLModel relationship navigation between models."""
|
||||
|
||||
@@ -177,3 +204,15 @@ class TestRelationships:
|
||||
assert job.transcript is not None
|
||||
assert isinstance(job.transcript, Transcript)
|
||||
assert job.transcript.text == "Transcribed text"
|
||||
|
||||
def test_job_exposes_revisions(self, session):
|
||||
"""job.revisions returns revision history linked to the Job."""
|
||||
doc = _persist_document(session)
|
||||
job = _persist_job(session, doc)
|
||||
session.add(TranscriptRevision(job_id=job.id, revision_number=1, text="v1"))
|
||||
session.add(TranscriptRevision(job_id=job.id, revision_number=2, text="v2", accepted=True))
|
||||
session.commit()
|
||||
|
||||
session.refresh(job)
|
||||
assert len(job.revisions) == 2
|
||||
assert all(isinstance(revision, TranscriptRevision) for revision in job.revisions)
|
||||
|
||||
@@ -19,14 +19,17 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
|
||||
"REQ-3": [
|
||||
"tests/services/test_worker.py",
|
||||
"tests/ui/test_jobs_page.py",
|
||||
"tests/services/test_library.py",
|
||||
],
|
||||
"REQ-4": [
|
||||
"tests/services/test_worker.py",
|
||||
"tests/integration/test_pipeline_flow.py",
|
||||
"tests/services/test_library.py",
|
||||
],
|
||||
"REQ-5": [
|
||||
"tests/ui/test_jobs_page.py",
|
||||
"tests/ui/test_pages_registration.py",
|
||||
"tests/api/test_routes.py",
|
||||
],
|
||||
"REQ-6": [
|
||||
"tests/test_app.py",
|
||||
@@ -36,6 +39,9 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
|
||||
"tests/test_app.py",
|
||||
"tests/test_config.py",
|
||||
],
|
||||
"REQ-11": [
|
||||
"tests/services/test_library.py",
|
||||
],
|
||||
"REQ-12": [
|
||||
"tests/test_prompts.py",
|
||||
"tests/services/test_transcription.py",
|
||||
|
||||
Reference in New Issue
Block a user