Implement Ver1 Stage3

This commit is contained in:
Jim Lancaster
2026-06-26 14:23:12 -05:00
parent 21478a904c
commit 06bb4290be
17 changed files with 1457 additions and 62 deletions
@@ -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. |
---
+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
+162
View File
@@ -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,
}
+2
View File
@@ -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
View File
@@ -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")
+257
View File
@@ -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
+107
View File
@@ -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")
+10 -2
View File
@@ -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")
+118
View File
@@ -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 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.
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 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
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 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.
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.
+90
View File
@@ -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"
+12 -3
View File
@@ -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
View File
@@ -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)
+6
View File
@@ -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",