generated from john/python-template
This commit is contained in:
@@ -50,7 +50,7 @@ wins if this file drifts from it.
|
|||||||
post-parse `model_dump()` and call the result transport evidence.
|
post-parse `model_dump()` and call the result transport evidence.
|
||||||
- Reset per-call capture state at the start of every call. Without it, a connection failure can
|
- Reset per-call capture state at the start of every call. Without it, a connection failure can
|
||||||
attach the *previous* call's response as evidence for this one. Guarded by
|
attach the *previous* call's response as evidence for this one. Guarded by
|
||||||
`tests/test_v42_evidence.py::test_openrouter_does_not_reuse_prior_response_on_connection_failure`.
|
`tests/test_evidence_provenance.py::test_openrouter_does_not_reuse_prior_response_on_connection_failure`.
|
||||||
- Keep transport capture scoped to the call, not the adapter instance. Concurrent `transcribe()`
|
- Keep transport capture scoped to the call, not the adapter instance. Concurrent `transcribe()`
|
||||||
calls on one adapter must not be able to overwrite each other's response evidence.
|
calls on one adapter must not be able to overwrite each other's response evidence.
|
||||||
- Handle the streamed-body case (`httpx.ResponseNotRead`) rather than assuming `response.content`
|
- Handle the streamed-body case (`httpx.ResponseNotRead`) rather than assuming `response.content`
|
||||||
@@ -118,5 +118,5 @@ If capture behavior, evidence schema, or the header allowlist changes:
|
|||||||
1. Update `docs/invariant/ai_evidence_and_provenance.md` only if the durable preservation contract
|
1. Update `docs/invariant/ai_evidence_and_provenance.md` only if the durable preservation contract
|
||||||
itself is changing — that revision is deliberate and reviewed, not incidental.
|
itself is changing — that revision is deliberate and reviewed, not incidental.
|
||||||
2. Update `docs/schema.md` when persisted evidence fields change.
|
2. Update `docs/schema.md` when persisted evidence fields change.
|
||||||
3. Update or add tests in the same change (`tests/providers/`, `tests/test_v42_evidence.py`).
|
3. Update or add tests in the same change (`tests/providers/`, `tests/test_evidence_provenance.py`).
|
||||||
4. Bump the affected evidence `schema_version` when a field's meaning changes.
|
4. Bump the affected evidence `schema_version` when a field's meaning changes.
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ Where **Enforced by** reads *unenforced*, recommending a deterministic test is i
|
|||||||
| 1 | **Service boundary rule:** no service-to-service imports | `tests/test_service_boundaries.py` |
|
| 1 | **Service boundary rule:** no service-to-service imports | `tests/test_service_boundaries.py` |
|
||||||
| 2 | **UI boundary rule:** pages/components do not perform persistence access | `tests/test_ui_boundaries.py` |
|
| 2 | **UI boundary rule:** pages/components do not perform persistence access | `tests/test_ui_boundaries.py` |
|
||||||
| 3 | **Status vocabulary conformance:** `JobStatus`/`JobSourceStatus`/`JobPurpose` usage matches current enums in `src/transcription/db/models.py`; no stringly-typed status literals | `tests/test_model_contract_guards.py` |
|
| 3 | **Status vocabulary conformance:** `JobStatus`/`JobSourceStatus`/`JobPurpose` usage matches current enums in `src/transcription/db/models.py`; no stringly-typed status literals | `tests/test_model_contract_guards.py` |
|
||||||
| 4 | **Evidence ownership conformance:** append-only attempt history is preserved and projection writes are not mistaken for history mutation (`src/transcription/services/sources.py`, `src/transcription/services/evidence.py`) | `tests/test_v42_evidence.py::test_attempts_are_append_only_and_exported_with_integrity` |
|
| 4 | **Evidence ownership conformance:** append-only attempt history is preserved and projection writes are not mistaken for history mutation (`src/transcription/services/sources.py`, `src/transcription/services/evidence.py`) | `tests/test_evidence_provenance.py::test_attempts_are_append_only_and_exported_with_integrity` |
|
||||||
| 5 | **Canonical authority:** findings must resolve against `docs/*` first | `tests/test_meta_contract_guards.py::test_canonical_authority_references_are_present` |
|
| 5 | **Canonical authority:** findings must resolve against `docs/*` first | `tests/test_meta_contract_guards.py::test_canonical_authority_references_are_present` |
|
||||||
| 6 | **Schema contract fidelity:** when model/persistence behavior changes, `docs/schema.md` remains field-accurate with `src/transcription/db/models.py` | `tests/test_model_contract_guards.py` (field names, ordering, enum members, table coverage), `tests/test_meta_contract_guards.py` (presence and references) |
|
| 6 | **Schema contract fidelity:** when model/persistence behavior changes, `docs/schema.md` remains field-accurate with `src/transcription/db/models.py` | `tests/test_model_contract_guards.py` (field names, ordering, enum members, table coverage), `tests/test_meta_contract_guards.py` (presence and references) |
|
||||||
| 7 | **Media boundary conformance:** print/export media is record-validated and UI media URL generation uses controlled resolver paths | `tests/test_media_path_safety.py`, `tests/ui/test_media_urls.py` |
|
| 7 | **Media boundary conformance:** print/export media is record-validated and UI media URL generation uses controlled resolver paths | `tests/test_media_path_safety.py`, `tests/ui/test_media_urls.py` |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: Quality Gate
|
name: Quality Gate
|
||||||
|
|
||||||
# V4.7 Phase 6 / review log [40]. Before this, ruff, ty and pytest were enforced
|
# Repository quality gate. Before this workflow existed, ruff, ty, and pytest were
|
||||||
# only by .pre-commit-config.yaml, and only for developers who had actually run
|
# enforced only by .pre-commit-config.yaml for developers who had run
|
||||||
# `pre-commit install`.
|
# `pre-commit install`.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Quality gate for V4.6 [HIGH-06]. `ruff check`, `ruff format --check`, and `ty check`
|
# Quality gate: `ruff check`, `ruff format --check`, and `ty check`
|
||||||
# are blocking once known `ty` false positives are suppressed inline with rationale.
|
# are blocking once known `ty` false positives are suppressed inline with rationale.
|
||||||
#
|
#
|
||||||
# Both tools are uv-managed dev dependencies and are not on PATH, so each entry must
|
# Both tools are uv-managed dev dependencies and are not on PATH, so each entry must
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ These are not conventions — a test fails if you break them:
|
|||||||
- **No persistence access from pages/components** (`test_ui_boundaries.py`, allowlist-based).
|
- **No persistence access from pages/components** (`test_ui_boundaries.py`, allowlist-based).
|
||||||
- **No hand-rolled error notifications in UI** — use the shared error presenter.
|
- **No hand-rolled error notifications in UI** — use the shared error presenter.
|
||||||
- **No stringly-typed status literals** — use the enums (`test_model_contract_guards.py`).
|
- **No stringly-typed status literals** — use the enums (`test_model_contract_guards.py`).
|
||||||
- **Attempt history is append-only** (`test_v42_evidence.py`).
|
- **Attempt history is append-only** (`test_evidence_provenance.py`).
|
||||||
- **`docs/schema.md` stays field-accurate** with `db/models.py`.
|
- **`docs/schema.md` stays field-accurate** with `db/models.py`.
|
||||||
- **Orphans are tracked, not tolerated** — `test_orphan_sweep.py` records each retained
|
- **Orphans are tracked, not tolerated** — `test_orphan_sweep.py` records each retained
|
||||||
orphan with rationale in `KNOWN_ORPHANS`.
|
orphan with rationale in `KNOWN_ORPHANS`.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Cloudflare Tunnel and Access Setup (V6.0 Phase 3)
|
# Cloudflare Tunnel and Access Setup
|
||||||
|
|
||||||
This guide defines the repository-supported setup for exposing app and selected LAN services through Cloudflare Tunnel with Cloudflare Access protection.
|
This guide defines the repository-supported setup for exposing app and selected LAN services through Cloudflare Tunnel with Cloudflare Access protection.
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -26,9 +26,9 @@ This directory is the single source of truth for current V6.1 behavior and archi
|
|||||||
|
|
||||||
## Baseline Statement
|
## Baseline Statement
|
||||||
|
|
||||||
The current V6.1 baseline includes behavior delivered through the architectural cleanup phases and
|
The current V6.1 baseline includes the architectural cleanup, person-schema redesign,
|
||||||
person-schema redesign (through V5.1), the V6.0 hosting migration to a containerized PostgreSQL
|
containerized PostgreSQL deployment, and the navigation, Document Detail, and worker-backed
|
||||||
deployment, and the V6.1 navigation, Document Detail, and worker-backed maintenance refinements.
|
maintenance refinements reflected across this canonical document set.
|
||||||
Use this `docs/*` canonical set for active design and implementation decisions.
|
Use this `docs/*` canonical set for active design and implementation decisions.
|
||||||
|
|
||||||
Every canonical document above states this same baseline; `tests/test_meta_contract_guards.py`
|
Every canonical document above states this same baseline; `tests/test_meta_contract_guards.py`
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ This runbook is the operational checklist for releasing and monitoring the trans
|
|||||||
## 2. Release execution steps
|
## 2. Release execution steps
|
||||||
|
|
||||||
1. Deploy artifact/config to target environment.
|
1. Deploy artifact/config to target environment.
|
||||||
- V6.0 Phase 1 production stack: `docker compose -f docker-compose.production.yml up -d --build`
|
- Production stack: `docker compose -f docker-compose.production.yml up -d --build`
|
||||||
- `Settings` loads from explicit `_env_file`, then `ENV_FILE`, then the repository-root `.env.production`; it does not resolve relative to the process working directory.
|
- `Settings` loads from explicit `_env_file`, then `ENV_FILE`, then the repository-root `.env.production`; it does not resolve relative to the process working directory.
|
||||||
- For Runtime Settings writes in production, mount `.env.production` into the app container and set `RUNTIME_SETTINGS_ENV_FILE=/app/.env.production`.
|
- For Runtime Settings writes in production, mount `.env.production` into the app container and set `RUNTIME_SETTINGS_ENV_FILE=/app/.env.production`.
|
||||||
- If deployment uses a non-default env-file location, set both `ENV_FILE` and `RUNTIME_SETTINGS_ENV_FILE` to that absolute path so startup reads and Settings-page writes stay aligned.
|
- If deployment uses a non-default env-file location, set both `ENV_FILE` and `RUNTIME_SETTINGS_ENV_FILE` to that absolute path so startup reads and Settings-page writes stay aligned.
|
||||||
|
|||||||
@@ -146,5 +146,5 @@ Rules:
|
|||||||
|
|
||||||
## Known Limitations and Deferred Work
|
## Known Limitations and Deferred Work
|
||||||
|
|
||||||
- Source page ordering remains read-only in V4.4.
|
- Source page ordering remains read-only.
|
||||||
- Printing other entities, batch printing, and server-side export formats are deferred.
|
- Printing other entities, batch printing, and server-side export formats are deferred.
|
||||||
|
|||||||
@@ -96,4 +96,4 @@ The list accepts optional `document_id` and `job_id` query parameters. Document
|
|||||||
|
|
||||||
## Planned Changes
|
## Planned Changes
|
||||||
|
|
||||||
- Source page reordering is deferred beyond V4.3 and may be reconsidered if a demonstrated workflow need emerges.
|
- Source page reordering remains deferred unless a demonstrated workflow need emerges.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from transcription.services.workflows import advance_job
|
|||||||
|
|
||||||
|
|
||||||
async def _attempts_for_job(session, job) -> list[ExecutionAttempt]:
|
async def _attempts_for_job(session, job) -> list[ExecutionAttempt]:
|
||||||
"""Load execution attempts for a job; V4.7 moved evidence off JobSource."""
|
"""Load execution attempts for a job; evidence lives on ExecutionAttempt."""
|
||||||
job_source_ids = [job_source.id for job_source in job.job_sources]
|
job_source_ids = [job_source.id for job_source in job.job_sources]
|
||||||
result = await session.exec(select(ExecutionAttempt).where(col(ExecutionAttempt.job_source_id).in_(job_source_ids)))
|
result = await session.exec(select(ExecutionAttempt).where(col(ExecutionAttempt.job_source_id).in_(job_source_ids)))
|
||||||
return list(result.all())
|
return list(result.all())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""V4.5 retranscription candidate and promotion tests."""
|
"""Retranscription candidate and promotion tests."""
|
||||||
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ def _services(default_session_factory, settings: Settings) -> ServiceBundle:
|
|||||||
|
|
||||||
|
|
||||||
async def _seed_source(services: ServiceBundle) -> Source:
|
async def _seed_source(services: ServiceBundle) -> Source:
|
||||||
document = await services.documents.create_document(Document(name="V4.5 source"))
|
document = await services.documents.create_document(Document(name="candidate source"))
|
||||||
source = Source(
|
source = Source(
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
page_number=1,
|
page_number=1,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for deterministic V4.5 transcription warnings."""
|
"""Tests for deterministic transcription warnings."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ from transcription.services.quality import quality_warning_payload
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_quality_analysis_reports_each_v45_warning_without_mutating_text():
|
def test_quality_analysis_reports_each_quality_warning_without_mutating_text():
|
||||||
text = (
|
text = (
|
||||||
"[document body handwritten]\n"
|
"[document body handwritten]\n"
|
||||||
"[document body typewritten]\n"
|
"[document body typewritten]\n"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Focused V4.2 evidence, integrity, and benchmark tests."""
|
"""Focused evidence, integrity, and benchmark tests."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -69,6 +69,19 @@ _BASELINE_CLAIM = re.compile(
|
|||||||
r"\b(?:Current Baseline:\s*|current\s+|active\s+|canonical\s+)V(\d+(?:\.\d+)?)",
|
r"\b(?:Current Baseline:\s*|current\s+|active\s+|canonical\s+)V(\d+(?:\.\d+)?)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
_LEGACY_VERSION_REFERENCE = re.compile(r"\b[Vv](4(?:\.\d+)?|5(?:\.\d+)?|6\.0)\b|test_v42_evidence|test_v45_candidates")
|
||||||
|
LEGACY_VERSION_REFERENCE_EXCLUSIONS = frozenset(
|
||||||
|
{
|
||||||
|
".github/workflows/quality-gate.yml",
|
||||||
|
"docs/data_migration.md",
|
||||||
|
"docs/requirements.md",
|
||||||
|
"docs/reviews",
|
||||||
|
"docs/roadmap_plan.md",
|
||||||
|
"docs/schema.md",
|
||||||
|
"src/transcription/db/operations.py",
|
||||||
|
"tests/test_meta_contract_guards.py",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _declared_baseline() -> str:
|
def _declared_baseline() -> str:
|
||||||
@@ -112,6 +125,40 @@ def test_canonical_docs_declare_one_consistent_baseline():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_version_markers_are_scoped_to_allowed_historical_files():
|
||||||
|
"""Version labels from older project baselines should not linger in active files.
|
||||||
|
|
||||||
|
Historical references are allowed only where the old version is the subject matter:
|
||||||
|
roadmap planning, dated reviews, stable requirement IDs, and explicit migration or
|
||||||
|
compatibility notes.
|
||||||
|
"""
|
||||||
|
scanned: list[Path] = []
|
||||||
|
for root in (
|
||||||
|
PROJECT_ROOT / ".github",
|
||||||
|
PROJECT_ROOT / "docs",
|
||||||
|
PROJECT_ROOT / "src",
|
||||||
|
PROJECT_ROOT / "tests",
|
||||||
|
):
|
||||||
|
scanned.extend(path for path in root.rglob("*") if path.suffix in {".md", ".py", ".yml", ".yaml"})
|
||||||
|
scanned.extend((PROJECT_ROOT / "AGENTS.md", PROJECT_ROOT / ".pre-commit-config.yaml"))
|
||||||
|
|
||||||
|
violations: dict[str, list[str]] = {}
|
||||||
|
for path in sorted(set(scanned)):
|
||||||
|
relative = path.relative_to(PROJECT_ROOT).as_posix()
|
||||||
|
if any(
|
||||||
|
relative == excluded or relative.startswith(f"{excluded}/")
|
||||||
|
for excluded in LEGACY_VERSION_REFERENCE_EXCLUSIONS
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
matches = sorted(
|
||||||
|
{match.group(0) for match in _LEGACY_VERSION_REFERENCE.finditer(path.read_text(encoding="utf-8"))}
|
||||||
|
)
|
||||||
|
if matches:
|
||||||
|
violations[relative] = matches
|
||||||
|
|
||||||
|
assert violations == {}, f"Legacy version markers remain in active files: {violations}"
|
||||||
|
|
||||||
|
|
||||||
def test_active_contract_files_are_present():
|
def test_active_contract_files_are_present():
|
||||||
"""Guard the guard: ensure all expected authority files are scanned."""
|
"""Guard the guard: ensure all expected authority files are scanned."""
|
||||||
missing = [path for path in ACTIVE_CONTRACT_FILES if not (PROJECT_ROOT / path).exists()]
|
missing = [path for path in ACTIVE_CONTRACT_FILES if not (PROJECT_ROOT / path).exists()]
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ class TestPersonAndDocumentPersonModel:
|
|||||||
|
|
||||||
class TestJobSourceModel:
|
class TestJobSourceModel:
|
||||||
def test_job_source_is_a_queue_row_not_an_evidence_row(self, session):
|
def test_job_source_is_a_queue_row_not_an_evidence_row(self, session):
|
||||||
"""V4.7: job_source carries only queue state; evidence lives on execution_attempt."""
|
"""job_source carries only queue state; evidence lives on execution_attempt."""
|
||||||
document = _persist_document(session)
|
document = _persist_document(session)
|
||||||
job = _persist_job(session, document)
|
job = _persist_job(session, document)
|
||||||
source = _persist_source(session, document)
|
source = _persist_source(session, document)
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ async def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., Awai
|
|||||||
session.add(job_source)
|
session.add(job_source)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
# V4.7: evidence lives on execution_attempt, not job_source.
|
# Evidence lives on execution_attempt, not job_source.
|
||||||
executed_at = datetime.now(UTC)
|
executed_at = datetime.now(UTC)
|
||||||
session.add(
|
session.add(
|
||||||
ExecutionAttempt(
|
ExecutionAttempt(
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ class TestSourcesPageRendering:
|
|||||||
await service.update_job_source_transcription(
|
await service.update_job_source_transcription(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
text="V4.2 transcription",
|
text="Machine transcription",
|
||||||
raw_api_response={"id": "sdk-snapshot"},
|
raw_api_response={"id": "sdk-snapshot"},
|
||||||
ai_metadata={"finish_reason": "stop"},
|
ai_metadata={"finish_reason": "stop"},
|
||||||
provider="openrouter",
|
provider="openrouter",
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
"""One-time migration of a V4.6 database into the V4.7 schema.
|
|
||||||
|
|
||||||
V4.7 is an architectural cleanup: no new user-facing behaviour, but three
|
|
||||||
structural changes plus a one-time image backfill. This tool carries all of
|
|
||||||
them, and is built up phase by phase so the live database stays usable at
|
|
||||||
every phase boundary.
|
|
||||||
|
|
||||||
Steps, in execution order:
|
|
||||||
|
|
||||||
1. Rotate every stored Source image that still carries a supported EXIF
|
|
||||||
orientation, in place, and update ``source.file_hash`` and
|
|
||||||
``source.file_size_bytes`` to describe the rewritten file.
|
|
||||||
2. Drop the ``processing_artifact`` table and delete its external files.
|
|
||||||
3. Rewrite ``execution_attempt.status`` from enum *names* to enum *values*, so
|
|
||||||
it compares equal to ``job_source.status`` (defect [45]).
|
|
||||||
4. Drop the five evidence columns from ``job_source``, leaving it a pure work
|
|
||||||
queue of ``id``, ``job_id``, ``source_id`` and ``status``.
|
|
||||||
|
|
||||||
Design notes:
|
|
||||||
|
|
||||||
- The image rewrite reuses the application's own
|
|
||||||
:func:`~transcription.services.normalization.normalize_orientation`, so the
|
|
||||||
backfilled bytes are byte-identical to what ingest would now produce. It
|
|
||||||
reuses the source quantization tables and subsampling rather than
|
|
||||||
re-quantizing, which is both smaller and higher fidelity than a fixed
|
|
||||||
quality setting.
|
|
||||||
- The hash and size are rewritten alongside the file. After V4.7 the evidence
|
|
||||||
digest is derived straight from ``source.file_hash``, so leaving it
|
|
||||||
describing the pre-rotation bytes would silently invalidate every future
|
|
||||||
export.
|
|
||||||
- The database is read and written through SQLAlchemy Core against the live
|
|
||||||
metadata, so the same script works against PostgreSQL when that cutover
|
|
||||||
happens. Raw DDL is used only for the table drop, which has no Core
|
|
||||||
equivalent that is safe to express against deleted metadata.
|
|
||||||
- The script is idempotent, keyed on state rather than on a version marker:
|
|
||||||
an image with no supported orientation tag is skipped, and a table that is
|
|
||||||
already absent is skipped. It is never invoked from application startup and
|
|
||||||
never runs in the test suite.
|
|
||||||
- **The application must not be running.** The image rewrite is not atomic
|
|
||||||
with the row update, and SQLite will refuse the schema change while another
|
|
||||||
connection holds the database.
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
python tools/migrate_v46_to_v47.py --dry-run
|
|
||||||
python tools/migrate_v46_to_v47.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import sys
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from sqlalchemy import bindparam
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy import update
|
|
||||||
from sqlalchemy.engine import Connection
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
from transcription.config import Settings
|
|
||||||
from transcription.config import get_settings
|
|
||||||
from transcription.db import models as _models # noqa: F401 (registers every table)
|
|
||||||
from transcription.db.engine import get_database_url
|
|
||||||
from transcription.db.models import JobSourceStatus
|
|
||||||
from transcription.services.normalization import normalize_orientation
|
|
||||||
from transcription.services.sources import source_mime_type
|
|
||||||
|
|
||||||
#: Row counts the V4.6 database is expected to carry, used as a pre-flight
|
|
||||||
#: guard so the script cannot silently run against the wrong file.
|
|
||||||
EXPECTED_ROW_COUNTS = {
|
|
||||||
"document": 8,
|
|
||||||
"document_person": 11,
|
|
||||||
"document_type": 7,
|
|
||||||
"execution_attempt": 80,
|
|
||||||
"job": 11,
|
|
||||||
"job_source": 79,
|
|
||||||
"person": 5,
|
|
||||||
"person_role": 3,
|
|
||||||
"source": 76,
|
|
||||||
}
|
|
||||||
|
|
||||||
ARTIFACT_TABLE = "processing_artifact"
|
|
||||||
|
|
||||||
#: Evidence columns removed from ``job_source`` in V4.7. Every one of them is
|
|
||||||
#: duplicated byte-for-byte by ``execution_attempt`` across all 77 rows that
|
|
||||||
#: carry evidence, so no information is lost by dropping them.
|
|
||||||
JOB_SOURCE_DROPPED_COLUMNS = (
|
|
||||||
"raw_transcription",
|
|
||||||
"ai_metadata",
|
|
||||||
"raw_api_response",
|
|
||||||
"error_detail",
|
|
||||||
"executed_at",
|
|
||||||
)
|
|
||||||
|
|
||||||
#: The V4.6 default for the deleted ``Settings.artifact_dir``. The setting no
|
|
||||||
#: longer exists, so the historical location is recorded here instead.
|
|
||||||
DEFAULT_ARTIFACT_DIR = Path("data/artifacts")
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_url(settings: Settings) -> str:
|
|
||||||
"""Return the target database URL with any async driver stripped."""
|
|
||||||
url = get_database_url(settings)
|
|
||||||
return url.replace("+aiosqlite", "").replace("+asyncpg", "").replace("+psycopg", "")
|
|
||||||
|
|
||||||
|
|
||||||
def _preflight(connection: Connection, *, strict: bool) -> None:
|
|
||||||
inspector = sqlalchemy_inspect(connection)
|
|
||||||
present = set(inspector.get_table_names())
|
|
||||||
mismatched: dict[str, tuple[object, int]] = {}
|
|
||||||
for name, expected in EXPECTED_ROW_COUNTS.items():
|
|
||||||
if name not in present:
|
|
||||||
mismatched[name] = ("missing", expected)
|
|
||||||
continue
|
|
||||||
actual = connection.execute(text(f'select count(*) from "{name}"')).scalar_one()
|
|
||||||
if actual != expected:
|
|
||||||
mismatched[name] = (actual, expected)
|
|
||||||
if not mismatched:
|
|
||||||
return
|
|
||||||
detail = ", ".join(f"{name}: found {found}, expected {want}" for name, (found, want) in sorted(mismatched.items()))
|
|
||||||
message = f"Database row counts do not match the recorded V4.6 snapshot ({detail})"
|
|
||||||
if strict:
|
|
||||||
raise RuntimeError(message)
|
|
||||||
print(f"WARNING: {message}", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def rotate_stored_images(connection: Connection, *, dry_run: bool) -> int:
|
|
||||||
"""Step 1: rewrite every mis-oriented stored image and its recorded digest."""
|
|
||||||
source = SQLModel.metadata.tables["source"]
|
|
||||||
rows = connection.execute(select(source.c.id, source.c.file_path, source.c.filename)).all()
|
|
||||||
|
|
||||||
rotated = 0
|
|
||||||
missing = 0
|
|
||||||
for source_id, file_path, filename in rows:
|
|
||||||
path = Path(str(file_path))
|
|
||||||
if not path.is_file():
|
|
||||||
print(f" WARNING: source file not found, skipped: {path}", file=sys.stderr)
|
|
||||||
missing += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
content = path.read_bytes()
|
|
||||||
normalized = normalize_orientation(content, media_type=source_mime_type(str(filename)))
|
|
||||||
if normalized is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
rotated += 1
|
|
||||||
print(
|
|
||||||
f" {path.name} orientation={normalized.original_orientation} "
|
|
||||||
f"rotation={normalized.applied_rotation_degrees} "
|
|
||||||
f"{len(content)} -> {len(normalized.content)} bytes"
|
|
||||||
)
|
|
||||||
if dry_run:
|
|
||||||
continue
|
|
||||||
|
|
||||||
path.write_bytes(normalized.content)
|
|
||||||
connection.execute(
|
|
||||||
update(source)
|
|
||||||
.where(source.c.id == source_id)
|
|
||||||
.values(
|
|
||||||
file_hash=hashlib.sha256(normalized.content).hexdigest(),
|
|
||||||
file_size_bytes=len(normalized.content),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f" rotated={rotated} upright={len(rows) - rotated - missing} missing={missing}")
|
|
||||||
return rotated
|
|
||||||
|
|
||||||
|
|
||||||
def drop_processing_artifacts(connection: Connection, artifact_dir: Path, *, dry_run: bool) -> int:
|
|
||||||
"""Step 2: drop the artifact table and delete the files it referenced."""
|
|
||||||
inspector = sqlalchemy_inspect(connection)
|
|
||||||
if ARTIFACT_TABLE not in set(inspector.get_table_names()):
|
|
||||||
print(f" {ARTIFACT_TABLE} already absent")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
references = [
|
|
||||||
str(row[0])
|
|
||||||
for row in connection.execute(
|
|
||||||
text(f'select external_reference from "{ARTIFACT_TABLE}" where external_reference is not null')
|
|
||||||
)
|
|
||||||
]
|
|
||||||
count = connection.execute(text(f'select count(*) from "{ARTIFACT_TABLE}"')).scalar_one()
|
|
||||||
print(f" dropping {ARTIFACT_TABLE} ({count} row(s), {len(references)} external file(s))")
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
return count
|
|
||||||
|
|
||||||
connection.execute(text(f'drop table "{ARTIFACT_TABLE}"'))
|
|
||||||
|
|
||||||
artifact_root = artifact_dir.resolve()
|
|
||||||
for reference in references:
|
|
||||||
relative = Path(reference)
|
|
||||||
if relative.is_absolute() or ".." in relative.parts:
|
|
||||||
print(f" WARNING: skipped unsafe artifact reference: {reference}", file=sys.stderr)
|
|
||||||
continue
|
|
||||||
artifact_path = (artifact_root / relative).resolve()
|
|
||||||
if artifact_root not in artifact_path.parents:
|
|
||||||
print(f" WARNING: skipped artifact outside root: {reference}", file=sys.stderr)
|
|
||||||
continue
|
|
||||||
artifact_path.unlink(missing_ok=True)
|
|
||||||
parent = artifact_path.parent
|
|
||||||
if parent != artifact_root and parent.is_dir() and not any(parent.iterdir()):
|
|
||||||
parent.rmdir()
|
|
||||||
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_attempt_status(connection: Connection, *, dry_run: bool) -> int:
|
|
||||||
"""Step 3: rewrite ``execution_attempt.status`` from enum names to values.
|
|
||||||
|
|
||||||
Defect [45]: ``execution_attempt.status`` was declared without
|
|
||||||
``values_callable``, so SQLAlchemy persisted enum *names* ('TRANSCRIBED')
|
|
||||||
while ``job_source.status`` persisted *values* ('transcribed'). The two
|
|
||||||
columns never compared equal on a single one of the 79 rows. The model
|
|
||||||
declaration is fixed in V4.7; the stored rows are fixed here.
|
|
||||||
"""
|
|
||||||
name_to_value = {member.name: member.value for member in JobSourceStatus}
|
|
||||||
recognised = sorted(set(name_to_value) | set(name_to_value.values()))
|
|
||||||
unknown = (
|
|
||||||
connection.execute(
|
|
||||||
text("select distinct status from execution_attempt where status not in :values").bindparams(
|
|
||||||
bindparam("values", recognised, expanding=True)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
if unknown:
|
|
||||||
raise RuntimeError(f"execution_attempt.status carries unrecognised spellings: {sorted(unknown)}")
|
|
||||||
|
|
||||||
rewritten = 0
|
|
||||||
for name, value in sorted(name_to_value.items()):
|
|
||||||
if name == value:
|
|
||||||
continue
|
|
||||||
count = connection.execute(
|
|
||||||
text("select count(*) from execution_attempt where status = :name"),
|
|
||||||
{"name": name},
|
|
||||||
).scalar_one()
|
|
||||||
if not count:
|
|
||||||
continue
|
|
||||||
print(f" {name} -> {value}: {count} row(s)")
|
|
||||||
rewritten += count
|
|
||||||
if dry_run:
|
|
||||||
continue
|
|
||||||
connection.execute(
|
|
||||||
text("update execution_attempt set status = :value where status = :name"),
|
|
||||||
{"name": name, "value": value},
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f" rewritten={rewritten}")
|
|
||||||
return rewritten
|
|
||||||
|
|
||||||
|
|
||||||
def strip_job_source_columns(connection: Connection, *, dry_run: bool) -> int:
|
|
||||||
"""Step 4: drop the evidence columns from ``job_source``.
|
|
||||||
|
|
||||||
Uses ``ALTER TABLE ... DROP COLUMN``, supported by SQLite 3.35+ and by
|
|
||||||
PostgreSQL. Idempotent: a column that is already gone is skipped.
|
|
||||||
"""
|
|
||||||
inspector = sqlalchemy_inspect(connection)
|
|
||||||
present = {column["name"] for column in inspector.get_columns("job_source")}
|
|
||||||
targets = [name for name in JOB_SOURCE_DROPPED_COLUMNS if name in present]
|
|
||||||
if not targets:
|
|
||||||
print(" all evidence columns already dropped")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
print(f" dropping {len(targets)} column(s): {', '.join(targets)}")
|
|
||||||
if dry_run:
|
|
||||||
return len(targets)
|
|
||||||
|
|
||||||
for name in targets:
|
|
||||||
connection.execute(text(f'alter table "job_source" drop column "{name}"'))
|
|
||||||
return len(targets)
|
|
||||||
|
|
||||||
|
|
||||||
def migrate(*, settings: Settings, artifact_dir: Path, dry_run: bool, strict_counts: bool) -> None:
|
|
||||||
"""Apply every V4.7 migration step in order."""
|
|
||||||
engine = create_engine(_sync_url(settings))
|
|
||||||
try:
|
|
||||||
with engine.begin() as connection:
|
|
||||||
_preflight(connection, strict=strict_counts)
|
|
||||||
|
|
||||||
print("\nStep 1: rotate stored images")
|
|
||||||
rotate_stored_images(connection, dry_run=dry_run)
|
|
||||||
|
|
||||||
print(f"\nStep 2: drop {ARTIFACT_TABLE}")
|
|
||||||
drop_processing_artifacts(connection, artifact_dir, dry_run=dry_run)
|
|
||||||
|
|
||||||
print("\nStep 3: normalize execution_attempt.status spelling")
|
|
||||||
normalize_attempt_status(connection, dry_run=dry_run)
|
|
||||||
|
|
||||||
print("\nStep 4: strip evidence columns from job_source")
|
|
||||||
strip_job_source_columns(connection, dry_run=dry_run)
|
|
||||||
finally:
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print("\nDry run: nothing was written.")
|
|
||||||
else:
|
|
||||||
print("\nDone.")
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Sequence[str] | None = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
||||||
parser.add_argument("--dry-run", action="store_true", help="Report what would change without writing")
|
|
||||||
parser.add_argument(
|
|
||||||
"--allow-count-mismatch",
|
|
||||||
action="store_true",
|
|
||||||
help="Warn instead of aborting when row counts differ from the recorded V4.6 snapshot",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--artifact-dir",
|
|
||||||
type=Path,
|
|
||||||
default=DEFAULT_ARTIFACT_DIR,
|
|
||||||
help="Directory that held external artifact files before V4.7",
|
|
||||||
)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
print(f"Target: {_sync_url(settings)}")
|
|
||||||
|
|
||||||
migrate(
|
|
||||||
settings=settings,
|
|
||||||
artifact_dir=args.artifact_dir,
|
|
||||||
dry_run=args.dry_run,
|
|
||||||
strict_counts=not args.allow_count_mismatch,
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
"""One-time migration for V4.8 queue-membership uniqueness.
|
|
||||||
|
|
||||||
This migration enforces the V4 requirement that each ``(job_id, source_id)``
|
|
||||||
pair appears at most once in ``job_source``.
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
1. Detect duplicate ``job_source`` rows per ``(job_id, source_id)``.
|
|
||||||
2. Keep one row per pair (prefer the row with the latest attempt evidence).
|
|
||||||
3. Re-point ``execution_attempt.job_source_id`` from removed duplicates to the
|
|
||||||
kept row.
|
|
||||||
4. Delete duplicate ``job_source`` rows.
|
|
||||||
5. Add uniqueness enforcement for ``(job_id, source_id)``.
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
python tools/migrate_v47_to_v48.py --dry-run
|
|
||||||
python tools/migrate_v47_to_v48.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
from collections import defaultdict
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import bindparam
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy import update
|
|
||||||
from sqlalchemy.engine import Connection
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
from transcription.config import Settings
|
|
||||||
from transcription.config import get_settings
|
|
||||||
from transcription.db import models as _models # noqa: F401 (registers metadata tables)
|
|
||||||
from transcription.db.engine import get_database_url
|
|
||||||
|
|
||||||
UNIQUE_NAME = "uq_job_source_job_source"
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_url(settings: Settings) -> str:
|
|
||||||
"""Return a sync URL for direct SQLAlchemy Core access."""
|
|
||||||
return get_database_url(settings).replace("+aiosqlite", "").replace("+asyncpg", "").replace("+psycopg", "")
|
|
||||||
|
|
||||||
|
|
||||||
def _uniqueness_already_enforced(connection: Connection) -> bool:
|
|
||||||
inspector = sqlalchemy_inspect(connection)
|
|
||||||
unique_constraints = inspector.get_unique_constraints("job_source")
|
|
||||||
if any(constraint.get("name") == UNIQUE_NAME for constraint in unique_constraints):
|
|
||||||
return True
|
|
||||||
indexes = inspector.get_indexes("job_source")
|
|
||||||
return any(index.get("name") == UNIQUE_NAME and index.get("unique") is True for index in indexes)
|
|
||||||
|
|
||||||
|
|
||||||
def _choose_keeper(
|
|
||||||
candidates: list[dict[str, object]],
|
|
||||||
) -> dict[str, object]:
|
|
||||||
"""Keep the row with latest attempt activity, then lexicographically greatest UUID."""
|
|
||||||
|
|
||||||
def key(item: dict[str, object]) -> tuple[datetime, str]:
|
|
||||||
latest_attempt = item["latest_attempt_at"]
|
|
||||||
attempt_key = latest_attempt if isinstance(latest_attempt, datetime) else datetime.min.replace(tzinfo=UTC)
|
|
||||||
return (attempt_key, str(item["job_source_id"]))
|
|
||||||
|
|
||||||
return max(candidates, key=key)
|
|
||||||
|
|
||||||
|
|
||||||
def deduplicate_job_source_membership(connection: Connection, *, dry_run: bool) -> tuple[int, int]:
|
|
||||||
"""Return ``(pairs_deduplicated, rows_deleted)``."""
|
|
||||||
job_source = SQLModel.metadata.tables["job_source"]
|
|
||||||
execution_attempt = SQLModel.metadata.tables["execution_attempt"]
|
|
||||||
|
|
||||||
duplicate_pairs = (
|
|
||||||
connection.execute(
|
|
||||||
select(job_source.c.job_id, job_source.c.source_id)
|
|
||||||
.group_by(job_source.c.job_id, job_source.c.source_id)
|
|
||||||
.having(func.count(job_source.c.id) > 1)
|
|
||||||
)
|
|
||||||
.mappings()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
if not duplicate_pairs:
|
|
||||||
return (0, 0)
|
|
||||||
|
|
||||||
rows_by_pair: dict[tuple[UUID, UUID], list[dict[str, object]]] = defaultdict(list)
|
|
||||||
duplicate_memberships = (
|
|
||||||
connection.execute(
|
|
||||||
select(
|
|
||||||
job_source.c.id.label("job_source_id"),
|
|
||||||
job_source.c.job_id,
|
|
||||||
job_source.c.source_id,
|
|
||||||
func.max(execution_attempt.c.created_at).label("latest_attempt_at"),
|
|
||||||
)
|
|
||||||
.select_from(job_source.outerjoin(execution_attempt, execution_attempt.c.job_source_id == job_source.c.id))
|
|
||||||
.group_by(job_source.c.id, job_source.c.job_id, job_source.c.source_id)
|
|
||||||
)
|
|
||||||
.mappings()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
duplicate_key_set = {(row["job_id"], row["source_id"]) for row in duplicate_pairs}
|
|
||||||
for row in duplicate_memberships:
|
|
||||||
key = (row["job_id"], row["source_id"])
|
|
||||||
if key in duplicate_key_set:
|
|
||||||
rows_by_pair[key].append(dict(row))
|
|
||||||
|
|
||||||
rows_deleted = 0
|
|
||||||
for key, rows in sorted(rows_by_pair.items(), key=lambda item: (str(item[0][0]), str(item[0][1]))):
|
|
||||||
keeper = _choose_keeper(rows)
|
|
||||||
keeper_id = keeper["job_source_id"]
|
|
||||||
duplicate_ids = [row["job_source_id"] for row in rows if row["job_source_id"] != keeper_id]
|
|
||||||
rows_deleted += len(duplicate_ids)
|
|
||||||
print(
|
|
||||||
f" dedupe pair job_id={key[0]} source_id={key[1]} "
|
|
||||||
f"keep={keeper_id} drop={','.join(str(item) for item in duplicate_ids)}"
|
|
||||||
)
|
|
||||||
if dry_run or not duplicate_ids:
|
|
||||||
continue
|
|
||||||
connection.execute(
|
|
||||||
update(execution_attempt)
|
|
||||||
.where(execution_attempt.c.job_source_id.in_(bindparam("duplicate_ids", expanding=True)))
|
|
||||||
.values(job_source_id=keeper_id),
|
|
||||||
{"duplicate_ids": duplicate_ids},
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
job_source.delete().where(job_source.c.id.in_(bindparam("duplicate_ids", expanding=True))),
|
|
||||||
{"duplicate_ids": duplicate_ids},
|
|
||||||
)
|
|
||||||
|
|
||||||
return (len(duplicate_pairs), rows_deleted)
|
|
||||||
|
|
||||||
|
|
||||||
def add_job_source_uniqueness(connection: Connection, *, dry_run: bool) -> None:
|
|
||||||
"""Create uniqueness enforcement for ``(job_id, source_id)``."""
|
|
||||||
if _uniqueness_already_enforced(connection):
|
|
||||||
print(f" uniqueness already enforced ({UNIQUE_NAME})")
|
|
||||||
return
|
|
||||||
|
|
||||||
dialect = connection.dialect.name
|
|
||||||
if dialect == "postgresql":
|
|
||||||
statement = text(f'alter table "job_source" add constraint "{UNIQUE_NAME}" unique ("job_id", "source_id")')
|
|
||||||
else:
|
|
||||||
statement = text(f'create unique index "{UNIQUE_NAME}" on "job_source" ("job_id", "source_id")')
|
|
||||||
print(f" applying {UNIQUE_NAME}")
|
|
||||||
if not dry_run:
|
|
||||||
connection.execute(statement)
|
|
||||||
|
|
||||||
|
|
||||||
def migrate(*, settings: Settings, dry_run: bool) -> None:
|
|
||||||
engine = create_engine(_sync_url(settings))
|
|
||||||
try:
|
|
||||||
with engine.begin() as connection:
|
|
||||||
print("Step 1: deduplicate job_source membership")
|
|
||||||
pair_count, rows_deleted = deduplicate_job_source_membership(connection, dry_run=dry_run)
|
|
||||||
print(f" duplicate pairs={pair_count} rows_deleted={rows_deleted}")
|
|
||||||
|
|
||||||
print("Step 2: enforce unique (job_id, source_id)")
|
|
||||||
add_job_source_uniqueness(connection, dry_run=dry_run)
|
|
||||||
finally:
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
print("\nDry run: nothing was written.")
|
|
||||||
else:
|
|
||||||
print("\nDone.")
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Sequence[str] | None = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("--dry-run", action="store_true", help="Report planned changes without writing")
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
print(f"Target: {_sync_url(settings)}")
|
|
||||||
migrate(settings=settings, dry_run=args.dry_run)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
Reference in New Issue
Block a user