generated from john/python-template
209 lines
16 KiB
Markdown
209 lines
16 KiB
Markdown
# Implementation Plan (Version 4.7)
|
|
|
|
## Goal
|
|
|
|
Collapse the duplicated evidence model, remove the `ProcessingArtifact` subsystem and move orientation normalization to ingest, complete the `SourceService` decomposition deferred from V4.6 ([MED-14]), correct the run-time measurement window, and close the remaining correctness and tooling items opened during V4.6. No new user-facing behavior.
|
|
|
|
## Planning Status
|
|
|
|
**Frozen.** The boundary is [`scope_boundary_v4_7.md`](scope_boundary_v4_7.md). Feature work is parked in [`../ver4.8/feature_backlog_v4_8.md`](../ver4.8/feature_backlog_v4_8.md).
|
|
|
|
## Planning Constraints
|
|
|
|
- Every change traces to a finding ID or a V4.6 review-log entry.
|
|
- `ruff check` clean and `ty check` at **0 diagnostics** at the end of every phase, matching the V4.6 exit state.
|
|
- The full suite passes at the end of every phase.
|
|
- **Test changes are expected in Phases 1 and 2.** This differs from V4.6, where the mechanical moves required no test logic changes. Measured blast radius: 33 references to the removed `job_source` evidence fields across 8 test files, and 10 `ProcessingArtifact` references across 3. Only Phase 4 retains the "no test logic changes" rule.
|
|
- Use `.\.venv\Scripts\python.exe -m pytest` (the system interpreter has no packages).
|
|
- Back up `data/transcription.db` **and** `data/documents/` before running any migration step. The image backfill rewrites files in place.
|
|
- One phase, one commit.
|
|
|
|
## Expected Project Impact
|
|
|
|
| Area | Before | After |
|
|
| :--- | :--- | :--- |
|
|
| `job_source` columns | 9 | **4** - `id`, `job_id`, `source_id`, `status` |
|
|
| `JobSourceStatus` on disk | two spellings across two tables | one spelling, plus a new `CANCELLED` member |
|
|
| `processing_artifact` | table, model, ~283 lines of service code, 2 rows | removed |
|
|
| Orientation normalization | derived per transcription, artifact-backed | applied once at ingest, no derivative |
|
|
| Stored image rotation | `quality=95, subsampling=0`, +38% size | qtables reuse, ~6% smaller, higher PSNR |
|
|
| `services/sources.py` | 1,389 lines, 4 domain models | ~900 lines, `Source` + `JobSource` |
|
|
| `services/evidence.py` | does not exist | ~174 lines, `ExecutionAttempt` reads and export |
|
|
| `services/artifacts.py` | planned | **cancelled** - deleted rather than extracted |
|
|
| `duration_ms` | provider call + normalization + artifact write + commit | the operation the timeout governs |
|
|
| Worker loop errors | every `Exception` logged and suppressed | programming errors distinguishable from provider faults |
|
|
| Quality gate | local pre-commit only, inert until installed | enforced in CI |
|
|
|
|
## Migration Handling
|
|
|
|
All schema and data changes are delivered by a single idempotent `tools/migrate_v46_to_v47.py`, following the `tools/migrate_v45_to_v46.py` conventions: never invoked at startup, never run by the test suite.
|
|
|
|
The tool is **built incrementally** - Phase 1 creates it with its own step, Phase 2 appends the next - and is **run at the end of each of those phases** so the live database stays usable at every phase boundary. Idempotency is what makes re-running safe.
|
|
|
|
Steps, in execution order:
|
|
|
|
1. Rotate the 58 stored images carrying EXIF orientation 3, in place, using qtables reuse; strip the orientation tag.
|
|
2. Drop the `processing_artifact` table and remove its external artifact files.
|
|
3. Normalize `execution_attempt.status` to the single declared spelling ([45]).
|
|
4. Drop `job_source.raw_transcription`, `ai_metadata`, `raw_api_response`, `executed_at`, `error_detail`.
|
|
|
|
`tools/migrate_v45_to_v46.py` deliberately accepts both enum spellings because it reads historical backups. Leave that tolerance in place.
|
|
|
|
## Implementation Phases
|
|
|
|
### 1. Ingest Normalization and ProcessingArtifact Removal
|
|
|
|
Deletion comes first, so that Phase 4 never restructures code that is on its way out.
|
|
|
|
Tasks:
|
|
|
|
1. Move orientation normalization into the ingest path in `media_storage`, ahead of `write_bytes` (`media_storage.py:57`). Rotate, strip the EXIF orientation tag, then store.
|
|
2. Change the encode settings at `normalization.py:84-85` from `quality=95, subsampling=0` to `qtables=im.quantization`, `subsampling=JpegImagePlugin.get_sampling(im)`, `optimize=True`. Import `JpegImagePlugin` explicitly - it is not reachable as an attribute of `PIL.Image`.
|
|
3. Delete `resolve_provider_input` and its call at `workflows.py:226`. The stored file is now already upright, so the transcription path reads it directly.
|
|
4. Fold the `transcription_quality_warnings` payload (`workflows.py:560-571`) into `execution_attempt.normalized_metadata`.
|
|
5. Delete the artifact cluster from `sources.py` (lines 732-1015) and the artifact branch of `build_evidence_export`.
|
|
6. Delete the `ProcessingArtifact` model and the `CheckConstraint`.
|
|
7. Remove the "Orientation normalized" badge at `sources_page.py:174-178`, the artifact evidence dump at line 417, and the quality-warnings render at line 661.
|
|
8. Update `test_normalization.py`, `test_v42_evidence.py`, and `test_db.py`. Normalization tests should now assert on ingest behavior rather than on artifact creation.
|
|
9. Create `tools/migrate_v46_to_v47.py` with steps 1 and 2. Back up, run, verify.
|
|
|
|
Verification: re-check EXIF orientation across `data/documents/` - no stored image should report orientation 3, 6, or 8. Spot-check one backfilled page visually.
|
|
|
|
Exit: suite green, `ty check` at 0, `processing_artifact` gone from schema and code.
|
|
|
|
### 2. Evidence Model Simplification
|
|
|
|
Tasks:
|
|
|
|
1. Add `JobSourceStatus.CANCELLED`. Update `jobs.py:378-384` to write it instead of `FAILED` plus `"Cancelled by user"`.
|
|
2. Declare one spelling for `JobSourceStatus` across both `job_source.status` and `execution_attempt.status` ([45]). `job_source.status` already declares `values_callable`; `execution_attempt.status` does not.
|
|
3. Remove `raw_transcription`, `ai_metadata`, `raw_api_response`, `executed_at`, and `error_detail` from the `JobSource` model.
|
|
4. Redirect every read to `execution_attempt`:
|
|
- `transcript.py:103-119` sorts by `executed_at` - sort by `ExecutionAttempt.finished_at`.
|
|
- `sources_page.py:390-393` reads `ai_metadata` and `raw_api_response`.
|
|
- `sources_page.py:337-375` renders status, executed time, and error detail.
|
|
- `models.py:266-278` and `models.py:328-343` derive transcript and error from `job_sources`.
|
|
5. Shrink `update_job_source_transcription` (`sources.py:524-679`) to write only the surviving `JobSource` columns. Keep the method in `sources.py` and keep both writes in one session scope.
|
|
6. Confirm `jobs.py:411-424` retry still works unchanged. It resets `FAILED` to `PENDING`; the attempt history it appears to discard is preserved by `ExecutionAttempt`'s unique constraint.
|
|
7. Confirm `workflows.py:432-442` work selection is unaffected. It filters `status != TRANSCRIBED` within `job.job_sources`, so `CANCELLED` pages are excluded from a re-run only if that is the intent - **decide explicitly** whether cancelled pages should be re-attempted, and encode the answer in the filter rather than leaving it implicit.
|
|
8. Update the 33 affected test references across the 8 files identified.
|
|
9. Append migration steps 3 and 4. Back up, run, verify row counts before and after.
|
|
|
|
Exit: suite green, `ty check` at 0, `job_source` at 4 columns.
|
|
|
|
### 3. Stage B - Extract `services/evidence.py` ([MED-14])
|
|
|
|
Read-side only, now applied to a substantially smaller `sources.py`.
|
|
|
|
Move:
|
|
|
|
- `read_latest_execution_attempt` (216-245), including the `LatestExecutionAttempt` read model
|
|
- `promote_machine_attempt` (679-710)
|
|
- `list_execution_attempts` (710-732)
|
|
- `build_evidence_export` (1015-1107)
|
|
|
|
Tasks:
|
|
|
|
1. Create `services/evidence.py` with an `EvidenceService(ServiceBase)` following the `DocumentService` conventions.
|
|
2. Move the methods and the `LatestExecutionAttempt` dataclass verbatim. Preserve signatures, keyword-only arguments, error types, and `_session_scope` usage exactly.
|
|
3. Preserve every explicit `selectinload()` chain. **Chain, never varargs** - `selectinload(A.b).selectinload(B.c)` and `selectinload(A.b, B.c)` produce an identical `.path` but are not equivalent, and under the `lazy="raise"` default set in V4.6 the varargs form raises at render time. See `db/loading.py`.
|
|
4. Update `ServiceBundle` to construct and expose the new service via the `from_session_factory` constructor added in V4.6.
|
|
5. Update call sites in `sources_page.py` and `workflows.py`.
|
|
6. Run `ruff check --fix` **in the same pass** as the import edits - autofix removes imports that are unused at that moment.
|
|
7. **Revise `.github/instructions/services.instructions.md` to describe the boundaries this decomposition actually produced** (review log [59]). Do this *after* the move, not before - the refactor is the empirical test of the rule, and a rule written in advance would have to be bent to fit. Known defects to correct:
|
|
- **Line 11, `1 service class per data model`** - the rule is table-shaped rather than aggregate-shaped, and is the measured cause of `sources.py` reaching 1,389 lines. Replace with aggregate ownership.
|
|
- **No home for junction tables.** The rule names the four core components (Document, Source, Job, Person) but is silent on `job_source` and `document_person`, where they intersect. Add an explicit model-ownership table naming the owning service for every model, including junctions and `ExecutionAttempt`.
|
|
- **Lines 30-32**, mandatory CRUD for every model, is already false: `prompts.py` does not comply. Soften to describe intent rather than mandate a method set.
|
|
- **Line 13 vs lines 75-77** read as contradictory on whether a service may touch more than one table. Reword the composition section so the ownership rule and the multi-table-operation guidance agree.
|
|
- **Line 77** typo: `picutre`.
|
|
|
|
Note that line numbers above are pre-Phase-1 positions and will have shifted. Locate by symbol, not by line.
|
|
|
|
Exit: suite green **with no test logic changes** beyond import paths, `ty check` at 0, and `services.instructions.md` consistent with the post-refactor module layout. Walk every `/ui/*` page and confirm a 200, since `lazy="raise"` turns a missed eager load into a runtime error rather than a slow query.
|
|
|
|
### 4. Run-Time Measurement Window (review log [55])
|
|
|
|
Tasks:
|
|
|
|
1. In `workflows.py`, make the recorded duration cover only the operation the `wait_for` at lines 240-249 governs. Either move `monotonic_started_at` (line 221) to immediately before the `wait_for`, or capture provider latency separately and record that.
|
|
2. Apply the same treatment to all three write sites: success (line 278), `TimeoutError` (line 295), and general failure (line 330). The failure paths must keep using the monotonic clock.
|
|
3. If preprocessing time is still worth keeping, record it as its own value rather than folding it into `duration_ms`.
|
|
4. Update `sources_page.py:400`, which renders the raw integer as `"27612 ms"`.
|
|
|
|
Phase 1 already removes normalization and the artifact write from this window, which narrows the gap but does not close it - the `session.commit()` at line 228 remains inside it.
|
|
|
|
Verification: a recorded timeout duration should sit at or just under the configured budget, not 0.4-2.0 s above it as in the three historical `local_timeout` rows.
|
|
|
|
### 5. Worker Exception Handling (review log [8])
|
|
|
|
`worker.py:96-106`.
|
|
|
|
Tasks:
|
|
|
|
1. Separate genuinely retriable faults from programming errors. `classify_unexpected_error` is already called at line 101 and its result is currently only logged.
|
|
2. Ensure a non-retriable error reaches a terminal state instead of being retried.
|
|
3. Keep terminal-state and retry persistence atomic per `services.instructions.md:63-65`.
|
|
4. Add a test that a deliberate programming error in the loop does not silently retry.
|
|
|
|
Context: with `WORKER_MAX_RETRIES=1` and a 30 s timeout the worst-case silent burn is 60 s, down from 360 s, so this is no longer urgent - but it remains the real fix behind that risk.
|
|
|
|
### 6. CI Enforcement ([HIGH-06], review log [40])
|
|
|
|
Tasks:
|
|
|
|
1. Add a workflow under `.github/workflows/` running `ruff check`, `ty check`, and `pytest` on push and pull request.
|
|
2. Use the same commands as `.pre-commit-config.yaml` so local and CI gates cannot drift.
|
|
3. Confirm the 4 tests that skip without `OPENROUTER_API_KEY` skip cleanly in CI rather than failing.
|
|
4. Negative-test the workflow by pushing a deliberate lint error on a scratch branch.
|
|
|
|
## Sequencing Constraints
|
|
|
|
- **Phase 1 before Phase 3.** Code scheduled for deletion is never extracted first. This is why the previously planned `services/artifacts.py` is cancelled.
|
|
- **Phase 1 before Phase 2.** Both touch `workflows.py` write paths; separating them keeps any regression attributable.
|
|
- **Phase 4 before any V4.8 telemetry work.** A model-performance rollup built on the current `duration_ms` would chart preprocessing mixed with provider latency.
|
|
|
|
## Test Strategy
|
|
|
|
- **Phase 1:** normalization tests move from asserting artifact creation to asserting ingest behavior. Add a test that a stored image never retains EXIF orientation 3, 6, or 8.
|
|
- **Phase 2:** assert that evidence reads resolve through `execution_attempt`; assert `CANCELLED` is distinguishable from `FAILED`; verify both status columns round-trip identically and existing rows read back correctly after the fix-up.
|
|
- **Phase 3:** the suite passes with **no test logic changes**. Only import paths update. A required behavioral change signals the move was not mechanical - stop and re-examine.
|
|
- **Phase 4:** assert the recorded duration is bounded by the configured timeout.
|
|
- **Phase 5:** new test that a programming error does not silently retry.
|
|
- **Phase 6:** CI must fail on an injected lint error.
|
|
|
|
## Risks
|
|
|
|
| Risk | Mitigation |
|
|
| :--- | :--- |
|
|
| The image backfill corrupts originals - it rewrites files in place with no archival master | Back up `data/documents/` before running; idempotent step keyed on the EXIF tag so a second run is a no-op; visually spot-check a backfilled page |
|
|
| Dropping `job_source` columns loses data that turns out not to be duplicated | Verified 77/77 identical on all three evidence columns before dropping; re-run that comparison inside the migration and abort on any mismatch |
|
|
| A read still expects a removed `job_source` column and fails only at render time | Grep-driven checklist in Phase 2 task 4; walk every `/ui/*` page after the phase |
|
|
| Cancelled pages are silently re-attempted, or silently never re-attempted | Phase 2 task 7 forces an explicit decision in the work-selection filter |
|
|
| A moved query loses an eager load and trips `lazy="raise"` at render time | Preserve `selectinload` chains verbatim; walk every `/ui/*` page after Phase 3 |
|
|
| `ruff check --fix` deletes an import mid-move | Edit imports and usages in the same pass, as in V4.6 |
|
|
| Circular imports between `sources.py` and `evidence.py` | Composition is sanctioned by `services.instructions.md:75-77`; keep the dependency one-directional |
|
|
| Scope creep into refactoring `update_job_source_transcription` | Out of scope; the boundary records why |
|
|
|
|
## Delivery Order
|
|
|
|
1. Ingest normalization and `ProcessingArtifact` removal
|
|
2. Evidence model simplification
|
|
3. Stage B - `evidence.py`, then revise `services.instructions.md` to match
|
|
4. Measurement window
|
|
5. Worker exception handling
|
|
6. CI enforcement
|
|
|
|
## Done Criteria
|
|
|
|
Every item in the scope boundary's Acceptance Criteria is satisfied, the suite is green, `ty check` reports 0 diagnostics, and no user-facing behavior has changed.
|
|
|
|
## Related Local References
|
|
|
|
- [V4.7 Scope Boundary](scope_boundary_v4_7.md)
|
|
- [Architecture & Code Review Report](../architecture_code_review_2026-08-17.md)
|
|
- [V4.6 Review Log](../ver4.6/review_log_v4_6.md) - resolves the `review log [N]` citations used throughout this document
|
|
- [V4.6 Implementation Plan](../ver4.6/implementation_plan_v4_6.md)
|
|
- [V4.8 Feature Backlog](../ver4.8/feature_backlog_v4_8.md)
|
|
- `.github/instructions/services.instructions.md`
|
|
- `src/transcription/db/loading.py` - the `selectinload` varargs trap
|