phase: 163_pdf_strip_additive_selfheal
Build and Push Containers / build-and-push-app (push) Successful in 2m3s
Build and Push Containers / build-and-push-db (push) Successful in 21s

All gates verified. Final report:

**Phase 163 — final verification pass (all 5 tasks already complete; verification-only run)**

- Verified D1–D4 in code: `page_strip_mode` column + migration 0027, `DocContent` absent-never-null rule keyed off `page_count`; `_persist_pdf_pages` render → atomic tmp+`os.replace` writes → stale-tail-last + `strip_class` stamp; `_regate_pdf_strip` on both unchanged paths (mirror gate, heal, repair, fail-soft, one INFO line only when acting); viewer mode branch + Text/Pages toggle (Text default, `aria-pressed`, no innerHTML, lazy imgs).
- `uv run pytest --cov=app --cov-report=term-missing` → **3796 tests, exit 0, coverage 99%** (>90%)
- `uv run pytest tests/e2e/test_pdf_strip_additive_selfheal.py -v --no-cov` → **5 passed** in isolation
- `test_pdf_pages_in_sources + test_pictorial_pdf + test_garbage_pdf` → 10 passed; `test_document_viewer(_context) + test_image_documents` → 20 passed
- `uv run ruff check .` clean; `uv run pyright` → 0 errors; alembic **0027 downgrade→upgrade CLI round-trip clean** (+ integration pins)
- Completion criteria: additive view w/ toggle ✅ · primary DOM byte-identity ✅ · write-first order/mid-write-failure/atomic pins ✅ · heal + repair E2E ✅ · zero vision/LLM on fast path (mock-captured) ✅ · non-qualifying payload byte-identity ✅ · docs pass ✅
- **Documentation**: README.md + AGENTS.md reviewed in full — the phase's updates are accurate; all config-table defaults cross-checked against `app/config.py` (no drift), every page route, script, and E2E command referenced exists, every `.env.example` var documented — **verified consistent, no changes needed**.
- Notable: live `Letumdei` repair is owner-runtime post-deploy per D5 (next production Sync self-heals; no live changes made); the modified `.agents/screenshots/doc_modal_scrollbar_1280.png` is an E2E-regenerated artifact left for the pipeline commit.
- **Next pending phase:** `164_chat_error_honesty`
This commit is contained in:
2026-10-07 13:57:30 -04:00
parent 74eb2e265c
commit 6ba46e735a
52 changed files with 4729 additions and 212 deletions
+83 -3
View File
@@ -10,7 +10,10 @@ per phase 124 A4) and the phase-148 (task 02) PDF page route
(``GET /api/documents/{id}/pages/{n}`` — the same trust model and 404
posture for a page-qualifying document's rendered page PNGs) + the
content endpoint's ``image_url`` / ``page_urls`` keys (present for the
qualifying row, ABSENT otherwise — never ``null``).
qualifying row, ABSENT otherwise — never ``null``), plus the phase-163
(task 01) ``page_strip_mode`` class signal (``"primary"`` /``"additive"``
for a stamped strip, ABSENT for a pre-phase row and for every
non-qualifying document).
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient.
"""
@@ -1381,7 +1384,8 @@ def test_content_endpoint_exposes_the_image_affordance(
# ---------------------------------------------------------------------------
# Phase 148 (task 02) — the PDF page BYTES route + the content payload's
# ``page_urls`` affordance
# ``page_urls`` affordance (phase 163 task 01 adds the ``page_strip_mode``
# class signal beside it)
# ---------------------------------------------------------------------------
@@ -1404,6 +1408,7 @@ def _seed_page_doc(
path: str = "besta.pdf",
page_count: int | None = 2,
write_copies: bool = True,
page_strip_mode: str | None = None,
) -> Document:
"""One PDF document row with ``page_count`` + its rendered page
copies (the importer's ``pdf_page_dir`` layout —
@@ -1411,7 +1416,8 @@ def _seed_page_doc(
sentinel bytes, so a page mix-up between the two copies is caught.
``page_count=None`` is a non-qualifying PDF (no copies);
``write_copies=False`` leaves a qualifying row without its files
(the lost-copy corner)."""
(the lost-copy corner); ``page_strip_mode`` (phase 163) stamps the
strip CLASS — ``None`` is the pre-phase row."""
doc = Document(
id=uuid.uuid4(),
source=source,
@@ -1424,6 +1430,7 @@ def _seed_page_doc(
created_at=datetime(2026, 1, 1, tzinfo=UTC),
summary="IKEA BESTA frame assembly manual (page descriptions).",
page_count=page_count,
page_strip_mode=page_strip_mode,
)
db.add(doc)
db.flush()
@@ -1610,6 +1617,79 @@ def test_content_endpoint_exposes_the_page_urls_affordance(
)
assert r.status_code == 200
assert "page_urls" not in r.json() # absent — never null (non-qualifying PDF)
# Phase 163 (task 01): the class key follows the same rule — a
# pre-phase row (mode NULL) omits it while keeping page_urls.
assert "page_strip_mode" not in r.json()
finally:
_cleanup_kb(db)
@pytest.mark.parametrize("mode", ["primary", "additive"])
def test_content_endpoint_carries_the_strip_class(
admin_client: TestClient, db, tmp_path: Path, mode: str
) -> None:
"""Phase 163 (task 01, LOCKED A2/A3) — the real-DB round-trip of the
strip CLASS: a stamped row (``page_strip_mode`` = ``"primary"`` — the
phase-133 fallback class, the strip IS the view — or ``"additive"`` —
the phase-140 pictorial class, text + a Text/Pages toggle) serves the
class VERBATIM beside the untouched ``page_urls`` list (the viewer's
branch signal, task 04); every other key stands where phase 148 left
it."""
_cleanup_kb(db)
try:
pages_dir = tmp_path / "pages"
_seed_page_doc(db, pages_dir, page_count=3, page_strip_mode=mode)
r = admin_client.get(
"/api/documents/content", params={"source": "PdfSrc", "path": "besta.pdf"}
)
assert r.status_code == 200
body = r.json()
assert body["page_strip_mode"] == mode
assert len(body["page_urls"]) == 3 # the strip rides along, unchanged
finally:
_cleanup_kb(db)
def test_content_strip_class_never_travels_without_the_strip(
admin_client: TestClient, db, tmp_path: Path
) -> None:
"""Phase 163 (task 01) — the two strip keys are ONE block keyed off
``page_count``: a row with a stray ``page_strip_mode`` and no strip
(``page_count`` NULL — the corner a partial repair could leave)
serializes NEITHER key, byte-identically to a plain non-qualifying
document (the viewer must never be handed a class for pages that do
not exist)."""
_cleanup_kb(db)
try:
pages_dir = tmp_path / "pages"
_seed_page_doc(
db,
pages_dir,
path="stray.pdf",
page_count=None,
write_copies=False,
page_strip_mode="additive",
)
r = admin_client.get(
"/api/documents/content", params={"source": "PdfSrc", "path": "stray.pdf"}
)
assert r.status_code == 200
body = r.json()
assert "page_strip_mode" not in body
assert "page_urls" not in body
# The exact pre-phase key set (the byte-identity pin).
assert set(body) == {
"source",
"path",
"title",
"format",
"summary",
"created_at",
"content",
"indexed_at",
"chunks",
"is_image",
}
finally:
_cleanup_kb(db)
+370
View File
@@ -0,0 +1,370 @@
"""Integration: migration 0027 (documents.page_strip_mode) schema contract
(phase 163, task 01).
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0026.py`` (information_schema assertions on the state
the migration must leave). The tests target the 0026 → 0027 step
explicitly so later migrations cannot break the pins:
* upgrade 0026 → 0027 → ``page_strip_mode`` exists with the full
contract — VARCHAR(16), NULLABLE, NO server default (NULL = "no strip"
for every pre-phase row, every non-qualifying document and every
fail-soft miss) and UNINDEXED (a display fact, never a query surface —
the content endpoint reads it from the ROW, the ``file_hash`` /
``page_count`` convention) — while the 0026 ``documents`` schema
survives, in particular ``page_count`` keeping its 0026 contract and
``content_hash`` staying NOT NULL and INDEXED;
* rows inserted while the DB is at 0026 backfill ``page_strip_mode`` to
NULL — including a row whose ``page_count`` IS set (the pre-phase strip
with no stamped class: the wire omits the key and the viewer keeps
today's "primary" DOM until the task-03 re-gate stamps it);
* the ORM contract agrees: a freshly inserted ``Document`` with no mode
reads ``page_strip_mode is None`` through a fresh session, and each of
the two classes (``"primary"`` / ``"additive"``) round-trips verbatim;
* downgrade to 0026 → the column is GONE (A13) while the row + its
content + its ``page_count`` survive; upgrade back to 0027 → the column
is back (round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted.
"""
from __future__ import annotations
import hashlib
import uuid
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.orm import Session
from alembic import command
from app.db import SessionLocal, db_available
from app.models import Document
SOURCE = "mig0027"
CONTENT = "content here"
CONTENT_HASH = hashlib.sha256(CONTENT.encode()).hexdigest()
@pytest.fixture()
def alembic(db: Session) -> Iterator[Config]:
"""Real Alembic config bound to the dev DB (URL from app settings).
Starts at head (repairs an interrupted earlier run); teardown
upgrades to head no matter what happened, so the dev DB is never
left below head.
"""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
cfg.set_main_option("script_location", "alembic")
command.upgrade(cfg, "head")
try:
yield cfg
finally:
# Release the test session's open transaction BEFORE the repair
# DDL: an idle-in-transaction SELECT holds an ACCESS SHARE lock
# on ``documents``, which would deadlock the repair's
# ``ALTER TABLE`` forever.
db.rollback()
command.upgrade(cfg, "head")
def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default, character_maximum_length)
for one documents column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default, character_maximum_length"
" FROM information_schema.columns"
" WHERE table_name = 'documents' AND column_name = :c"
),
{"c": column},
).fetchone()
return tuple(row) if row is not None else None
def _index_covering(db: Session, column: str) -> str | None:
"""The name of any documents index whose definition mentions the
column (None = UNINDEXED)."""
return db.execute(
text(
"SELECT indexname FROM pg_indexes"
" WHERE tablename = 'documents' AND indexdef ILIKE :like"
),
{"like": f"%{column}%"},
).scalar()
def _insert_sql(db: Session, path: str, page_count: int | None = None) -> uuid.UUID:
"""Insert one documents row with the PRE-0027 column set (the 0026
shape — the mode column, when present, is omitted so a NULL backfill
is what the row reads; ``page_count`` is 0026's own column and may be
set to model a pre-phase strip)."""
doc_id = uuid.uuid4()
db.execute(
text(
"INSERT INTO documents (id, source, path, full_path, title, content,"
" content_hash, page_count)"
" VALUES (:id, :s, :p, :fp, :t, :c, :h, :pc)"
),
{
"id": doc_id,
"s": SOURCE,
"p": path,
"fp": f"/tmp/{path}",
"t": f"Doc {path}",
"c": CONTENT,
"h": CONTENT_HASH,
"pc": page_count,
},
)
db.commit()
return doc_id
def _delete(db: Session, doc_id: uuid.UUID) -> None:
db.execute(text("DELETE FROM documents WHERE id = :id"), {"id": doc_id})
db.commit()
def _assert_0026_schema_survives(db: Session) -> None:
"""The 0026 documents contract must survive the additive upgrade —
``page_count`` keeps its INTEGER / NULLABLE / NO-default / UNINDEXED
shape (the sibling column this one rides) and ``content_hash`` stays
NOT NULL and indexed (the retriever's identity)."""
page_count = _column(db, "page_count")
assert page_count is not None, "documents.page_count (0026) must survive the upgrade"
assert page_count[0] == "integer", "page_count must stay INTEGER"
assert page_count[1] == "YES", "page_count must stay NULLABLE"
assert page_count[2] is None, "page_count must keep NO server default"
assert _index_covering(db, "page_count") is None, "page_count must stay UNINDEXED"
content = _column(db, "content_hash")
assert content is not None and content[0] == "character varying" and content[1] == "NO", (
"documents.content_hash (0001) must keep its contract"
)
assert (
_index_covering(db, "content_hash") is not None
), "the ix_documents_content_hash index must survive the upgrade"
indexed = _column(db, "indexed_at")
assert indexed is not None and indexed[0] == "timestamp with time zone", (
"documents.indexed_at (0001) must survive the upgrade"
)
is_image = _column(db, "is_image")
assert is_image is not None and is_image[0] == "boolean" and is_image[1] == "NO", (
"documents.is_image (0022) must survive the upgrade"
)
def test_upgrade_to_0027_adds_page_strip_mode(db: Session, alembic: Config) -> None:
"""Upgrade 0026 → 0027: ``page_strip_mode`` exists with the full
contract (VARCHAR(16), NULLABLE, NO server default — NULL = "no
strip" — and UNINDEXED: a display fact, never a query surface), is
ABSENT at 0026, and every pre-0027 row backfills it to NULL —
including the row whose ``page_count`` IS set (the pre-phase strip
with no stamped class), while the 0026 table contract (the
``page_count`` sibling, the indexed ``content_hash``) survives.
"""
command.downgrade(alembic, "0026") # start from the pre-0027 state
assert _version(db) == "0026"
assert _column(db, "page_strip_mode") is None, "page_strip_mode must be absent at 0026"
plain_id = _insert_sql(db, "plain.pdf") # the 0026 column set, no strip
pre_strip_id = _insert_sql(db, "prephase.pdf", page_count=36) # a live pre-phase strip
try:
command.upgrade(alembic, "0027")
assert _version(db) == "0027", "alembic_version must be at 0027"
col = _column(db, "page_strip_mode")
assert col is not None, "documents.page_strip_mode is missing"
assert col[0] == "character varying", "page_strip_mode must be VARCHAR"
assert col[3] == 16, "page_strip_mode must be VARCHAR(16) — the two class names"
assert col[1] == "YES", (
"page_strip_mode must be NULLABLE (NULL = no strip, and the pre-phase shape)"
)
assert col[2] is None, (
"page_strip_mode must carry NO server default — NULL is the meaningful"
" pre-phase state"
)
assert _index_covering(db, "page_strip_mode") is None, (
"page_strip_mode must be UNINDEXED — a display fact, never a query"
" surface (the content endpoint reads it from the row)"
)
# Every pre-0027 row backfills to NULL — no backfill, no default.
assert (
db.execute(
text("SELECT page_count, page_strip_mode FROM documents WHERE id = :id"),
{"id": plain_id},
).fetchone()
== (None, None)
), "a pre-0027 row without a strip must read both columns NULL"
assert (
db.execute(
text("SELECT page_count, page_strip_mode FROM documents WHERE id = :id"),
{"id": pre_strip_id},
).fetchone()
== (36, None)
), (
"a pre-phase strip (page_count set) must backfill page_strip_mode to"
" NULL — the wire omits the key and the viewer keeps today's DOM"
)
_assert_0026_schema_survives(db)
finally:
_delete(db, plain_id)
_delete(db, pre_strip_id)
@pytest.mark.parametrize("mode", ["primary", "additive"])
def test_orm_page_strip_mode_round_trips(db: Session, alembic: Config, mode: str) -> None:
"""The ORM contract agrees with the column contract: each class
(``"primary"`` = the fallback class, ``"additive"`` = the pictorial
class) round-trips VERBATIM through a fresh session, and a row
created with no mode reads ``page_strip_mode is None`` (the
pre-phase shape — NULL until the importer stamps it)."""
command.upgrade(alembic, "head")
stamped = Document(
source=SOURCE,
path="stamped.pdf",
full_path="/tmp/stamped.pdf",
title="stamped",
content=CONTENT,
content_hash=CONTENT_HASH,
page_count=36,
page_strip_mode=mode,
)
unstamped = Document(
source=SOURCE,
path="unstamped.md",
full_path="/tmp/unstamped.md",
title="unstamped",
content=CONTENT,
content_hash=CONTENT_HASH,
)
db.add(stamped)
db.add(unstamped)
db.commit()
try:
with SessionLocal() as fresh:
reloaded = fresh.get(Document, stamped.id)
assert reloaded is not None, "the stamped row must be readable"
assert reloaded.page_strip_mode == mode, (
f"the {mode!r} class must round-trip through the DB verbatim"
)
assert reloaded.page_count == 36, "the sibling page_count must round-trip too"
reloaded_unstamped = fresh.get(Document, unstamped.id)
assert reloaded_unstamped is not None, "the unstamped row must be readable"
assert reloaded_unstamped.page_strip_mode is None, (
"a row created without a mode must read NULL (the pre-phase shape)"
)
assert reloaded_unstamped.content_hash == CONTENT_HASH, (
"content_hash must be untouched by the page_strip_mode addition"
)
finally:
_delete(db, stamped.id)
_delete(db, unstamped.id)
def test_downgrade_to_0026_drops_the_column(db: Session, alembic: Config) -> None:
"""Downgrade 0027 → 0026: the page_strip_mode column is gone (A13 —
fully reversible, the class is re-derivable from the source file by
the deterministic re-gate) while the row + its content + its 0026
``page_count`` survive, and the rest of the table contract is intact."""
command.upgrade(alembic, "head")
row = Document(
source=SOURCE,
path="down.pdf",
full_path="/tmp/down.pdf",
title="down",
content=CONTENT,
content_hash=CONTENT_HASH,
page_count=36,
page_strip_mode="additive",
)
db.add(row)
db.commit()
try:
command.downgrade(alembic, "0026")
assert _version(db) == "0026"
assert _column(db, "page_strip_mode") is None, "page_strip_mode must be dropped"
surviving = db.execute(
text(
"SELECT source, path, content, content_hash, page_count, is_image"
" FROM documents WHERE id = :id"
),
{"id": row.id},
).fetchone()
assert surviving is not None and surviving[0] == SOURCE, (
"the row must survive the column drop"
)
assert surviving[1] == "down.pdf" and surviving[2] == CONTENT, (
"path + content must survive the drop"
)
assert surviving[3] == CONTENT_HASH, (
"content_hash (the retriever's identity) must survive the drop"
)
assert surviving[4] == 36, (
"page_count (the 0026 sibling) must survive the drop — the strip"
" itself is untouched by this migration's reversal"
)
assert surviving[5] is False, "is_image must survive the drop"
assert _index_covering(db, "content_hash") is not None, (
"the content_hash index must survive the downgrade"
)
finally:
_delete(db, row.id)
# Repair: the fixture teardown re-upgrades to head.
def test_upgrade_round_trip_restores_the_column(db: Session, alembic: Config) -> None:
"""Downgrade to 0026, then upgrade back to 0027: ``page_strip_mode``
is back with the full contract (VARCHAR(16), NULLABLE, NO server
default, UNINDEXED), a row seeded at 0026 — strip included — reads
the mode as NULL (the pre-phase shape on the round-trip), and the
0026 schema (the ``page_count`` sibling, the indexed
``content_hash``) is intact."""
command.downgrade(alembic, "0026")
pre_id = _insert_sql(db, "round.pdf", page_count=36)
try:
command.upgrade(alembic, "0027")
assert _version(db) == "0027", "round-trip upgrade must land at 0027"
col = _column(db, "page_strip_mode")
assert col is not None, "documents.page_strip_mode must be back"
assert col[0] == "character varying", "page_strip_mode must be VARCHAR after the round-trip"
assert col[3] == 16, "page_strip_mode must be VARCHAR(16) after the round-trip"
assert col[1] == "YES", "page_strip_mode must be NULLABLE after the round-trip"
assert col[2] is None, (
"page_strip_mode must still carry NO server default after the round-trip"
)
assert _index_covering(db, "page_strip_mode") is None, (
"page_strip_mode must stay UNINDEXED after the round-trip"
)
assert (
db.execute(
text("SELECT page_count, page_strip_mode FROM documents WHERE id = :id"),
{"id": pre_id},
).fetchone()
== (36, None)
), (
"a row seeded at 0026 must read page_strip_mode NULL after the"
" round-trip while its page_count stands"
)
_assert_0026_schema_survives(db)
finally:
_delete(db, pre_id)